overmark 0.1.2 copy "overmark: ^0.1.2" to clipboard
overmark: ^0.1.2 copied to clipboard

Spotlight coach marks for Flutter onboarding tours. Dims the screen, highlights any widget through a cut-out, and explains it in a fully customizable card. Zero dependencies.

example/lib/main.dart

import 'dart:ui';

import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:overmark/overmark.dart';
import 'package:shared_preferences/shared_preferences.dart';

void main() {
  WidgetsFlutterBinding.ensureInitialized();
  SystemChrome.setSystemUIOverlayStyle(
    const SystemUiOverlayStyle(
      statusBarColor: Colors.transparent,
      statusBarIconBrightness: Brightness.dark,
    ),
  );
  runApp(const OvermarkExampleApp());
}

/// Persists the "already seen" flag across launches.
///
/// Overmark ships without dependencies, so this adapter lives in your app.
class PrefsOvermarkStorage implements OvermarkStorage {
  String _key(String tourId) => 'overmark_$tourId';

  @override
  Future<bool> hasShown(String tourId) async =>
      (await SharedPreferences.getInstance()).getBool(_key(tourId)) ?? false;

  @override
  Future<void> markShown(String tourId) async =>
      (await SharedPreferences.getInstance()).setBool(_key(tourId), true);

  Future<void> reset(String tourId) async =>
      (await SharedPreferences.getInstance()).remove(_key(tourId));
}

class _Block {
  const _Block({
    required this.time,
    required this.label,
    required this.minutes,
    this.active = false,
  });

  final String time;
  final String label;
  final int minutes;
  final bool active;
}

const List<_Block> _today = <_Block>[
  _Block(time: '09:00', label: 'Inbox zero', minutes: 25),
  _Block(time: '09:40', label: 'Deep work', minutes: 50, active: true),
  _Block(time: '11:00', label: 'Design review', minutes: 30),
  _Block(time: '14:00', label: 'Write RFC', minutes: 50),
  _Block(time: '16:30', label: 'Walk', minutes: 20),
];

/// Strong ease-out — starts fast, settles soft (Emil / animations.dev).
const Curve _easeOut = Cubic(0.23, 1.0, 0.32, 1.0);

/// Strong ease-in-out for on-screen morphs.
const Curve _easeInOut = Cubic(0.77, 0.0, 0.175, 1.0);

/// Still — a quiet focus timer used to demo Overmark.
class OvermarkExampleApp extends StatefulWidget {
  const OvermarkExampleApp({super.key});

  @override
  State<OvermarkExampleApp> createState() => _OvermarkExampleAppState();
}

class _OvermarkExampleAppState extends State<OvermarkExampleApp> {
  ThemeMode _mode = ThemeMode.light;

  static const Color _ink = Color(0xFF0E0E0C);
  static const Color _paper = Color(0xFFF5F4F0);
  static const Color _accent = Color(0xFF2563EB);

  ThemeData _theme(Brightness brightness) {
    final bool dark = brightness == Brightness.dark;
    final ColorScheme scheme = ColorScheme(
      brightness: brightness,
      primary: _accent,
      onPrimary: Colors.white,
      secondary: dark ? const Color(0xFF9AA3B2) : const Color(0xFF5C6570),
      onSecondary: dark ? _ink : Colors.white,
      error: const Color(0xFFC23B3B),
      onError: Colors.white,
      surface: dark ? const Color(0xFF0C0D10) : _paper,
      onSurface: dark ? const Color(0xFFECECE8) : _ink,
    );

    // Platform system font (SF / Roboto / Segoe) — optical sizing, no custom face.
    return ThemeData(
      useMaterial3: true,
      colorScheme: scheme,
      scaffoldBackgroundColor: scheme.surface,
      textTheme: TextTheme(
        displayLarge: TextStyle(
          fontSize: 76,
          fontWeight: FontWeight.w600,
          letterSpacing: -3.2,
          height: 0.92,
          color: scheme.onSurface,
          fontFeatures: const <FontFeature>[FontFeature.tabularFigures()],
        ),
        titleLarge: TextStyle(
          fontSize: 22,
          fontWeight: FontWeight.w700,
          letterSpacing: -0.55,
          height: 1.1,
          color: scheme.onSurface,
        ),
        titleMedium: TextStyle(
          fontSize: 15,
          fontWeight: FontWeight.w600,
          letterSpacing: -0.2,
          color: scheme.onSurface,
        ),
        bodyMedium: TextStyle(
          fontSize: 15,
          height: 1.4,
          fontWeight: FontWeight.w400,
          letterSpacing: -0.1,
          color: scheme.onSurface.withAlpha(175),
        ),
        labelLarge: TextStyle(
          fontSize: 14,
          fontWeight: FontWeight.w600,
          letterSpacing: -0.15,
          color: scheme.onSurface,
        ),
        labelSmall: TextStyle(
          fontSize: 11,
          fontWeight: FontWeight.w600,
          letterSpacing: 0.35,
          color: scheme.onSurface.withAlpha(125),
        ),
      ),
      navigationBarTheme: NavigationBarThemeData(
        height: 68,
        backgroundColor: Colors.transparent,
        elevation: 0,
        indicatorColor: Colors.transparent,
        labelTextStyle: WidgetStateProperty.resolveWith(
          (Set<WidgetState> states) => TextStyle(
            fontSize: 11,
            letterSpacing: -0.1,
            fontWeight: states.contains(WidgetState.selected)
                ? FontWeight.w700
                : FontWeight.w500,
            color: states.contains(WidgetState.selected)
                ? scheme.onSurface
                : scheme.onSurface.withAlpha(120),
          ),
        ),
        iconTheme: WidgetStateProperty.resolveWith(
          (Set<WidgetState> states) => IconThemeData(
            size: 22,
            color: states.contains(WidgetState.selected)
                ? scheme.onSurface
                : scheme.onSurface.withAlpha(120),
          ),
        ),
      ),
      snackBarTheme: SnackBarThemeData(
        behavior: SnackBarBehavior.floating,
        backgroundColor: scheme.onSurface,
        contentTextStyle: TextStyle(
          color: scheme.surface,
          fontWeight: FontWeight.w500,
          letterSpacing: -0.1,
        ),
        shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
      ),
    );
  }

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Still',
      debugShowCheckedModeBanner: false,
      themeMode: _mode,
      theme: _theme(Brightness.light),
      darkTheme: _theme(Brightness.dark),
      home: HomePage(
        onToggleBrightness: () => setState(() {
          _mode = _mode == ThemeMode.light ? ThemeMode.dark : ThemeMode.light;
        }),
      ),
    );
  }
}

class HomePage extends StatefulWidget {
  const HomePage({required this.onToggleBrightness, super.key});

  final VoidCallback onToggleBrightness;

  @override
  State<HomePage> createState() => _HomePageState();
}

class _HomePageState extends State<HomePage> with TickerProviderStateMixin {
  static const String _tourId = 'home_tour_v1';

  final PrefsOvermarkStorage _storage = PrefsOvermarkStorage();
  final GlobalKey _searchKey = GlobalKey();
  final GlobalKey _scheduleKey = GlobalKey();
  final GlobalKey _logKey = GlobalKey();
  final GlobalKey _youKey = GlobalKey();

  late final AnimationController _enter;
  late final AnimationController _statusPulse;
  late final AnimationController _sessionBreath;

  int _tab = 0;
  int _activeBlock = 1;
  String _lastOutcome = '—';
  bool _running = false;
  bool _ctaPressed = false;

  @override
  void initState() {
    super.initState();
    _enter = AnimationController(
      vsync: this,
      duration: const Duration(milliseconds: 720),
    )..forward();
    _statusPulse = AnimationController(
      vsync: this,
      duration: const Duration(milliseconds: 2200),
    )..repeat(reverse: true);
    _sessionBreath = AnimationController(
      vsync: this,
      duration: const Duration(milliseconds: 2800),
    );
    WidgetsBinding.instance.addPostFrameCallback((_) => _showFirstRunTour());
  }

  @override
  void dispose() {
    _enter.dispose();
    _statusPulse.dispose();
    _sessionBreath.dispose();
    super.dispose();
  }

  List<OvermarkStep> get _steps => <OvermarkStep>[
        OvermarkStep(
          anchorKey: _searchKey,
          title: 'Jump to anything',
          message:
              'Find a project, note, or past session without leaving focus.',
        ),
        OvermarkStep(
          anchorKey: _scheduleKey,
          title: 'Today’s blocks',
          message:
              'Your day as a short timeline. Tap a block to load it into the timer.',
        ),
        OvermarkStep(
          anchorKey: _logKey,
          title: 'Session log',
          message:
              'Finished blocks land here — time spent, not streaks or badges.',
          shape: OvermarkShape.circle,
        ),
        OvermarkStep(
          anchorKey: _youKey,
          title: 'Preferences',
          message:
              'Timer length, sounds, and when Still should stay out of the way.',
          shape: OvermarkShape.circle,
        ),
      ];

  Future<void> _showFirstRunTour() async {
    final OvermarkOutcome outcome = await Overmark.showOnce(
      context,
      tourId: _tourId,
      storage: _storage,
      steps: _steps,
    );
    _record(outcome);
  }

  Future<void> _replayDefault() async {
    _record(await Overmark.show(context, steps: _steps));
  }

  Future<void> _replayCustom() async {
    _record(
      await Overmark.show(
        context,
        labels: const OvermarkLabels(
          next: 'Next',
          skip: 'Skip',
          done: 'Done',
        ),
        theme: OvermarkTheme(
          scrimColor: const Color(0xFF0A0A0A).withAlpha(200),
          spotlightBorderColor: const Color(0xFFFFFFFF).withAlpha(220),
          spotlightBorderWidth: 1.5,
          spotlightRadius: 14,
          spotlightPadding: const EdgeInsets.all(8),
          cardColor: const Color(0xFF171717),
          cardRadius: 16,
          cardPadding: const EdgeInsets.fromLTRB(18, 16, 18, 14),
          titleStyle: const TextStyle(
            fontSize: 17,
            fontWeight: FontWeight.w600,
            letterSpacing: -0.2,
            color: Color(0xFFF4F4F4),
          ),
          messageStyle: const TextStyle(
            fontSize: 13.5,
            height: 1.45,
            color: Color(0xFFA8A8A8),
          ),
          skipStyle: const TextStyle(
            fontSize: 13,
            fontWeight: FontWeight.w600,
            color: Color(0xFF7A7A7A),
          ),
          buttonColor: const Color(0xFF2563EB),
          buttonLabelStyle: const TextStyle(
            fontSize: 13,
            fontWeight: FontWeight.w600,
            color: Colors.white,
          ),
          buttonRadius: 10,
          indicatorActiveColor: const Color(0xFF2563EB),
          indicatorColor: const Color(0xFF3A3A3A),
        ),
        config: const OvermarkConfig(advanceOnBarrierTap: false),
        steps: _steps,
      ),
    );
  }

  Future<void> _resetFlag() async {
    await _storage.reset(_tourId);
    if (!mounted) return;
    ScaffoldMessenger.of(context).showSnackBar(
      const SnackBar(
        content: Text('Once-only flag cleared. Restart to autoplay.'),
      ),
    );
  }

  void _record(OvermarkOutcome outcome) {
    if (!mounted) return;
    setState(() => _lastOutcome = outcome.name);
  }

  void _toggleSession() {
    setState(() => _running = !_running);
    if (_running) {
      _sessionBreath.repeat(reverse: true);
    } else {
      _sessionBreath
        ..stop()
        ..value = 0;
    }
    HapticFeedback.lightImpact();
  }

  void _selectBlock(int index) {
    if (index == _activeBlock) return;
    setState(() {
      _activeBlock = index;
      _running = false;
    });
    _sessionBreath
      ..stop()
      ..value = 0;
    HapticFeedback.selectionClick();
  }

  Animation<double> _stagger(double start, double end) {
    return CurvedAnimation(
      parent: _enter,
      curve: Interval(start, end, curve: _easeOut),
    );
  }

  @override
  Widget build(BuildContext context) {
    final ColorScheme colors = Theme.of(context).colorScheme;
    final TextTheme text = Theme.of(context).textTheme;
    final bool dark = Theme.of(context).brightness == Brightness.dark;
    final MediaQueryData mq = MediaQuery.of(context);
    final bool reduceMotion = mq.disableAnimations;
    final _Block block = _today[_activeBlock];

    return AnnotatedRegion<SystemUiOverlayStyle>(
      value: SystemUiOverlayStyle(
        statusBarColor: Colors.transparent,
        statusBarIconBrightness: dark ? Brightness.light : Brightness.dark,
      ),
      child: Scaffold(
        backgroundColor: Colors.transparent,
        extendBody: true,
        body: Stack(
          children: <Widget>[
            // Atmospheric wash — not a flat fill.
            Positioned.fill(
              child: DecoratedBox(
                decoration: BoxDecoration(
                  gradient: LinearGradient(
                    begin: Alignment.topLeft,
                    end: Alignment.bottomRight,
                    colors: dark
                        ? const <Color>[
                            Color(0xFF12141A),
                            Color(0xFF0C0D10),
                            Color(0xFF10131A),
                          ]
                        : const <Color>[
                            Color(0xFFF8F7F3),
                            Color(0xFFF1F0EB),
                            Color(0xFFECEAE4),
                          ],
                    stops: const <double>[0.0, 0.45, 1.0],
                  ),
                ),
              ),
            ),
            // Soft radial accent — presence without decoration noise.
            Positioned(
              top: -80,
              right: -60,
              child: IgnorePointer(
                child: AnimatedBuilder(
                  animation: _sessionBreath,
                  builder: (BuildContext context, Widget? child) {
                    final double breath =
                        reduceMotion ? 0 : _sessionBreath.value;
                    return Opacity(
                      opacity: _running ? 0.22 + breath * 0.12 : 0.14,
                      child: Container(
                        width: 280 + breath * 24,
                        height: 280 + breath * 24,
                        decoration: BoxDecoration(
                          shape: BoxShape.circle,
                          gradient: RadialGradient(
                            colors: <Color>[
                              (_running
                                      ? const Color(0xFF1FA971)
                                      : colors.primary)
                                  .withAlpha(dark ? 70 : 55),
                              Colors.transparent,
                            ],
                          ),
                        ),
                      ),
                    );
                  },
                ),
              ),
            ),
            SafeArea(
              bottom: false,
              child: Column(
                children: <Widget>[
                  _FadeSlide(
                    animation: reduceMotion
                        ? const AlwaysStoppedAnimation<double>(1)
                        : _stagger(0.0, 0.35),
                    child: Padding(
                      padding: const EdgeInsets.fromLTRB(24, 8, 12, 0),
                      child: Row(
                        children: <Widget>[
                          Text('Still', style: text.titleLarge),
                          const SizedBox(width: 12),
                          _StatusChip(
                            running: _running,
                            pulse: _statusPulse,
                            reduceMotion: reduceMotion,
                          ),
                          const Spacer(),
                          _IconPress(
                            onPressed: widget.onToggleBrightness,
                            tooltip: 'Toggle brightness',
                            child: Icon(
                              dark
                                  ? Icons.wb_sunny_outlined
                                  : Icons.dark_mode_outlined,
                              size: 20,
                              color: colors.onSurface.withAlpha(180),
                            ),
                          ),
                        ],
                      ),
                    ),
                  ),
                  Expanded(
                    child: ListView(
                      padding: EdgeInsets.fromLTRB(
                        24,
                        32,
                        24,
                        28 + mq.padding.bottom + 72,
                      ),
                      children: <Widget>[
                        _FadeSlide(
                          animation: reduceMotion
                              ? const AlwaysStoppedAnimation<double>(1)
                              : _stagger(0.08, 0.42),
                          child: Column(
                            crossAxisAlignment: CrossAxisAlignment.start,
                            children: <Widget>[
                              Text(
                                'Tue · focus block',
                                style: text.labelSmall?.copyWith(
                                  letterSpacing: 0.8,
                                ),
                              ),
                              const SizedBox(height: 10),
                              AnimatedSwitcher(
                                duration: reduceMotion
                                    ? Duration.zero
                                    : const Duration(milliseconds: 220),
                                switchInCurve: _easeOut,
                                switchOutCurve: _easeOut,
                                transitionBuilder:
                                    (Widget child, Animation<double> anim) {
                                  return FadeTransition(
                                    opacity: anim,
                                    child: SlideTransition(
                                      position: Tween<Offset>(
                                        begin: const Offset(0, 0.06),
                                        end: Offset.zero,
                                      ).animate(anim),
                                      child: child,
                                    ),
                                  );
                                },
                                child: Text(
                                  _running ? '24:17' : '25:00',
                                  key: ValueKey<bool>(_running),
                                  style: text.displayLarge,
                                ),
                              ),
                              const SizedBox(height: 8),
                              AnimatedSwitcher(
                                duration: reduceMotion
                                    ? Duration.zero
                                    : const Duration(milliseconds: 200),
                                child: Text(
                                  '${block.label} · product spec',
                                  key: ValueKey<String>(block.label),
                                  style: text.bodyMedium,
                                ),
                              ),
                              const SizedBox(height: 26),
                              _StartButton(
                                running: _running,
                                pressed: _ctaPressed,
                                reduceMotion: reduceMotion,
                                onTapDown: () =>
                                    setState(() => _ctaPressed = true),
                                onTapUp: () {
                                  setState(() => _ctaPressed = false);
                                  _toggleSession();
                                },
                                onTapCancel: () =>
                                    setState(() => _ctaPressed = false),
                              ),
                            ],
                          ),
                        ),
                        const SizedBox(height: 40),
                        _FadeSlide(
                          animation: reduceMotion
                              ? const AlwaysStoppedAnimation<double>(1)
                              : _stagger(0.18, 0.52),
                          child: _SearchField(
                            key: _searchKey,
                            dark: dark,
                          ),
                        ),
                        const SizedBox(height: 36),
                        _FadeSlide(
                          animation: reduceMotion
                              ? const AlwaysStoppedAnimation<double>(1)
                              : _stagger(0.26, 0.58),
                          child: Column(
                            crossAxisAlignment: CrossAxisAlignment.start,
                            children: <Widget>[
                              Row(
                                children: <Widget>[
                                  Text('Today', style: text.titleMedium),
                                  const Spacer(),
                                  Text(
                                    '3 of 5 done',
                                    style: text.labelSmall,
                                  ),
                                ],
                              ),
                              const SizedBox(height: 10),
                              _ProgressTrack(
                                progress: 3 / 5,
                                dark: dark,
                                reduceMotion: reduceMotion,
                                enter: _enter,
                              ),
                              const SizedBox(height: 16),
                              SizedBox(
                                key: _scheduleKey,
                                height: 118,
                                child: ListView.separated(
                                  scrollDirection: Axis.horizontal,
                                  itemCount: _today.length,
                                  separatorBuilder: (_, __) =>
                                      const SizedBox(width: 10),
                                  itemBuilder:
                                      (BuildContext context, int index) {
                                    final double delay =
                                        0.32 + (index * 0.05).clamp(0.0, 0.25);
                                    return _FadeSlide(
                                      animation: reduceMotion
                                          ? const AlwaysStoppedAnimation<
                                              double>(1)
                                          : _stagger(delay, (delay + 0.28)
                                              .clamp(0.0, 1.0)),
                                      child: _ScheduleTile(
                                        block: _today[index],
                                        selected: index == _activeBlock,
                                        dark: dark,
                                        onTap: () => _selectBlock(index),
                                      ),
                                    );
                                  },
                                ),
                              ),
                            ],
                          ),
                        ),
                        const SizedBox(height: 40),
                        _FadeSlide(
                          animation: reduceMotion
                              ? const AlwaysStoppedAnimation<double>(1)
                              : _stagger(0.40, 0.72),
                          child: Column(
                            crossAxisAlignment: CrossAxisAlignment.start,
                            children: <Widget>[
                              Text('Recent', style: text.titleMedium),
                              const SizedBox(height: 6),
                              const _RecentRow(
                                title: 'Write RFC outline',
                                meta: '50 min · yesterday',
                              ),
                              const _RecentRow(
                                title: 'Refactor onboarding',
                                meta: '25 min · yesterday',
                              ),
                              const _RecentRow(
                                title: 'Read research notes',
                                meta: '15 min · Mon',
                              ),
                            ],
                          ),
                        ),
                        const SizedBox(height: 32),
                        _FadeSlide(
                          animation: reduceMotion
                              ? const AlwaysStoppedAnimation<double>(1)
                              : _stagger(0.52, 0.85),
                          child: _DevStrip(
                            lastOutcome: _lastOutcome,
                            onReplay: _replayDefault,
                            onCustom: _replayCustom,
                            onReset: _resetFlag,
                          ),
                        ),
                      ],
                    ),
                  ),
                ],
              ),
            ),
          ],
        ),
        bottomNavigationBar: _GlassNavBar(
          selectedIndex: _tab,
          dark: dark,
          logKey: _logKey,
          youKey: _youKey,
          onDestinationSelected: (int index) {
            setState(() => _tab = index);
            HapticFeedback.selectionClick();
          },
        ),
      ),
    );
  }
}

/// Opacity + small translateY — never scale(0); start from a visible shape.
class _FadeSlide extends StatelessWidget {
  const _FadeSlide({required this.animation, required this.child});

  final Animation<double> animation;
  final Widget child;

  @override
  Widget build(BuildContext context) {
    return AnimatedBuilder(
      animation: animation,
      builder: (BuildContext context, Widget? child) {
        final double t = animation.value;
        return Opacity(
          opacity: t,
          child: Transform.translate(
            offset: Offset(0, (1 - t) * 10),
            child: child,
          ),
        );
      },
      child: child,
    );
  }
}

class _StatusChip extends StatelessWidget {
  const _StatusChip({
    required this.running,
    required this.pulse,
    required this.reduceMotion,
  });

  final bool running;
  final AnimationController pulse;
  final bool reduceMotion;

  @override
  Widget build(BuildContext context) {
    final ColorScheme colors = Theme.of(context).colorScheme;
    final Color live = running ? const Color(0xFF1FA971) : colors.primary;

    return AnimatedBuilder(
      animation: pulse,
      builder: (BuildContext context, Widget? child) {
        final double p = reduceMotion ? 0.5 : pulse.value;
        return Row(
          mainAxisSize: MainAxisSize.min,
          children: <Widget>[
            SizedBox(
              width: 14,
              height: 14,
              child: Stack(
                alignment: Alignment.center,
                children: <Widget>[
                  if (!running && !reduceMotion)
                    Transform.scale(
                      scale: 1 + p * 0.85,
                      child: Container(
                        width: 6,
                        height: 6,
                        decoration: BoxDecoration(
                          shape: BoxShape.circle,
                          color: live.withAlpha((40 + p * 50).round()),
                        ),
                      ),
                    ),
                  Container(
                    width: 6,
                    height: 6,
                    decoration: BoxDecoration(
                      shape: BoxShape.circle,
                      color: live,
                      boxShadow: <BoxShadow>[
                        BoxShadow(
                          color: live.withAlpha(running ? 90 : 50),
                          blurRadius: running ? 6 : 4,
                        ),
                      ],
                    ),
                  ),
                ],
              ),
            ),
            const SizedBox(width: 4),
            AnimatedDefaultTextStyle(
              duration: const Duration(milliseconds: 180),
              curve: _easeOut,
              style: Theme.of(context).textTheme.labelSmall!.copyWith(
                    color: live.withAlpha(200),
                    letterSpacing: 0.2,
                  ),
              child: Text(running ? 'In session' : 'Ready'),
            ),
          ],
        );
      },
    );
  }
}

class _StartButton extends StatelessWidget {
  const _StartButton({
    required this.running,
    required this.pressed,
    required this.reduceMotion,
    required this.onTapDown,
    required this.onTapUp,
    required this.onTapCancel,
  });

  final bool running;
  final bool pressed;
  final bool reduceMotion;
  final VoidCallback onTapDown;
  final VoidCallback onTapUp;
  final VoidCallback onTapCancel;

  @override
  Widget build(BuildContext context) {
    final ColorScheme colors = Theme.of(context).colorScheme;

    return GestureDetector(
      onTapDown: (_) => onTapDown(),
      onTapUp: (_) => onTapUp(),
      onTapCancel: onTapCancel,
      child: AnimatedScale(
        scale: pressed ? 0.97 : 1.0,
        duration: reduceMotion
            ? Duration.zero
            : Duration(milliseconds: pressed ? 110 : 160),
        curve: pressed ? Curves.easeOut : _easeOut,
        child: AnimatedContainer(
          duration: reduceMotion
              ? Duration.zero
              : const Duration(milliseconds: 220),
          curve: _easeInOut,
          padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 15),
          decoration: BoxDecoration(
            color: running ? colors.primary : colors.onSurface,
            borderRadius: BorderRadius.circular(999),
            boxShadow: <BoxShadow>[
              BoxShadow(
                color: (running ? colors.primary : colors.onSurface)
                    .withAlpha(pressed ? 20 : 45),
                blurRadius: pressed ? 8 : 18,
                offset: Offset(0, pressed ? 2 : 6),
              ),
            ],
          ),
          child: AnimatedDefaultTextStyle(
            duration: reduceMotion
                ? Duration.zero
                : const Duration(milliseconds: 180),
            curve: _easeOut,
            style: TextStyle(
              fontSize: 14,
              fontWeight: FontWeight.w600,
              letterSpacing: -0.15,
              color: running ? Colors.white : colors.surface,
            ),
            child: Row(
              mainAxisSize: MainAxisSize.min,
              children: <Widget>[
                AnimatedSwitcher(
                  duration: reduceMotion
                      ? Duration.zero
                      : const Duration(milliseconds: 180),
                  switchInCurve: _easeOut,
                  transitionBuilder:
                      (Widget child, Animation<double> anim) {
                    return FadeTransition(
                      opacity: anim,
                      child: ScaleTransition(
                        scale: Tween<double>(begin: 0.92, end: 1).animate(anim),
                        child: child,
                      ),
                    );
                  },
                  child: Icon(
                    running ? Icons.pause_rounded : Icons.play_arrow_rounded,
                    key: ValueKey<bool>(running),
                    size: 18,
                    color: running ? Colors.white : colors.surface,
                  ),
                ),
                const SizedBox(width: 8),
                Text(running ? 'Pause' : 'Start session'),
              ],
            ),
          ),
        ),
      ),
    );
  }
}

class _SearchField extends StatelessWidget {
  const _SearchField({super.key, required this.dark});

  final bool dark;

  @override
  Widget build(BuildContext context) {
    final ColorScheme colors = Theme.of(context).colorScheme;

    return ClipRRect(
      borderRadius: BorderRadius.circular(14),
      child: BackdropFilter(
        filter: ImageFilter.blur(sigmaX: 18, sigmaY: 18),
        child: DecoratedBox(
          decoration: BoxDecoration(
            color: dark
                ? Colors.white.withAlpha(12)
                : Colors.white.withAlpha(160),
            borderRadius: BorderRadius.circular(14),
            border: Border.all(
              color: colors.onSurface.withAlpha(dark ? 32 : 20),
            ),
            boxShadow: dark
                ? null
                : <BoxShadow>[
                    BoxShadow(
                      color: colors.onSurface.withAlpha(12),
                      blurRadius: 24,
                      offset: const Offset(0, 8),
                    ),
                  ],
          ),
          child: TextField(
            style: TextStyle(
              fontSize: 15,
              letterSpacing: -0.1,
              color: colors.onSurface,
            ),
            decoration: InputDecoration(
              hintText: 'Search projects or notes',
              hintStyle: TextStyle(
                color: colors.onSurface.withAlpha(110),
                fontWeight: FontWeight.w400,
                letterSpacing: -0.1,
              ),
              prefixIcon: Icon(
                Icons.search_rounded,
                size: 20,
                color: colors.onSurface.withAlpha(130),
              ),
              border: InputBorder.none,
              contentPadding: const EdgeInsets.symmetric(
                horizontal: 4,
                vertical: 15,
              ),
            ),
          ),
        ),
      ),
    );
  }
}

class _ProgressTrack extends StatelessWidget {
  const _ProgressTrack({
    required this.progress,
    required this.dark,
    required this.reduceMotion,
    required this.enter,
  });

  final double progress;
  final bool dark;
  final bool reduceMotion;
  final AnimationController enter;

  @override
  Widget build(BuildContext context) {
    final ColorScheme colors = Theme.of(context).colorScheme;
    final Animation<double> fill = CurvedAnimation(
      parent: enter,
      curve: const Interval(0.35, 0.75, curve: _easeOut),
    );

    return ClipRRect(
      borderRadius: BorderRadius.circular(99),
      child: SizedBox(
        height: 3,
        child: Stack(
          fit: StackFit.expand,
          children: <Widget>[
            ColoredBox(
              color: colors.onSurface.withAlpha(dark ? 28 : 18),
            ),
            AnimatedBuilder(
              animation: fill,
              builder: (BuildContext context, Widget? child) {
                final double t = reduceMotion ? 1 : fill.value;
                return FractionallySizedBox(
                  alignment: Alignment.centerLeft,
                  widthFactor: progress * t,
                  child: DecoratedBox(
                    decoration: BoxDecoration(
                      color: colors.onSurface.withAlpha(200),
                      borderRadius: BorderRadius.circular(99),
                    ),
                  ),
                );
              },
            ),
          ],
        ),
      ),
    );
  }
}

class _ScheduleTile extends StatefulWidget {
  const _ScheduleTile({
    required this.block,
    required this.selected,
    required this.dark,
    required this.onTap,
  });

  final _Block block;
  final bool selected;
  final bool dark;
  final VoidCallback onTap;

  @override
  State<_ScheduleTile> createState() => _ScheduleTileState();
}

class _ScheduleTileState extends State<_ScheduleTile> {
  bool _pressed = false;

  @override
  Widget build(BuildContext context) {
    final ColorScheme colors = Theme.of(context).colorScheme;
    final bool active = widget.selected;

    return GestureDetector(
      onTapDown: (_) => setState(() => _pressed = true),
      onTapUp: (_) {
        setState(() => _pressed = false);
        widget.onTap();
      },
      onTapCancel: () => setState(() => _pressed = false),
      child: AnimatedScale(
        scale: _pressed ? 0.97 : 1.0,
        duration: Duration(milliseconds: _pressed ? 100 : 160),
        curve: _easeOut,
        child: AnimatedContainer(
          duration: const Duration(milliseconds: 240),
          curve: _easeInOut,
          width: 138,
          padding: const EdgeInsets.fromLTRB(15, 15, 15, 13),
          decoration: BoxDecoration(
            color: active
                ? colors.onSurface
                : (widget.dark
                    ? Colors.white.withAlpha(10)
                    : Colors.white.withAlpha(200)),
            borderRadius: BorderRadius.circular(16),
            border: active
                ? null
                : Border.all(
                    color: colors.onSurface.withAlpha(widget.dark ? 28 : 14),
                  ),
            boxShadow: active
                ? <BoxShadow>[
                    BoxShadow(
                      color: colors.onSurface.withAlpha(55),
                      blurRadius: 22,
                      offset: const Offset(0, 10),
                    ),
                  ]
                : <BoxShadow>[
                    BoxShadow(
                      color: colors.onSurface.withAlpha(widget.dark ? 0 : 10),
                      blurRadius: 16,
                      offset: const Offset(0, 6),
                    ),
                  ],
          ),
          child: Column(
            crossAxisAlignment: CrossAxisAlignment.start,
            children: <Widget>[
              Text(
                widget.block.time,
                style: TextStyle(
                  fontSize: 12,
                  fontWeight: FontWeight.w600,
                  letterSpacing: -0.1,
                  color: active
                      ? colors.surface.withAlpha(165)
                      : colors.onSurface.withAlpha(125),
                  fontFeatures: const <FontFeature>[
                    FontFeature.tabularFigures(),
                  ],
                ),
              ),
              const Spacer(),
              Text(
                widget.block.label,
                maxLines: 2,
                overflow: TextOverflow.ellipsis,
                style: TextStyle(
                  fontSize: 14,
                  fontWeight: FontWeight.w600,
                  height: 1.2,
                  letterSpacing: -0.2,
                  color: active ? colors.surface : colors.onSurface,
                ),
              ),
              const SizedBox(height: 5),
              Text(
                '${widget.block.minutes} min',
                style: TextStyle(
                  fontSize: 11,
                  fontWeight: FontWeight.w500,
                  color: active
                      ? colors.surface.withAlpha(155)
                      : colors.onSurface.withAlpha(115),
                ),
              ),
            ],
          ),
        ),
      ),
    );
  }
}

class _RecentRow extends StatefulWidget {
  const _RecentRow({
    required this.title,
    required this.meta,
  });

  final String title;
  final String meta;

  @override
  State<_RecentRow> createState() => _RecentRowState();
}

class _RecentRowState extends State<_RecentRow> {
  bool _pressed = false;

  @override
  Widget build(BuildContext context) {
    final ColorScheme colors = Theme.of(context).colorScheme;

    return GestureDetector(
      onTapDown: (_) => setState(() => _pressed = true),
      onTapUp: (_) => setState(() => _pressed = false),
      onTapCancel: () => setState(() => _pressed = false),
      child: AnimatedScale(
        scale: _pressed ? 0.985 : 1.0,
        duration: Duration(milliseconds: _pressed ? 100 : 150),
        curve: _easeOut,
        child: AnimatedContainer(
          duration: const Duration(milliseconds: 150),
          curve: _easeOut,
          margin: const EdgeInsets.symmetric(vertical: 2),
          padding: const EdgeInsets.symmetric(vertical: 13, horizontal: 2),
          decoration: BoxDecoration(
            border: Border(
              bottom: BorderSide(
                color: colors.onSurface.withAlpha(14),
              ),
            ),
          ),
          child: Row(
            children: <Widget>[
              Expanded(
                child: Text(
                  widget.title,
                  style: TextStyle(
                    fontSize: 14.5,
                    fontWeight: FontWeight.w500,
                    letterSpacing: -0.15,
                    color: colors.onSurface,
                  ),
                ),
              ),
              Text(
                widget.meta,
                style: TextStyle(
                  fontSize: 12,
                  letterSpacing: -0.05,
                  color: colors.onSurface.withAlpha(115),
                  fontFeatures: const <FontFeature>[
                    FontFeature.tabularFigures(),
                  ],
                ),
              ),
            ],
          ),
        ),
      ),
    );
  }
}

class _GlassNavBar extends StatelessWidget {
  const _GlassNavBar({
    required this.selectedIndex,
    required this.dark,
    required this.logKey,
    required this.youKey,
    required this.onDestinationSelected,
  });

  final int selectedIndex;
  final bool dark;
  final GlobalKey logKey;
  final GlobalKey youKey;
  final ValueChanged<int> onDestinationSelected;

  @override
  Widget build(BuildContext context) {
    final ColorScheme colors = Theme.of(context).colorScheme;
    final double bottom = MediaQuery.paddingOf(context).bottom;

    return ClipRect(
      child: BackdropFilter(
        filter: ImageFilter.blur(sigmaX: 24, sigmaY: 24),
        child: DecoratedBox(
          decoration: BoxDecoration(
            color: (dark ? const Color(0xFF0C0D10) : const Color(0xFFF5F4F0))
                .withAlpha(dark ? 185 : 170),
            border: Border(
              top: BorderSide(
                color: colors.onSurface.withAlpha(dark ? 28 : 16),
              ),
            ),
          ),
          child: Padding(
            padding: EdgeInsets.only(bottom: bottom),
            child: SizedBox(
              height: 64,
              child: Row(
                children: <Widget>[
                  _NavItem(
                    selected: selectedIndex == 0,
                    icon: Icons.circle_outlined,
                    selectedIcon: Icons.circle,
                    label: 'Today',
                    onTap: () => onDestinationSelected(0),
                  ),
                  _NavItem(
                    key: logKey,
                    selected: selectedIndex == 1,
                    icon: Icons.list_alt_outlined,
                    selectedIcon: Icons.list_alt,
                    label: 'Log',
                    onTap: () => onDestinationSelected(1),
                  ),
                  _NavItem(
                    key: youKey,
                    selected: selectedIndex == 2,
                    icon: Icons.person_outline,
                    selectedIcon: Icons.person,
                    label: 'You',
                    onTap: () => onDestinationSelected(2),
                  ),
                ],
              ),
            ),
          ),
        ),
      ),
    );
  }
}

class _NavItem extends StatefulWidget {
  const _NavItem({
    super.key,
    required this.selected,
    required this.icon,
    required this.selectedIcon,
    required this.label,
    required this.onTap,
  });

  final bool selected;
  final IconData icon;
  final IconData selectedIcon;
  final String label;
  final VoidCallback onTap;

  @override
  State<_NavItem> createState() => _NavItemState();
}

class _NavItemState extends State<_NavItem> {
  bool _pressed = false;

  @override
  Widget build(BuildContext context) {
    final ColorScheme colors = Theme.of(context).colorScheme;
    final Color color = widget.selected
        ? colors.onSurface
        : colors.onSurface.withAlpha(120);

    return Expanded(
      child: GestureDetector(
        behavior: HitTestBehavior.opaque,
        onTapDown: (_) => setState(() => _pressed = true),
        onTapUp: (_) {
          setState(() => _pressed = false);
          widget.onTap();
        },
        onTapCancel: () => setState(() => _pressed = false),
        child: AnimatedScale(
          scale: _pressed ? 0.94 : 1.0,
          duration: Duration(milliseconds: _pressed ? 100 : 150),
          curve: _easeOut,
          child: Column(
            mainAxisAlignment: MainAxisAlignment.center,
            children: <Widget>[
              AnimatedSwitcher(
                duration: const Duration(milliseconds: 180),
                switchInCurve: _easeOut,
                transitionBuilder: (Widget child, Animation<double> anim) {
                  return FadeTransition(
                    opacity: anim,
                    child: ScaleTransition(
                      scale: Tween<double>(begin: 0.88, end: 1).animate(anim),
                      child: child,
                    ),
                  );
                },
                child: Icon(
                  widget.selected ? widget.selectedIcon : widget.icon,
                  key: ValueKey<bool>(widget.selected),
                  size: 22,
                  color: color,
                ),
              ),
              const SizedBox(height: 4),
              Text(
                widget.label,
                style: TextStyle(
                  fontSize: 11,
                  letterSpacing: -0.1,
                  fontWeight:
                      widget.selected ? FontWeight.w700 : FontWeight.w500,
                  color: color,
                ),
              ),
            ],
          ),
        ),
      ),
    );
  }
}

class _IconPress extends StatefulWidget {
  const _IconPress({
    required this.onPressed,
    required this.child,
    this.tooltip,
  });

  final VoidCallback onPressed;
  final Widget child;
  final String? tooltip;

  @override
  State<_IconPress> createState() => _IconPressState();
}

class _IconPressState extends State<_IconPress> {
  bool _pressed = false;

  @override
  Widget build(BuildContext context) {
    final Widget button = GestureDetector(
      onTapDown: (_) => setState(() => _pressed = true),
      onTapUp: (_) {
        setState(() => _pressed = false);
        widget.onPressed();
      },
      onTapCancel: () => setState(() => _pressed = false),
      child: AnimatedScale(
        scale: _pressed ? 0.90 : 1.0,
        duration: Duration(milliseconds: _pressed ? 100 : 150),
        curve: _easeOut,
        child: Padding(
          padding: const EdgeInsets.all(10),
          child: widget.child,
        ),
      ),
    );

    if (widget.tooltip == null) return button;
    return Tooltip(message: widget.tooltip!, child: button);
  }
}

class _DevStrip extends StatelessWidget {
  const _DevStrip({
    required this.lastOutcome,
    required this.onReplay,
    required this.onCustom,
    required this.onReset,
  });

  final String lastOutcome;
  final VoidCallback onReplay;
  final VoidCallback onCustom;
  final VoidCallback onReset;

  @override
  Widget build(BuildContext context) {
    final ColorScheme colors = Theme.of(context).colorScheme;
    final bool dark = Theme.of(context).brightness == Brightness.dark;

    return ClipRRect(
      borderRadius: BorderRadius.circular(14),
      child: BackdropFilter(
        filter: ImageFilter.blur(sigmaX: 12, sigmaY: 12),
        child: Container(
          padding: const EdgeInsets.fromLTRB(16, 15, 16, 12),
          decoration: BoxDecoration(
            borderRadius: BorderRadius.circular(14),
            color: dark
                ? Colors.white.withAlpha(8)
                : Colors.white.withAlpha(120),
            border: Border.all(
              color: colors.onSurface.withAlpha(dark ? 30 : 16),
            ),
          ),
          child: Column(
            crossAxisAlignment: CrossAxisAlignment.start,
            children: <Widget>[
              Row(
                children: <Widget>[
                  Text(
                    'Overmark demo',
                    style: TextStyle(
                      fontSize: 12,
                      fontWeight: FontWeight.w700,
                      letterSpacing: -0.1,
                      color: colors.onSurface.withAlpha(170),
                    ),
                  ),
                  const Spacer(),
                  Container(
                    padding: const EdgeInsets.symmetric(
                      horizontal: 8,
                      vertical: 3,
                    ),
                    decoration: BoxDecoration(
                      color: colors.onSurface.withAlpha(dark ? 22 : 12),
                      borderRadius: BorderRadius.circular(99),
                    ),
                    child: Text(
                      lastOutcome,
                      style: TextStyle(
                        fontSize: 11,
                        fontWeight: FontWeight.w600,
                        color: colors.onSurface.withAlpha(130),
                        fontFeatures: const <FontFeature>[
                          FontFeature.tabularFigures(),
                        ],
                      ),
                    ),
                  ),
                ],
              ),
              const SizedBox(height: 12),
              Wrap(
                spacing: 18,
                runSpacing: 4,
                children: <Widget>[
                  _Link(label: 'Replay tour', onTap: onReplay),
                  _Link(label: 'Custom theme', onTap: onCustom),
                  _Link(label: 'Reset flag', onTap: onReset),
                ],
              ),
            ],
          ),
        ),
      ),
    );
  }
}

class _Link extends StatefulWidget {
  const _Link({required this.label, required this.onTap});

  final String label;
  final VoidCallback onTap;

  @override
  State<_Link> createState() => _LinkState();
}

class _LinkState extends State<_Link> {
  bool _pressed = false;

  @override
  Widget build(BuildContext context) {
    return GestureDetector(
      onTapDown: (_) => setState(() => _pressed = true),
      onTapUp: (_) {
        setState(() => _pressed = false);
        widget.onTap();
      },
      onTapCancel: () => setState(() => _pressed = false),
      child: AnimatedOpacity(
        opacity: _pressed ? 0.55 : 1,
        duration: const Duration(milliseconds: 120),
        curve: _easeOut,
        child: Padding(
          padding: const EdgeInsets.symmetric(vertical: 6),
          child: Text(
            widget.label,
            style: TextStyle(
              fontSize: 13,
              fontWeight: FontWeight.w600,
              letterSpacing: -0.1,
              color: Theme.of(context).colorScheme.primary,
            ),
          ),
        ),
      ),
    );
  }
}
7
likes
160
points
226
downloads

Documentation

API reference

Publisher

verified publisherfirdanumar.com

Weekly Downloads

Spotlight coach marks for Flutter onboarding tours. Dims the screen, highlights any widget through a cut-out, and explains it in a fully customizable card. Zero dependencies.

Repository (GitHub)
View/report issues

Topics

#onboarding #coachmark #tutorial #overlay #spotlight

License

MIT (license)

Dependencies

flutter

More

Packages that depend on overmark