overmark 0.1.2
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.
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,
),
),
),
),
);
}
}