From 1ffe6269ea8e9cea071b6b7b76fc690bb17a9eda Mon Sep 17 00:00:00 2001 From: Cedric Hornberger Date: Thu, 29 Oct 2026 00:34:49 +0100 Subject: [PATCH] Stage 5: History, the shared daily Streak and Reminders - Streak computed from all complete Sessions: Grace Hour (00:00-01:00 counts for yesterday unless it is done), a Freeze per 7 Streak Days (max 2) spent automatically on a missed day (ADR 0006) - Top bar flame (grey until today is done) opening a panel with current and longest Streak, Freezes and a month calendar; 'Day N of your streak' on a fresh summary - History per Mode grouped by day, reopening each summary - Two adjustable Reminders (19:00, 22:30) scheduled 14 days ahead with flutter_local_notifications, none on a done day; permission asked after the first complete Session or from Settings - Settings: Reminders, the Player chime, exercise credits Co-Authored-By: Claude Opus 5.5 --- GLOSSARY.md | 5 +- android/app/build.gradle.kts | 6 + android/app/src/main/AndroidManifest.xml | 12 + ...treak-derived-and-reminders-rescheduled.md | 25 ++ future_ideas.md | 1 + lib/main.dart | 11 +- lib/src/app.dart | 7 +- lib/src/design/mode_switch.dart | 15 +- lib/src/history/session_repository.dart | 12 + lib/src/history/views/history_view.dart | 125 +++++++- lib/src/home/home_view.dart | 20 +- lib/src/localization/app_en.arb | 85 +++++- lib/src/localization/app_localizations.dart | 235 ++++++++++++++- .../localization/app_localizations_en.dart | 206 ++++++++++++- lib/src/player/views/player_view.dart | 6 +- lib/src/player/views/summary_view.dart | 56 +++- lib/src/settings/settings_controller.dart | 37 +++ lib/src/settings/settings_view.dart | 167 ++++++++++ lib/src/streak/day.dart | 42 +++ lib/src/streak/reminder_scheduler.dart | 113 +++++++ lib/src/streak/reminders.dart | 67 ++++ lib/src/streak/streak.dart | 93 ++++++ lib/src/streak/views/streak_views.dart | 285 ++++++++++++++++++ pubspec.lock | 58 +++- pubspec.yaml | 3 + test/history/session_repository_test.dart | 8 + test/streak/reminders_test.dart | 69 +++++ test/streak/streak_test.dart | 90 ++++++ test/support/test_app.dart | 33 +- test/views/player_view_test.dart | 16 +- test/views/streak_and_history_test.dart | 114 +++++++ 31 files changed, 1972 insertions(+), 50 deletions(-) create mode 100644 docs/adr/0006-streak-derived-and-reminders-rescheduled.md create mode 100644 lib/src/settings/settings_view.dart create mode 100644 lib/src/streak/day.dart create mode 100644 lib/src/streak/reminder_scheduler.dart create mode 100644 lib/src/streak/reminders.dart create mode 100644 lib/src/streak/streak.dart create mode 100644 lib/src/streak/views/streak_views.dart create mode 100644 test/streak/reminders_test.dart create mode 100644 test/streak/streak_test.dart create mode 100644 test/views/streak_and_history_test.dart diff --git a/GLOSSARY.md b/GLOSSARY.md index 53c10a3..a3fc82f 100644 --- a/GLOSSARY.md +++ b/GLOSSARY.md @@ -100,5 +100,6 @@ per 7 consecutive Streak Days; at most 2 are held. A frozen day shows ❄️ in days than Freezes held resets the Streak to 0. **Reminder**: -A local notification sent only if no complete Session exists for the current Streak Day. Two by -default (19:00 and 22:30); each can be switched on/off and moved in Settings. No more than two. +A local notification sent only if no complete Session exists for that day. Two by default (19:00 and +22:30); each can be switched on/off and moved in Settings. No more than two. Android may deliver one up +to about an hour late (see docs/adr/0006). diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 23bf383..cc04dde 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -11,6 +11,8 @@ android { ndkVersion = "27.0.12077973" compileOptions { + // Desugaring: flutter_local_notifications needs java.time on older Android versions. + isCoreLibraryDesugaringEnabled = true sourceCompatibility = JavaVersion.VERSION_11 targetCompatibility = JavaVersion.VERSION_11 } @@ -42,3 +44,7 @@ android { flutter { source = "../.." } + +dependencies { + coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.4") +} diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 2aa4f5e..ef034e5 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -1,4 +1,7 @@ + + + + + + + + + + + + .value(value: notifier), + Provider.value(value: reminders), Provider(create: (_) => DevicePlayerEffects()), ], child: const ZenXerciseApp(), diff --git a/lib/src/app.dart b/lib/src/app.dart index 5b8b7fb..a1c3a01 100644 --- a/lib/src/app.dart +++ b/lib/src/app.dart @@ -13,6 +13,7 @@ import 'plans/views/plan_editor_view.dart'; import 'player/views/player_view.dart'; import 'player/views/summary_view.dart'; import 'settings/settings_controller.dart'; +import 'settings/settings_view.dart'; class ZenXerciseApp extends StatelessWidget { const ZenXerciseApp({super.key}); @@ -47,7 +48,11 @@ class ZenXerciseApp extends StatelessWidget { PlanEditorView.routeName => PlanEditorView(planId: settings.arguments! as String), ExercisePickerView.routeName => ExercisePickerView(planId: settings.arguments! as String), PlayerView.routeName => PlayerView(planId: settings.arguments! as String), - SummaryView.routeName => SummaryView(sessionId: settings.arguments! as String), + SummaryView.routeName => switch (settings.arguments) { + final SummaryArguments a => SummaryView(sessionId: a.sessionId, justFinished: a.justFinished), + final id => SummaryView(sessionId: id! as String), + }, + SettingsView.routeName => const SettingsView(), _ => const HomeView(), }, ), diff --git a/lib/src/design/mode_switch.dart b/lib/src/design/mode_switch.dart index de584e5..bb9c7e9 100644 --- a/lib/src/design/mode_switch.dart +++ b/lib/src/design/mode_switch.dart @@ -19,6 +19,12 @@ class ModeSwitch extends StatelessWidget { final s = ZenStyle.of(context); final l10n = AppLocalizations.of(context); final radius = BorderRadius.circular(s.controlRadius.clamp(0, height / 2)); + return LayoutBuilder( + builder: (context, constraints) => _build(context, l10n, s, radius, constraints.maxWidth >= 260), + ); + } + + Widget _build(BuildContext context, AppLocalizations l10n, ZenStyle s, BorderRadius radius, bool icons) { return Semantics( label: l10n.modeSwitchLabel, child: Container( @@ -45,13 +51,13 @@ class ModeSwitch extends StatelessWidget { Row( children: [ _Segment( - icon: Icons.self_improvement_rounded, + icon: icons ? Icons.self_improvement_rounded : null, label: l10n.modeStretching, selected: mode == Mode.stretching, onTap: () => onChanged(Mode.stretching), ), _Segment( - icon: Icons.fitness_center_rounded, + icon: icons ? Icons.fitness_center_rounded : null, label: l10n.modeWorkout, selected: mode == Mode.workout, onTap: () => onChanged(Mode.workout), @@ -68,7 +74,7 @@ class ModeSwitch extends StatelessWidget { class _Segment extends StatelessWidget { const _Segment({required this.icon, required this.label, required this.selected, required this.onTap}); - final IconData icon; + final IconData? icon; final String label; final bool selected; final VoidCallback onTap; @@ -87,8 +93,7 @@ class _Segment extends StatelessWidget { child: Row( mainAxisAlignment: MainAxisAlignment.center, children: [ - Icon(icon, size: 18, color: color), - const SizedBox(width: 6), + if (icon != null) ...[Icon(icon, size: 18, color: color), const SizedBox(width: 6)], Flexible( child: Text( label, diff --git a/lib/src/history/session_repository.dart b/lib/src/history/session_repository.dart index dfe71c9..9a3dd7e 100644 --- a/lib/src/history/session_repository.dart +++ b/lib/src/history/session_repository.dart @@ -71,6 +71,18 @@ class SessionRepository { return query.watch().map((rows) => rows.map(_toSession).toList()); } + /// When every complete Session of either Mode ended: all the Streak needs. + Stream> watchCompleteEndTimes() => _completeEndTimes().watch(); + + Future> completeEndTimes() => _completeEndTimes().get(); + + Selectable _completeEndTimes() { + final query = _db.selectOnly(_db.sessions) + ..addColumns([_db.sessions.endedAt]) + ..where(_db.sessions.complete.equals(true)); + return query.map((row) => row.read(_db.sessions.endedAt)!); + } + static Session _toSession(SessionRow row) => Session( id: row.id, mode: Mode.fromName(row.mode), diff --git a/lib/src/history/views/history_view.dart b/lib/src/history/views/history_view.dart index 0a8afb4..6b5087b 100644 --- a/lib/src/history/views/history_view.dart +++ b/lib/src/history/views/history_view.dart @@ -1,21 +1,132 @@ +import 'package:clock/clock.dart'; import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; +import 'package:provider/provider.dart'; import '../../design/page_header.dart'; +import '../../design/zen_nav_bar.dart'; +import '../../design/zen_style.dart'; import '../../localization/app_localizations.dart'; +import '../../player/views/summary_view.dart'; +import '../../settings/settings_controller.dart'; +import '../../streak/day.dart'; +import '../session_repository.dart'; -/// The current Mode's finished Sessions. +/// The current Mode's finished Sessions by day, the latest first; each opens +/// its summary again. class HistoryView extends StatelessWidget { const HistoryView({super.key}); @override Widget build(BuildContext context) { final l10n = AppLocalizations.of(context); - return ListView( - children: [ - PageHeader(title: l10n.historyTitle), - const SizedBox(height: 24), - EmptyState(icon: Icons.history_rounded, title: l10n.historyEmptyTitle, body: l10n.historyEmptyBody), - ], + final mode = context.watch().mode; + return StreamBuilder( + stream: context.read().watchSessions(mode), + builder: (context, snapshot) { + final sessions = snapshot.data ?? const []; + if (sessions.isEmpty) { + return ListView( + children: [ + PageHeader(title: l10n.historyTitle), + const SizedBox(height: 24), + EmptyState(icon: Icons.history_rounded, title: l10n.historyEmptyTitle, body: l10n.historyEmptyBody), + ], + ); + } + final today = Day.of(clock.now()); + final rows = [PageHeader(title: l10n.historyTitle)]; + Day? shown; + for (final session in sessions) { + final day = Day.of(session.endedAt); + if (day != shown) { + shown = day; + rows.add(_DayHeader(label: _dayLabel(l10n, day, today))); + } + rows.add( + Padding( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 10), + child: _SessionTile(session: session), + ), + ); + } + return ListView( + padding: EdgeInsets.only(bottom: ZenNavBar.height + 32 + MediaQuery.paddingOf(context).bottom), + children: rows, + ); + }, + ); + } + + static String _dayLabel(AppLocalizations l10n, Day day, Day today) { + if (day == today) return l10n.historyToday; + if (day == today.add(-1)) return l10n.historyYesterday; + final date = day.at(0, 0); + return day.year == today.year ? DateFormat.MMMEd().format(date) : DateFormat.yMMMEd().format(date); + } +} + +class _DayHeader extends StatelessWidget { + const _DayHeader({required this.label}); + + final String label; + + @override + Widget build(BuildContext context) => Padding( + padding: const EdgeInsets.fromLTRB(20, 8, 20, 8), + child: Text(label, style: Theme.of(context).textTheme.titleSmall?.copyWith(color: ZenStyle.of(context).inkMuted)), + ); +} + +class _SessionTile extends StatelessWidget { + const _SessionTile({required this.session}); + + final Session session; + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context); + final s = ZenStyle.of(context); + final text = Theme.of(context).textTheme; + final line = l10n.historySessionLine( + DateFormat.Hm().format(session.endedAt), + l10n.durationMinutes((session.duration.inSeconds / 60).round()), + ); + return Card( + child: InkWell( + onTap: () => Navigator.of(context).pushNamed(SummaryView.routeName, arguments: session.id), + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 14, 12, 14), + child: Row( + children: [ + Icon( + session.complete ? Icons.check_circle_rounded : Icons.flag_rounded, + color: session.complete ? s.accent : s.inkMuted, + ), + const SizedBox(width: 14), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(session.planName, style: text.titleMedium, maxLines: 1, overflow: TextOverflow.ellipsis), + const SizedBox(height: 2), + Text(line, style: text.bodySmall?.copyWith(color: s.inkMuted)), + ], + ), + ), + if (!session.complete) + Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), + decoration: BoxDecoration( + color: s.surfaceSoft, + borderRadius: BorderRadius.circular(s.controlRadius.clamp(0, 12)), + ), + child: Text(l10n.historyEndedEarly, style: text.labelSmall?.copyWith(color: s.inkMuted)), + ), + ], + ), + ), + ), ); } } diff --git a/lib/src/home/home_view.dart b/lib/src/home/home_view.dart index 09b3ef4..c394e4b 100644 --- a/lib/src/home/home_view.dart +++ b/lib/src/home/home_view.dart @@ -9,6 +9,8 @@ import '../history/views/history_view.dart'; import '../localization/app_localizations.dart'; import '../plans/views/plans_view.dart'; import '../settings/settings_controller.dart'; +import '../settings/settings_view.dart'; +import '../streak/views/streak_views.dart'; /// The Mode switch on top, the current Mode's Plans, Library and History tabs /// below, and the floating navigation bar. @@ -47,12 +49,22 @@ class _HomeViewState extends State { padding: const EdgeInsets.fromLTRB(16, 12, 16, 0), child: Row( children: [ - Flexible( - child: ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 300), - child: ModeSwitch(mode: settings.mode, onChanged: settings.updateMode), + Expanded( + child: Align( + alignment: Alignment.centerLeft, + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 300), + child: ModeSwitch(mode: settings.mode, onChanged: settings.updateMode), + ), ), ), + const SizedBox(width: 8), + const StreakFlame(), + IconButton( + tooltip: l10n.settingsTitle, + onPressed: () => Navigator.of(context).pushNamed(SettingsView.routeName), + icon: const Icon(Icons.settings_rounded), + ), ], ), ), diff --git a/lib/src/localization/app_en.arb b/lib/src/localization/app_en.arb index ac4ea6c..94f3ba0 100644 --- a/lib/src/localization/app_en.arb +++ b/lib/src/localization/app_en.arb @@ -327,5 +327,88 @@ } } }, - "logWeight": "Add a weight" + "logWeight": "Add a weight", + "streakTooltip": "Streak", + "streakTitle": "{count, plural, =0{No streak yet} =1{1-day streak} other{{count}-day streak}}", + "@streakTitle": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "streakLongest": "{count, plural, =0{Longest: none yet} =1{Longest: 1 day} other{Longest: {count} days}}", + "@streakLongest": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "streakTodayDone": "Today is done.", + "streakTodayOpen": "Finish a session today to keep it going.", + "streakStart": "Finish any session to start one.", + "streakFreezes": "Freezes: {count} of {max}", + "@streakFreezes": { + "placeholders": { + "count": { + "type": "int" + }, + "max": { + "type": "int" + } + } + }, + "streakFreezeHint": "You earn one every 7 days. A freeze covers a missed day by itself.", + "streakGraceHint": "Sessions finished before 1:00 still count for the day before.", + "previousMonth": "Previous month", + "nextMonth": "Next month", + "summaryStreak": "Day {count} of your streak", + "@summaryStreak": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "settingsTitle": "Settings", + "settingsReminders": "Reminders", + "settingsRemindersHint": "Only sent on days you haven't finished a session yet.", + "reminderFirst": "First reminder", + "reminderLate": "Late reminder", + "settingsSound": "Sound", + "settingsChime": "Chime in the player", + "settingsChimeHint": "A soft sound when a timer ends or something new starts.", + "settingsAbout": "About", + "settingsCredits": "Exercises and photos come from free-exercise-db, released into the public domain.", + "reminderTitleFirst": "Time for a few minutes?", + "reminderTitleLate": "Still time today", + "reminderBodyStreak": "Keep your {count}-day streak going 🔥", + "@reminderBodyStreak": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "reminderBodyFirst": "A short session keeps your streak going.", + "reminderBodyLate": "A quick stretch before bed still counts.", + "notificationChannel": "Reminders", + "notificationChannelDescription": "A gentle nudge on days you haven't finished a session yet.", + "historyToday": "Today", + "historyYesterday": "Yesterday", + "historyEndedEarly": "Ended early", + "historySessionLine": "{time} · {duration}", + "@historySessionLine": { + "placeholders": { + "time": { + "type": "String" + }, + "duration": { + "type": "String" + } + } + }, + "remindersNeedPermission": "Reminders need permission to show notifications.", + "allowNotifications": "Allow" } diff --git a/lib/src/localization/app_localizations.dart b/lib/src/localization/app_localizations.dart index c70b0e9..db9e6ee 100644 --- a/lib/src/localization/app_localizations.dart +++ b/lib/src/localization/app_localizations.dart @@ -61,7 +61,8 @@ import 'app_localizations_en.dart'; /// be consistent with the languages listed in the AppLocalizations.supportedLocales /// property. abstract class AppLocalizations { - AppLocalizations(String locale) : localeName = intl.Intl.canonicalizedLocale(locale.toString()); + AppLocalizations(String locale) + : localeName = intl.Intl.canonicalizedLocale(locale.toString()); final String localeName; @@ -69,7 +70,8 @@ abstract class AppLocalizations { return Localizations.of(context, AppLocalizations)!; } - static const LocalizationsDelegate delegate = _AppLocalizationsDelegate(); + static const LocalizationsDelegate delegate = + _AppLocalizationsDelegate(); /// A list of this localizations delegate along with the default localizations /// delegates. @@ -81,12 +83,13 @@ abstract class AppLocalizations { /// Additional delegates can be added by appending to this list in /// MaterialApp. This list does not have to be used at all if a custom list /// of delegates is preferred or required. - static const List> localizationsDelegates = >[ - delegate, - GlobalMaterialLocalizations.delegate, - GlobalCupertinoLocalizations.delegate, - GlobalWidgetsLocalizations.delegate, - ]; + static const List> localizationsDelegates = + >[ + delegate, + GlobalMaterialLocalizations.delegate, + GlobalCupertinoLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + ]; /// A list of this localizations delegate's supported locales. static const List supportedLocales = [Locale('en')]; @@ -840,9 +843,220 @@ abstract class AppLocalizations { /// In en, this message translates to: /// **'Add a weight'** String get logWeight; + + /// No description provided for @streakTooltip. + /// + /// In en, this message translates to: + /// **'Streak'** + String get streakTooltip; + + /// No description provided for @streakTitle. + /// + /// In en, this message translates to: + /// **'{count, plural, =0{No streak yet} =1{1-day streak} other{{count}-day streak}}'** + String streakTitle(int count); + + /// No description provided for @streakLongest. + /// + /// In en, this message translates to: + /// **'{count, plural, =0{Longest: none yet} =1{Longest: 1 day} other{Longest: {count} days}}'** + String streakLongest(int count); + + /// No description provided for @streakTodayDone. + /// + /// In en, this message translates to: + /// **'Today is done.'** + String get streakTodayDone; + + /// No description provided for @streakTodayOpen. + /// + /// In en, this message translates to: + /// **'Finish a session today to keep it going.'** + String get streakTodayOpen; + + /// No description provided for @streakStart. + /// + /// In en, this message translates to: + /// **'Finish any session to start one.'** + String get streakStart; + + /// No description provided for @streakFreezes. + /// + /// In en, this message translates to: + /// **'Freezes: {count} of {max}'** + String streakFreezes(int count, int max); + + /// No description provided for @streakFreezeHint. + /// + /// In en, this message translates to: + /// **'You earn one every 7 days. A freeze covers a missed day by itself.'** + String get streakFreezeHint; + + /// No description provided for @streakGraceHint. + /// + /// In en, this message translates to: + /// **'Sessions finished before 1:00 still count for the day before.'** + String get streakGraceHint; + + /// No description provided for @previousMonth. + /// + /// In en, this message translates to: + /// **'Previous month'** + String get previousMonth; + + /// No description provided for @nextMonth. + /// + /// In en, this message translates to: + /// **'Next month'** + String get nextMonth; + + /// No description provided for @summaryStreak. + /// + /// In en, this message translates to: + /// **'Day {count} of your streak'** + String summaryStreak(int count); + + /// No description provided for @settingsTitle. + /// + /// In en, this message translates to: + /// **'Settings'** + String get settingsTitle; + + /// No description provided for @settingsReminders. + /// + /// In en, this message translates to: + /// **'Reminders'** + String get settingsReminders; + + /// No description provided for @settingsRemindersHint. + /// + /// In en, this message translates to: + /// **'Only sent on days you haven\'t finished a session yet.'** + String get settingsRemindersHint; + + /// No description provided for @reminderFirst. + /// + /// In en, this message translates to: + /// **'First reminder'** + String get reminderFirst; + + /// No description provided for @reminderLate. + /// + /// In en, this message translates to: + /// **'Late reminder'** + String get reminderLate; + + /// No description provided for @settingsSound. + /// + /// In en, this message translates to: + /// **'Sound'** + String get settingsSound; + + /// No description provided for @settingsChime. + /// + /// In en, this message translates to: + /// **'Chime in the player'** + String get settingsChime; + + /// No description provided for @settingsChimeHint. + /// + /// In en, this message translates to: + /// **'A soft sound when a timer ends or something new starts.'** + String get settingsChimeHint; + + /// No description provided for @settingsAbout. + /// + /// In en, this message translates to: + /// **'About'** + String get settingsAbout; + + /// No description provided for @settingsCredits. + /// + /// In en, this message translates to: + /// **'Exercises and photos come from free-exercise-db, released into the public domain.'** + String get settingsCredits; + + /// No description provided for @reminderTitleFirst. + /// + /// In en, this message translates to: + /// **'Time for a few minutes?'** + String get reminderTitleFirst; + + /// No description provided for @reminderTitleLate. + /// + /// In en, this message translates to: + /// **'Still time today'** + String get reminderTitleLate; + + /// No description provided for @reminderBodyStreak. + /// + /// In en, this message translates to: + /// **'Keep your {count}-day streak going 🔥'** + String reminderBodyStreak(int count); + + /// No description provided for @reminderBodyFirst. + /// + /// In en, this message translates to: + /// **'A short session keeps your streak going.'** + String get reminderBodyFirst; + + /// No description provided for @reminderBodyLate. + /// + /// In en, this message translates to: + /// **'A quick stretch before bed still counts.'** + String get reminderBodyLate; + + /// No description provided for @notificationChannel. + /// + /// In en, this message translates to: + /// **'Reminders'** + String get notificationChannel; + + /// No description provided for @notificationChannelDescription. + /// + /// In en, this message translates to: + /// **'A gentle nudge on days you haven\'t finished a session yet.'** + String get notificationChannelDescription; + + /// No description provided for @historyToday. + /// + /// In en, this message translates to: + /// **'Today'** + String get historyToday; + + /// No description provided for @historyYesterday. + /// + /// In en, this message translates to: + /// **'Yesterday'** + String get historyYesterday; + + /// No description provided for @historyEndedEarly. + /// + /// In en, this message translates to: + /// **'Ended early'** + String get historyEndedEarly; + + /// No description provided for @historySessionLine. + /// + /// In en, this message translates to: + /// **'{time} · {duration}'** + String historySessionLine(String time, String duration); + + /// No description provided for @remindersNeedPermission. + /// + /// In en, this message translates to: + /// **'Reminders need permission to show notifications.'** + String get remindersNeedPermission; + + /// No description provided for @allowNotifications. + /// + /// In en, this message translates to: + /// **'Allow'** + String get allowNotifications; } -class _AppLocalizationsDelegate extends LocalizationsDelegate { +class _AppLocalizationsDelegate + extends LocalizationsDelegate { const _AppLocalizationsDelegate(); @override @@ -851,7 +1065,8 @@ class _AppLocalizationsDelegate extends LocalizationsDelegate } @override - bool isSupported(Locale locale) => ['en'].contains(locale.languageCode); + bool isSupported(Locale locale) => + ['en'].contains(locale.languageCode); @override bool shouldReload(_AppLocalizationsDelegate old) => false; diff --git a/lib/src/localization/app_localizations_en.dart b/lib/src/localization/app_localizations_en.dart index a77ec24..5f0edaa 100644 --- a/lib/src/localization/app_localizations_en.dart +++ b/lib/src/localization/app_localizations_en.dart @@ -38,8 +38,10 @@ class AppLocalizationsEn extends AppLocalizations { @override String plansEmptyBody(String mode) { String _temp0 = intl.Intl.selectLogic(mode, { - 'stretching': 'Put together a stretch routine from the library and it will wait for you here.', - 'other': 'Put together a workout from the library and it will wait for you here.', + 'stretching': + 'Put together a stretch routine from the library and it will wait for you here.', + 'other': + 'Put together a workout from the library and it will wait for you here.', }); return '$_temp0'; } @@ -49,9 +51,22 @@ class AppLocalizationsEn extends AppLocalizations { @override String librarySubtitle(String mode, int count) { - String _temp0 = intl.Intl.pluralLogic(count, locale: localeName, other: '$count stretches', one: '1 stretch'); - String _temp1 = intl.Intl.pluralLogic(count, locale: localeName, other: '$count exercises', one: '1 exercise'); - String _temp2 = intl.Intl.selectLogic(mode, {'stretching': '$_temp0', 'other': '$_temp1'}); + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count stretches', + one: '1 stretch', + ); + String _temp1 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count exercises', + one: '1 exercise', + ); + String _temp2 = intl.Intl.selectLogic(mode, { + 'stretching': '$_temp0', + 'other': '$_temp1', + }); return '$_temp2'; } @@ -62,7 +77,8 @@ class AppLocalizationsEn extends AppLocalizations { String get historyEmptyTitle => 'Nothing logged yet'; @override - String get historyEmptyBody => 'Finished sessions show up here with their date and duration.'; + String get historyEmptyBody => + 'Finished sessions show up here with their date and duration.'; @override String librarySubtitleFiltered(String mode, int shown, int total) { @@ -75,7 +91,10 @@ class AppLocalizationsEn extends AppLocalizations { @override String searchHint(String mode) { - String _temp0 = intl.Intl.selectLogic(mode, {'stretching': 'Search stretches', 'other': 'Search exercises'}); + String _temp0 = intl.Intl.selectLogic(mode, { + 'stretching': 'Search stretches', + 'other': 'Search exercises', + }); return '$_temp0'; } @@ -110,7 +129,8 @@ class AppLocalizationsEn extends AppLocalizations { String get noMatchesTitle => 'No matches'; @override - String get noMatchesBody => 'Try fewer words, or clear the filters to see everything again.'; + String get noMatchesBody => + 'Try fewer words, or clear the filters to see everything again.'; @override String get detailMainMuscles => 'Main muscles'; @@ -147,7 +167,10 @@ class AppLocalizationsEn extends AppLocalizations { @override String planNameHint(String mode) { - String _temp0 = intl.Intl.selectLogic(mode, {'stretching': 'e.g. Morning stretch', 'other': 'e.g. Push day'}); + String _temp0 = intl.Intl.selectLogic(mode, { + 'stretching': 'e.g. Morning stretch', + 'other': 'e.g. Push day', + }); return '$_temp0'; } @@ -195,7 +218,8 @@ class AppLocalizationsEn extends AppLocalizations { } @override - String get deletePlanBody => 'The plan is removed. Sessions you already finished stay in your history.'; + String get deletePlanBody => + 'The plan is removed. Sessions you already finished stay in your history.'; @override String planExerciseCount(int count) { @@ -245,7 +269,12 @@ class AppLocalizationsEn extends AppLocalizations { @override String repsCount(int count) { - String _temp0 = intl.Intl.pluralLogic(count, locale: localeName, other: '$count reps', one: '1 rep'); + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count reps', + one: '1 rep', + ); return '$_temp0'; } @@ -269,13 +298,23 @@ class AppLocalizationsEn extends AppLocalizations { @override String roundsCount(int count) { - String _temp0 = intl.Intl.pluralLogic(count, locale: localeName, other: '$count rounds', one: '1 round'); + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count rounds', + one: '1 round', + ); return '$_temp0'; } @override String setsCount(int count) { - String _temp0 = intl.Intl.pluralLogic(count, locale: localeName, other: '$count sets', one: '1 set'); + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count sets', + one: '1 set', + ); return '$_temp0'; } @@ -316,7 +355,8 @@ class AppLocalizationsEn extends AppLocalizations { String get restBetweenLabel => 'Rest between exercises'; @override - String get restBetweenHint => 'When off, you go straight to the next exercise and rest after each round.'; + String get restBetweenHint => + 'When off, you go straight to the next exercise and rest after each round.'; @override String get supersetWithNext => 'Superset with next'; @@ -482,4 +522,142 @@ class AppLocalizationsEn extends AppLocalizations { @override String get logWeight => 'Add a weight'; + + @override + String get streakTooltip => 'Streak'; + + @override + String streakTitle(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count-day streak', + one: '1-day streak', + zero: 'No streak yet', + ); + return '$_temp0'; + } + + @override + String streakLongest(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'Longest: $count days', + one: 'Longest: 1 day', + zero: 'Longest: none yet', + ); + return '$_temp0'; + } + + @override + String get streakTodayDone => 'Today is done.'; + + @override + String get streakTodayOpen => 'Finish a session today to keep it going.'; + + @override + String get streakStart => 'Finish any session to start one.'; + + @override + String streakFreezes(int count, int max) { + return 'Freezes: $count of $max'; + } + + @override + String get streakFreezeHint => + 'You earn one every 7 days. A freeze covers a missed day by itself.'; + + @override + String get streakGraceHint => + 'Sessions finished before 1:00 still count for the day before.'; + + @override + String get previousMonth => 'Previous month'; + + @override + String get nextMonth => 'Next month'; + + @override + String summaryStreak(int count) { + return 'Day $count of your streak'; + } + + @override + String get settingsTitle => 'Settings'; + + @override + String get settingsReminders => 'Reminders'; + + @override + String get settingsRemindersHint => + 'Only sent on days you haven\'t finished a session yet.'; + + @override + String get reminderFirst => 'First reminder'; + + @override + String get reminderLate => 'Late reminder'; + + @override + String get settingsSound => 'Sound'; + + @override + String get settingsChime => 'Chime in the player'; + + @override + String get settingsChimeHint => + 'A soft sound when a timer ends or something new starts.'; + + @override + String get settingsAbout => 'About'; + + @override + String get settingsCredits => + 'Exercises and photos come from free-exercise-db, released into the public domain.'; + + @override + String get reminderTitleFirst => 'Time for a few minutes?'; + + @override + String get reminderTitleLate => 'Still time today'; + + @override + String reminderBodyStreak(int count) { + return 'Keep your $count-day streak going 🔥'; + } + + @override + String get reminderBodyFirst => 'A short session keeps your streak going.'; + + @override + String get reminderBodyLate => 'A quick stretch before bed still counts.'; + + @override + String get notificationChannel => 'Reminders'; + + @override + String get notificationChannelDescription => + 'A gentle nudge on days you haven\'t finished a session yet.'; + + @override + String get historyToday => 'Today'; + + @override + String get historyYesterday => 'Yesterday'; + + @override + String get historyEndedEarly => 'Ended early'; + + @override + String historySessionLine(String time, String duration) { + return '$time · $duration'; + } + + @override + String get remindersNeedPermission => + 'Reminders need permission to show notifications.'; + + @override + String get allowNotifications => 'Allow'; } diff --git a/lib/src/player/views/player_view.dart b/lib/src/player/views/player_view.dart index dfd895e..152838a 100644 --- a/lib/src/player/views/player_view.dart +++ b/lib/src/player/views/player_view.dart @@ -16,6 +16,7 @@ import '../../plans/models/plan.dart'; import '../../plans/plan_labels.dart'; import '../../plans/plan_repository.dart'; import '../../settings/settings_controller.dart'; +import '../../streak/reminder_scheduler.dart'; import '../player_effects.dart'; import '../segments.dart'; import '../session_runner.dart'; @@ -81,8 +82,11 @@ class _PlayerViewState extends State { navigator.pop(); return; } + final scheduler = context.read(); final session = await context.read().save(runner.result(clock.now())); - navigator.pushReplacementNamed(SummaryView.routeName, arguments: session.id); + navigator.pushReplacementNamed(SummaryView.routeName, arguments: SummaryArguments(session.id, justFinished: true)); + // A finished day needs no more Reminders; the first finished Session is when to ask for them. + await scheduler.sync(askPermission: session.complete); } Future _confirmEnd() async { diff --git a/lib/src/player/views/summary_view.dart b/lib/src/player/views/summary_view.dart index ec6c973..08766f8 100644 --- a/lib/src/player/views/summary_view.dart +++ b/lib/src/player/views/summary_view.dart @@ -8,17 +8,23 @@ import '../../localization/app_localizations.dart'; import '../../modes/mode.dart'; import '../../plans/models/plan.dart'; import '../../plans/plan_labels.dart'; +import '../../streak/streak.dart'; +import '../../streak/views/streak_views.dart'; import '../session_runner.dart'; /// Shown when a Session ends: how long it took, what was done and, for /// Workouts, every logged set. class SummaryView extends StatelessWidget { - const SummaryView({super.key, required this.sessionId}); + const SummaryView({super.key, required this.sessionId, this.justFinished = false}); static const routeName = '/summary'; final String sessionId; + /// Straight from the Player rather than opened again from History: only + /// then is the Session's Streak Day worth pointing out. + final bool justFinished; + @override Widget build(BuildContext context) { final l10n = AppLocalizations.of(context); @@ -50,6 +56,7 @@ class SummaryView extends StatelessWidget { Text(session.complete ? l10n.summaryComplete : l10n.summaryIncomplete, style: text.headlineLarge), const SizedBox(height: 4), Text(session.planName, style: text.titleMedium?.copyWith(color: s.inkMuted)), + if (justFinished && session.complete) _StreakLine(session: session), const SizedBox(height: 28), Row( children: [ @@ -100,6 +107,53 @@ class SummaryView extends StatelessWidget { } } +/// "Day 12 of your streak", when this Session was the first to complete its day. +class _StreakLine extends StatelessWidget { + const _StreakLine({required this.session}); + + final Session session; + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context); + final s = ZenStyle.of(context); + final sessions = context.read(); + return StreakBuilder( + builder: (context, streak) => FutureBuilder( + future: sessions.completeEndTimes(), + builder: (context, snapshot) { + final times = snapshot.data; + if (times == null) return const SizedBox.shrink(); + final before = streakDays(times.where((t) => t != session.endedAt)); + final fresh = streakDays(times).length > before.length; + if (!fresh || streak.current == 0) return const SizedBox.shrink(); + return Padding( + padding: const EdgeInsets.only(top: 12), + child: Row( + children: [ + Icon(Icons.local_fire_department_rounded, color: s.accent), + const SizedBox(width: 6), + Text( + l10n.summaryStreak(streak.current), + style: Theme.of(context).textTheme.titleMedium?.copyWith(color: s.accent), + ), + ], + ), + ); + }, + ), + ); + } +} + +/// What the summary route takes. +class SummaryArguments { + const SummaryArguments(this.sessionId, {this.justFinished = false}); + + final String sessionId; + final bool justFinished; +} + class _Stat extends StatelessWidget { const _Stat({required this.label, required this.value}); diff --git a/lib/src/settings/settings_controller.dart b/lib/src/settings/settings_controller.dart index 2d40e8f..b95d507 100644 --- a/lib/src/settings/settings_controller.dart +++ b/lib/src/settings/settings_controller.dart @@ -2,11 +2,15 @@ import 'package:flutter/foundation.dart'; import 'package:shared_preferences/shared_preferences.dart'; import '../modes/mode.dart'; +import '../streak/reminders.dart'; /// Stores the user's app preferences on the device. class SettingsService { static const _modeKey = 'mode'; static const _mutedKey = 'muted'; + static const _askedKey = 'notificationsAsked'; + static String _reminderOnKey(int slot) => 'reminder${slot}On'; + static String _reminderAtKey(int slot) => 'reminder${slot}At'; /// The last-used Mode; Stretching on first launch. Future mode() async => Mode.fromName((await SharedPreferences.getInstance()).getString(_modeKey)); @@ -17,6 +21,29 @@ class SettingsService { Future muted() async => (await SharedPreferences.getInstance()).getBool(_mutedKey) ?? false; Future updateMuted(bool muted) async => (await SharedPreferences.getInstance()).setBool(_mutedKey, muted); + + /// The two Reminder slots, each falling back to its default. + Future> reminders() async { + final prefs = await SharedPreferences.getInstance(); + return [ + for (final (slot, fallback) in defaultReminders.indexed) + Reminder( + enabled: prefs.getBool(_reminderOnKey(slot)) ?? fallback.enabled, + minutes: prefs.getInt(_reminderAtKey(slot)) ?? fallback.minutes, + ), + ]; + } + + Future updateReminder(int slot, Reminder reminder) async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setBool(_reminderOnKey(slot), reminder.enabled); + await prefs.setInt(_reminderAtKey(slot), reminder.minutes); + } + + /// Whether the notification permission was asked for already. + Future notificationsAsked() async => (await SharedPreferences.getInstance()).getBool(_askedKey) ?? false; + + Future markNotificationsAsked() async => (await SharedPreferences.getInstance()).setBool(_askedKey, true); } class SettingsController extends ChangeNotifier { @@ -25,16 +52,26 @@ class SettingsController extends ChangeNotifier { final SettingsService _service; Mode _mode = Mode.stretching; bool _muted = false; + List _reminders = defaultReminders; + SettingsService get service => _service; Mode get mode => _mode; bool get muted => _muted; + List get reminders => _reminders; Future loadSettings() async { _mode = await _service.mode(); _muted = await _service.muted(); + _reminders = await _service.reminders(); notifyListeners(); } + Future updateReminder(int slot, Reminder reminder) async { + _reminders = [..._reminders]..[slot] = reminder; + notifyListeners(); + await _service.updateReminder(slot, reminder); + } + Future updateMuted(bool muted) async { _muted = muted; notifyListeners(); diff --git a/lib/src/settings/settings_view.dart b/lib/src/settings/settings_view.dart new file mode 100644 index 0000000..88d5eff --- /dev/null +++ b/lib/src/settings/settings_view.dart @@ -0,0 +1,167 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import '../design/page_header.dart'; +import '../design/zen_style.dart'; +import '../localization/app_localizations.dart'; +import '../streak/reminder_scheduler.dart'; +import '../streak/reminders.dart'; +import 'settings_controller.dart'; + +/// The two Reminders, the Player's chime and where the Exercises come from. +class SettingsView extends StatelessWidget { + const SettingsView({super.key}); + + static const routeName = '/settings'; + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context); + final settings = context.watch(); + final s = ZenStyle.of(context); + final text = Theme.of(context).textTheme; + return Scaffold( + appBar: AppBar(), + body: ListView( + padding: const EdgeInsets.only(bottom: 32), + children: [ + PageHeader(title: l10n.settingsTitle), + _Section(title: l10n.settingsReminders, hint: l10n.settingsRemindersHint), + for (final (slot, reminder) in settings.reminders.indexed) + _ReminderRow(slot: slot, reminder: reminder, label: slot == 0 ? l10n.reminderFirst : l10n.reminderLate), + if (settings.reminders.any((r) => r.enabled)) const _PermissionNotice(), + _Section(title: l10n.settingsSound), + SwitchListTile( + contentPadding: const EdgeInsets.symmetric(horizontal: 20), + title: Text(l10n.settingsChime, style: text.titleSmall), + subtitle: Text(l10n.settingsChimeHint), + value: !settings.muted, + onChanged: (on) => settings.updateMuted(!on), + ), + _Section(title: l10n.settingsAbout), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 20), + child: Text(l10n.settingsCredits, style: text.bodyMedium?.copyWith(color: s.inkMuted)), + ), + ], + ), + ); + } +} + +/// Shown while Android does not let ZenXercise post notifications; asking +/// again opens the system prompt (or does nothing once the user said "never"). +class _PermissionNotice extends StatefulWidget { + const _PermissionNotice(); + + @override + State<_PermissionNotice> createState() => _PermissionNoticeState(); +} + +class _PermissionNoticeState extends State<_PermissionNotice> { + late Future _enabled = context.read().enabled(); + + Future _allow() async { + final notifier = context.read(); + final scheduler = context.read(); + await context.read().service.markNotificationsAsked(); + await notifier.requestPermission(); + await scheduler.sync(); + setState(() => _enabled = notifier.enabled()); + } + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context); + final s = ZenStyle.of(context); + return FutureBuilder( + future: _enabled, + builder: (context, snapshot) => snapshot.data != false + ? const SizedBox.shrink() + : Padding( + padding: const EdgeInsets.fromLTRB(20, 4, 12, 0), + child: Row( + children: [ + Expanded(child: Text(l10n.remindersNeedPermission, style: Theme.of(context).textTheme.bodySmall)), + TextButton( + onPressed: _allow, + style: TextButton.styleFrom(foregroundColor: s.accent), + child: Text(l10n.allowNotifications), + ), + ], + ), + ), + ); + } +} + +class _Section extends StatelessWidget { + const _Section({required this.title, this.hint}); + + final String title; + final String? hint; + + @override + Widget build(BuildContext context) { + final s = ZenStyle.of(context); + final text = Theme.of(context).textTheme; + return Padding( + padding: const EdgeInsets.fromLTRB(20, 16, 20, 4), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(title, style: text.titleMedium?.copyWith(color: s.accent)), + if (hint != null) Text(hint!, style: text.bodySmall?.copyWith(color: s.inkMuted)), + ], + ), + ); + } +} + +/// A Reminder's switch and its time; changing either reschedules at once. +class _ReminderRow extends StatelessWidget { + const _ReminderRow({required this.slot, required this.reminder, required this.label}); + + final int slot; + final Reminder reminder; + final String label; + + Future _update(BuildContext context, Reminder updated) async { + final scheduler = context.read(); + await context.read().updateReminder(slot, updated); + await scheduler.sync(askPermission: updated.enabled); + } + + @override + Widget build(BuildContext context) { + final s = ZenStyle.of(context); + final text = Theme.of(context).textTheme; + final time = TimeOfDay(hour: reminder.hour, minute: reminder.minute); + return ListTile( + contentPadding: const EdgeInsets.only(left: 20, right: 12), + title: Text(label, style: text.titleSmall), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + TextButton( + style: TextButton.styleFrom(foregroundColor: reminder.enabled ? s.accent : s.inkMuted), + onPressed: () async { + final picked = await showTimePicker(context: context, initialTime: time); + if (picked != null && context.mounted) { + await _update(context, reminder.copyWith(minutes: picked.hour * 60 + picked.minute, enabled: true)); + } + }, + child: Text( + MaterialLocalizations.of(context).formatTimeOfDay(time, alwaysUse24HourFormat: true), + style: TextStyle(fontFamily: s.headingFont, fontWeight: FontWeight.w800, fontSize: 18), + ), + ), + Switch( + value: reminder.enabled, + onChanged: (on) => _update(context, reminder.copyWith(enabled: on)), + ), + ], + ), + ); + } +} diff --git a/lib/src/streak/day.dart b/lib/src/streak/day.dart new file mode 100644 index 0000000..10e8f2e --- /dev/null +++ b/lib/src/streak/day.dart @@ -0,0 +1,42 @@ +import 'package:flutter/foundation.dart'; + +/// A calendar day without a time, safe to step through across daylight saving changes. +@immutable +class Day implements Comparable { + Day(int year, int month, int day) : _utc = DateTime.utc(year, month, day); + + factory Day.of(DateTime time) => Day(time.year, time.month, time.day); + + final DateTime _utc; + + int get year => _utc.year; + int get month => _utc.month; + int get day => _utc.day; + + /// 1 for Monday … 7 for Sunday. + int get weekday => _utc.weekday; + + Day add(int days) { + final d = _utc.add(Duration(days: days)); + return Day(d.year, d.month, d.day); + } + + int differenceInDays(Day other) => _utc.difference(other._utc).inDays; + + bool isBefore(Day other) => _utc.isBefore(other._utc); + + /// This day at [hour]:[minute] local time. + DateTime at(int hour, int minute) => DateTime(year, month, day, hour, minute); + + @override + int compareTo(Day other) => _utc.compareTo(other._utc); + + @override + bool operator ==(Object other) => other is Day && other._utc == _utc; + + @override + int get hashCode => _utc.hashCode; + + @override + String toString() => '$year-${month.toString().padLeft(2, '0')}-${day.toString().padLeft(2, '0')}'; +} diff --git a/lib/src/streak/reminder_scheduler.dart b/lib/src/streak/reminder_scheduler.dart new file mode 100644 index 0000000..fd28a89 --- /dev/null +++ b/lib/src/streak/reminder_scheduler.dart @@ -0,0 +1,113 @@ +import 'package:clock/clock.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_local_notifications/flutter_local_notifications.dart'; +import 'package:flutter_timezone/flutter_timezone.dart'; +import 'package:timezone/data/latest.dart' as tzdata; +import 'package:timezone/timezone.dart' as tz; + +import '../history/session_repository.dart'; +import '../localization/app_localizations.dart'; +import '../settings/settings_controller.dart'; +import 'reminders.dart'; +import 'streak.dart'; + +/// Shows and schedules local notifications. Tests swap in a recording fake. +abstract interface class Notifier { + Future init(); + + /// Asks Android for the notification permission; true when granted. + Future requestPermission(); + + Future enabled(); + + Future cancelAll(); + + Future schedule({required int id, required DateTime at, required String title, required String body}); +} + +class LocalNotifier implements Notifier { + final _plugin = FlutterLocalNotificationsPlugin(); + + AndroidFlutterLocalNotificationsPlugin? get _android => + _plugin.resolvePlatformSpecificImplementation(); + + @override + Future init() async { + tzdata.initializeTimeZones(); + try { + tz.setLocalLocation(tz.getLocation((await FlutterTimezone.getLocalTimezone()).identifier)); + } catch (_) { + // An unknown zone name leaves UTC; Reminders then drift by the offset but still arrive. + } + await _plugin.initialize( + settings: const InitializationSettings(android: AndroidInitializationSettings('@mipmap/ic_launcher')), + ); + } + + @override + Future requestPermission() async => await _android?.requestNotificationsPermission() ?? false; + + @override + Future enabled() async => await _android?.areNotificationsEnabled() ?? false; + + @override + Future cancelAll() => _plugin.cancelAll(); + + @override + Future schedule({required int id, required DateTime at, required String title, required String body}) { + final l10n = lookupAppLocalizations(const Locale('en')); + return _plugin.zonedSchedule( + id: id, + title: title, + body: body, + scheduledDate: tz.TZDateTime.from(at, tz.local), + // Inexact is enough for a nudge and needs no exact-alarm permission. + androidScheduleMode: AndroidScheduleMode.inexactAllowWhileIdle, + notificationDetails: NotificationDetails( + android: AndroidNotificationDetails( + 'reminders', + l10n.notificationChannel, + channelDescription: l10n.notificationChannelDescription, + importance: Importance.defaultImportance, + priority: Priority.defaultPriority, + enableVibration: false, + ), + ), + ); + } +} + +/// Keeps the scheduled Reminders in line with the settings and the Streak: +/// two weeks ahead, nothing on a day that is already done. Call [sync] +/// whenever either changes and when the app starts. +class ReminderScheduler { + ReminderScheduler(this._notifier, this._settings, this._sessions); + + final Notifier _notifier; + final SettingsController _settings; + final SessionRepository _sessions; + + /// [askPermission] asks Android once, the first time it is set. + Future sync({bool askPermission = false}) async { + if (askPermission && !await _settings.service.notificationsAsked()) { + await _settings.service.markNotificationsAsked(); + await _notifier.requestPermission(); + } + final now = clock.now(); + final streak = Streak.compute(await _sessions.completeEndTimes(), now: now); + final l10n = lookupAppLocalizations(const Locale('en')); + await _notifier.cancelAll(); + for (final r in upcomingReminders(_settings.reminders, streak: streak, now: now)) { + await _notifier.schedule( + id: r.id, + at: r.at, + title: r.slot == 0 ? l10n.reminderTitleFirst : l10n.reminderTitleLate, + body: r.streakDays != null + ? l10n.reminderBodyStreak(r.streakDays!) + : r.slot == 0 + ? l10n.reminderBodyFirst + : l10n.reminderBodyLate, + ); + } + } +} diff --git a/lib/src/streak/reminders.dart b/lib/src/streak/reminders.dart new file mode 100644 index 0000000..af9cd4b --- /dev/null +++ b/lib/src/streak/reminders.dart @@ -0,0 +1,67 @@ +import 'package:flutter/foundation.dart'; + +import 'day.dart'; +import 'streak.dart'; + +/// One of the two daily Reminder slots: on or off, and its time of day. +@immutable +class Reminder { + const Reminder({required this.enabled, required this.minutes}); + + final bool enabled; + + /// Minutes after midnight. + final int minutes; + + int get hour => minutes ~/ 60; + int get minute => minutes % 60; + + Reminder copyWith({bool? enabled, int? minutes}) => + Reminder(enabled: enabled ?? this.enabled, minutes: minutes ?? this.minutes); + + @override + bool operator ==(Object other) => other is Reminder && other.enabled == enabled && other.minutes == minutes; + + @override + int get hashCode => Object.hash(enabled, minutes); +} + +/// The two slots and their defaults, 19:00 and 22:30. +const defaultReminders = [Reminder(enabled: true, minutes: 19 * 60), Reminder(enabled: true, minutes: 22 * 60 + 30)]; + +/// A Reminder notification due at [at]. +class PlannedReminder { + const PlannedReminder({required this.id, required this.at, required this.slot, required this.streakDays}); + + final int id; + final DateTime at; + + /// 0 for the first Reminder, 1 for the late one. + final int slot; + + /// The running Streak, for today's Reminders only; later days cannot know it yet. + final int? streakDays; +} + +/// The Reminders for today and the [days] - 1 days after it: none on a day +/// that is already done, none in the past, none for a switched-off slot. +List upcomingReminders( + List reminders, { + required Streak streak, + required DateTime now, + int days = 14, +}) { + final today = Day.of(now); + return [ + for (var offset = 0; offset < days; offset++) + if (!(offset == 0 && streak.todayDone)) + for (final (slot, reminder) in reminders.indexed) + if (reminder.enabled && today.add(offset).at(reminder.hour, reminder.minute).isAfter(now)) + PlannedReminder( + id: offset * reminders.length + slot, + at: today.add(offset).at(reminder.hour, reminder.minute), + slot: slot, + streakDays: offset == 0 && streak.current > 0 ? streak.current : null, + ), + ]; +} diff --git a/lib/src/streak/streak.dart b/lib/src/streak/streak.dart new file mode 100644 index 0000000..a91c71f --- /dev/null +++ b/lib/src/streak/streak.dart @@ -0,0 +1,93 @@ +import 'dart:math' as math; + +import 'day.dart'; + +/// Sessions finished before 01:00 may still count for the day before. +const graceHourEnd = 1; + +/// One Freeze is earned per this many Streak Days, and at most [maxFreezes] are held. +const daysPerFreeze = 7; +const maxFreezes = 2; + +/// The Streak Days that complete Sessions ending at [endTimes] (local time) +/// count for. In the Grace Hour a Session counts for the previous day unless +/// that day is already done. +Set streakDays(Iterable endTimes) { + final days = {}; + for (final end in endTimes.toList()..sort()) { + final today = Day.of(end); + final yesterday = today.add(-1); + days.add(end.hour < graceHourEnd && !days.contains(yesterday) ? yesterday : today); + } + return days; +} + +/// The single daily Streak shared by both Modes, worked out from scratch +/// from every complete Session, so Freezes need no storage of their own. +class Streak { + const Streak({ + required this.current, + required this.longest, + required this.freezes, + required this.todayDone, + required this.today, + required this.activeDays, + required this.frozenDays, + }); + + /// Consecutive Streak Days up to today (or yesterday, while today is still open). + final int current; + final int longest; + + /// Freezes held right now. + final int freezes; + + /// Whether the current Streak Day already has a complete Session. + final bool todayDone; + + /// The current Streak Day: yesterday during the Grace Hour if it is still open. + final Day today; + final Set activeDays; + + /// Missed days a Freeze covered. + final Set frozenDays; + + static Streak compute(Iterable endTimes, {required DateTime now}) { + final active = streakDays(endTimes); + final calendarToday = Day.of(now); + // During the Grace Hour yesterday can still be completed, so it is not missed yet. + final inGrace = now.hour < graceHourEnd && !active.contains(calendarToday.add(-1)); + final today = inGrace ? calendarToday.add(-1) : calendarToday; + + var current = 0; + var longest = 0; + var freezes = 0; + final frozen = {}; + if (active.isNotEmpty) { + final first = active.reduce((a, b) => a.isBefore(b) ? a : b); + for (var day = first; !today.isBefore(day); day = day.add(1)) { + if (active.contains(day)) { + current++; + longest = math.max(longest, current); + if (current % daysPerFreeze == 0) freezes = math.min(maxFreezes, freezes + 1); + } else if (day == today || (inGrace && day == calendarToday)) { + // Still open: neither done nor missed. + } else if (freezes > 0) { + freezes--; + frozen.add(day); + } else { + current = 0; + } + } + } + return Streak( + current: current, + longest: longest, + freezes: freezes, + todayDone: active.contains(today) || active.contains(calendarToday), + today: today, + activeDays: active, + frozenDays: frozen, + ); + } +} diff --git a/lib/src/streak/views/streak_views.dart b/lib/src/streak/views/streak_views.dart new file mode 100644 index 0000000..c23ced6 --- /dev/null +++ b/lib/src/streak/views/streak_views.dart @@ -0,0 +1,285 @@ +import 'package:clock/clock.dart'; +import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; +import 'package:provider/provider.dart'; + +import '../../design/zen_style.dart'; +import '../../history/session_repository.dart'; +import '../../localization/app_localizations.dart'; +import '../day.dart'; +import '../streak.dart'; + +/// Rebuilds with the Streak whenever a complete Session is saved. +class StreakBuilder extends StatelessWidget { + const StreakBuilder({super.key, required this.builder}); + + final Widget Function(BuildContext context, Streak streak) builder; + + @override + Widget build(BuildContext context) => StreamBuilder( + stream: context.read().watchCompleteEndTimes(), + builder: (context, snapshot) => builder(context, Streak.compute(snapshot.data ?? const [], now: clock.now())), + ); +} + +/// The small flame and count in the top bar: grey until today is done, then +/// lit in the Mode's accent. Tapping it opens the Streak panel. +class StreakFlame extends StatelessWidget { + const StreakFlame({super.key}); + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context); + final s = ZenStyle.of(context); + return StreakBuilder( + builder: (context, streak) { + final lit = streak.todayDone; + final radius = BorderRadius.circular(s.controlRadius.clamp(0, 22)); + return Semantics( + button: true, + label: '${l10n.streakTooltip}: ${l10n.streakTitle(streak.current)}', + child: Tooltip( + message: l10n.streakTooltip, + child: Material( + color: s.surfaceSoft, + borderRadius: radius, + child: InkWell( + borderRadius: radius, + onTap: () => showStreakSheet(context), + child: AnimatedContainer( + duration: s.motion, + curve: s.curve, + height: 44, + padding: const EdgeInsets.symmetric(horizontal: 12), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.local_fire_department_rounded, size: 22, color: lit ? s.accent : s.inkMuted), + const SizedBox(width: 4), + Text( + '${streak.current}', + style: TextStyle( + fontFamily: s.headingFont, + fontWeight: FontWeight.w800, + fontSize: 17, + color: lit ? s.ink : s.inkMuted, + ), + ), + ], + ), + ), + ), + ), + ), + ); + }, + ); + } +} + +Future showStreakSheet(BuildContext context) => showModalBottomSheet( + context: context, + showDragHandle: true, + isScrollControlled: true, + builder: (context) => const _StreakSheet(), +); + +class _StreakSheet extends StatefulWidget { + const _StreakSheet(); + + @override + State<_StreakSheet> createState() => _StreakSheetState(); +} + +class _StreakSheetState extends State<_StreakSheet> { + late Day _month = () { + final today = Day.of(clock.now()); + return Day(today.year, today.month, 1); + }(); + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context); + final s = ZenStyle.of(context); + final text = Theme.of(context).textTheme; + return StreakBuilder( + builder: (context, streak) => SafeArea( + child: SingleChildScrollView( + padding: const EdgeInsets.fromLTRB(20, 0, 20, 20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(Icons.local_fire_department_rounded, size: 40, color: streak.todayDone ? s.accent : s.inkMuted), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(l10n.streakTitle(streak.current), style: text.headlineSmall), + Text( + streak.current == 0 + ? l10n.streakStart + : streak.todayDone + ? l10n.streakTodayDone + : l10n.streakTodayOpen, + style: text.bodyMedium?.copyWith(color: s.inkMuted), + ), + ], + ), + ), + ], + ), + const SizedBox(height: 16), + Row( + children: [ + Expanded(child: _Fact(text: l10n.streakLongest(streak.longest))), + const SizedBox(width: 8), + Expanded( + child: _Fact(text: l10n.streakFreezes(streak.freezes, maxFreezes), icon: Icons.ac_unit_rounded), + ), + ], + ), + const SizedBox(height: 20), + _MonthCalendar(month: _month, streak: streak, onMonth: (m) => setState(() => _month = m)), + const SizedBox(height: 16), + Text(l10n.streakFreezeHint, style: text.bodySmall?.copyWith(color: s.inkMuted)), + const SizedBox(height: 4), + Text(l10n.streakGraceHint, style: text.bodySmall?.copyWith(color: s.inkMuted)), + ], + ), + ), + ), + ); + } +} + +class _Fact extends StatelessWidget { + const _Fact({required this.text, this.icon}); + + final String text; + final IconData? icon; + + @override + Widget build(BuildContext context) { + final s = ZenStyle.of(context); + return Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + decoration: BoxDecoration(color: s.surfaceSoft, borderRadius: BorderRadius.circular(s.radius * 0.5)), + child: Row( + children: [ + if (icon != null) ...[Icon(icon, size: 16, color: s.accent), const SizedBox(width: 6)], + Flexible(child: Text(text, style: Theme.of(context).textTheme.titleSmall)), + ], + ), + ); + } +} + +/// A month of days: done days filled in the accent, frozen ones with a +/// snowflake, today ringed. +class _MonthCalendar extends StatelessWidget { + const _MonthCalendar({required this.month, required this.streak, required this.onMonth}); + + final Day month; + final Streak streak; + final ValueChanged onMonth; + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context); + final s = ZenStyle.of(context); + final text = Theme.of(context).textTheme; + final first = month; + final next = Day(month.month == 12 ? month.year + 1 : month.year, month.month == 12 ? 1 : month.month + 1, 1); + final previous = Day(month.month == 1 ? month.year - 1 : month.year, month.month == 1 ? 12 : month.month - 1, 1); + final daysInMonth = next.differenceInDays(first); + final leading = first.weekday - 1; + final today = Day.of(clock.now()); + final weekdays = [for (var i = 0; i < 7; i++) DateFormat.E().format(DateTime(2026, 1, 5 + i)).substring(0, 1)]; + return Column( + children: [ + Row( + children: [ + IconButton( + tooltip: l10n.previousMonth, + onPressed: () => onMonth(previous), + icon: const Icon(Icons.chevron_left_rounded), + ), + Expanded( + child: Text( + DateFormat.yMMMM().format(first.at(0, 0)), + textAlign: TextAlign.center, + style: text.titleMedium, + ), + ), + IconButton( + tooltip: l10n.nextMonth, + onPressed: next.isBefore(today.add(1)) ? () => onMonth(next) : null, + icon: const Icon(Icons.chevron_right_rounded), + ), + ], + ), + const SizedBox(height: 4), + GridView.count( + crossAxisCount: 7, + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + mainAxisSpacing: 4, + crossAxisSpacing: 4, + children: [ + for (final w in weekdays) + Center( + child: Text(w, style: text.bodySmall?.copyWith(color: s.inkMuted)), + ), + for (var i = 0; i < leading; i++) const SizedBox.shrink(), + for (var d = 0; d < daysInMonth; d++) _DayCell(day: first.add(d), streak: streak, today: today), + ], + ), + ], + ); + } +} + +class _DayCell extends StatelessWidget { + const _DayCell({required this.day, required this.streak, required this.today}); + + final Day day; + final Streak streak; + final Day today; + + @override + Widget build(BuildContext context) { + final s = ZenStyle.of(context); + final active = streak.activeDays.contains(day); + final frozen = streak.frozenDays.contains(day); + final future = today.isBefore(day); + final radius = BorderRadius.circular(s.controlRadius.clamp(0, 20)); + return Container( + decoration: BoxDecoration( + color: active + ? s.accent + : frozen + ? s.surfaceSoft + : null, + borderRadius: radius, + border: day == today && !active ? Border.all(color: s.accent, width: 1.5) : null, + ), + alignment: Alignment.center, + child: frozen + ? Icon(Icons.ac_unit_rounded, size: 16, color: s.accent) + : Text( + '${day.day}', + style: TextStyle( + fontWeight: active ? FontWeight.w800 : FontWeight.w500, + color: active + ? s.onAccent + : future + ? s.inkMuted.withValues(alpha: 0.5) + : s.ink, + ), + ), + ); + } +} diff --git a/pubspec.lock b/pubspec.lock index 75ec772..1ec51be 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -249,6 +249,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.2.8" + equatable: + dependency: transitive + description: + name: equatable + sha256: ea36e500cbf0ec18361deabca755434f73e42fe53c5130021c7e2d6b5ebe1702 + url: "https://pub.dev" + source: hosted + version: "3.0.0" fake_async: dependency: transitive description: @@ -294,6 +302,38 @@ packages: url: "https://pub.dev" source: hosted version: "5.0.0" + flutter_local_notifications: + dependency: "direct main" + description: + name: flutter_local_notifications + sha256: "2b50e938a275e1ad77352d6a25e25770f4130baa61eaf02de7a9a884680954ad" + url: "https://pub.dev" + source: hosted + version: "20.1.0" + flutter_local_notifications_linux: + dependency: transitive + description: + name: flutter_local_notifications_linux + sha256: dce0116868cedd2cdf768af0365fc37ff1cbef7c02c4f51d0587482e625868d0 + url: "https://pub.dev" + source: hosted + version: "7.0.0" + flutter_local_notifications_platform_interface: + dependency: transitive + description: + name: flutter_local_notifications_platform_interface + sha256: "23de31678a48c084169d7ae95866df9de5c9d2a44be3e5915a2ff067aeeba899" + url: "https://pub.dev" + source: hosted + version: "10.0.0" + flutter_local_notifications_windows: + dependency: transitive + description: + name: flutter_local_notifications_windows + sha256: e97a1a3016512437d9c0b12fae7d1491c3c7b9aa7f03a69b974308840656b02a + url: "https://pub.dev" + source: hosted + version: "2.0.1" flutter_localizations: dependency: "direct main" description: flutter @@ -304,6 +344,14 @@ packages: description: flutter source: sdk version: "0.0.0" + flutter_timezone: + dependency: "direct main" + description: + name: flutter_timezone + sha256: "7830bc64bef0b003468fc1f58fc3f3faf54817f8cd6e68fa818b306560410d68" + url: "https://pub.dev" + source: hosted + version: "5.1.1" flutter_web_plugins: dependency: transitive description: flutter @@ -770,6 +818,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.7.4" + timezone: + dependency: "direct main" + description: + name: timezone + sha256: dd14a3b83cfd7cb19e7888f1cbc20f258b8d71b54c06f79ac585f14093a287d1 + url: "https://pub.dev" + source: hosted + version: "0.10.1" typed_data: dependency: transitive description: @@ -884,4 +940,4 @@ packages: version: "3.1.4" sdks: dart: ">=3.8.1 <4.0.0" - flutter: ">=3.29.0" + flutter: ">=3.32.0" diff --git a/pubspec.yaml b/pubspec.yaml index 7fa59de..1e15bc0 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -13,11 +13,14 @@ dependencies: drift_flutter: ^0.2.8 flutter: sdk: flutter + flutter_local_notifications: ^20.1.0 flutter_localizations: sdk: flutter + flutter_timezone: ^5.1.1 intl: ^0.20.2 provider: ^6.1.5+1 shared_preferences: ^2.5.3 + timezone: ^0.10.1 uuid: ^4.6.0 wakelock_plus: ^1.4.0 diff --git a/test/history/session_repository_test.dart b/test/history/session_repository_test.dart index a010b66..809879c 100644 --- a/test/history/session_repository_test.dart +++ b/test/history/session_repository_test.dart @@ -55,4 +55,12 @@ void main() { expect(workout.map((s) => s.endedAt.day), [29, 28]); }); + + test('the Streak sees complete Sessions of both Modes only', () async { + await sessions.save(result(Mode.workout, DateTime(2026, 10, 28))); + await sessions.save(result(Mode.stretching, DateTime(2026, 10, 29))); + await sessions.save(result(Mode.stretching, DateTime(2026, 10, 30), complete: false)); + + expect(await sessions.completeEndTimes(), unorderedEquals([DateTime(2026, 10, 28), DateTime(2026, 10, 29)])); + }); } diff --git a/test/streak/reminders_test.dart b/test/streak/reminders_test.dart new file mode 100644 index 0000000..3735d78 --- /dev/null +++ b/test/streak/reminders_test.dart @@ -0,0 +1,69 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:zenxercise/src/streak/reminders.dart'; +import 'package:zenxercise/src/streak/streak.dart'; + +void main() { + const evening = Reminder(enabled: true, minutes: 19 * 60); + const late = Reminder(enabled: true, minutes: 22 * 60 + 30); + + Streak streak({bool todayDone = false, required DateTime now}) => + Streak.compute([if (todayDone) now.subtract(const Duration(minutes: 1))], now: now); + + test('both Reminders are planned for each of the next days', () { + final now = DateTime(2026, 10, 29, 9); + final planned = upcomingReminders( + [evening, late], + streak: streak(now: now), + now: now, + days: 2, + ); + + expect(planned.map((r) => r.at), [ + DateTime(2026, 10, 29, 19), + DateTime(2026, 10, 29, 22, 30), + DateTime(2026, 10, 30, 19), + DateTime(2026, 10, 30, 22, 30), + ]); + expect(planned.map((r) => r.id).toSet(), hasLength(4)); + }); + + test('a done day gets no Reminders, and times already past are skipped', () { + final now = DateTime(2026, 10, 29, 20); + final done = upcomingReminders( + [evening, late], + streak: streak(todayDone: true, now: now), + now: now, + days: 2, + ); + final open = upcomingReminders( + [evening, late], + streak: streak(now: now), + now: now, + days: 1, + ); + + expect(done.map((r) => r.at), [DateTime(2026, 10, 30, 19), DateTime(2026, 10, 30, 22, 30)]); + expect(open.map((r) => r.at), [DateTime(2026, 10, 29, 22, 30)]); + }); + + test('a switched-off Reminder is never planned', () { + final now = DateTime(2026, 10, 29, 9); + final planned = upcomingReminders( + [evening, const Reminder(enabled: false, minutes: 22 * 60 + 30)], + streak: streak(now: now), + now: now, + days: 2, + ); + + expect(planned.map((r) => r.at.hour), [19, 19]); + }); + + test('only today\'s Reminders mention the running Streak; later ones cannot know it', () { + final now = DateTime(2026, 10, 29, 9); + final s = Streak.compute([DateTime(2026, 10, 27, 18), DateTime(2026, 10, 28, 18)], now: now); + + final planned = upcomingReminders([evening], streak: s, now: now, days: 2); + + expect(planned.map((r) => r.streakDays), [2, null]); + }); +} diff --git a/test/streak/streak_test.dart b/test/streak/streak_test.dart new file mode 100644 index 0000000..bdbe52c --- /dev/null +++ b/test/streak/streak_test.dart @@ -0,0 +1,90 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:zenxercise/src/streak/day.dart'; +import 'package:zenxercise/src/streak/streak.dart'; + +/// A complete Session ending on October [day] at [hour]:[minute]. +DateTime oct(int day, [int hour = 18, int minute = 0]) => DateTime(2026, 10, day, hour, minute); + +void main() { + group('Streak Day', () { + test('a Session counts for its calendar day', () { + expect(streakDays([oct(3, 18)]), {Day(2026, 10, 3)}); + }); + + test('in the Grace Hour it counts for the previous day if that day is not done yet', () { + expect(streakDays([oct(4, 0, 30)]), {Day(2026, 10, 3)}); + }); + + test('in the Grace Hour it counts for the current day once the previous day is done', () { + expect(streakDays([oct(3, 20), oct(4, 0, 30)]), {Day(2026, 10, 3), Day(2026, 10, 4)}); + }); + + test('from 01:00 on it is the current day again', () { + expect(streakDays([oct(4, 1, 0)]), {Day(2026, 10, 4)}); + }); + }); + + group('counting', () { + test('no Sessions, no Streak', () { + final s = Streak.compute(const [], now: oct(10, 9)); + + expect((s.current, s.longest, s.freezes, s.todayDone), (0, 0, 0, false)); + }); + + test('consecutive days count up and today being done lights it', () { + final s = Streak.compute([oct(8), oct(9), oct(10)], now: oct(10, 20)); + + expect(s.current, 3); + expect(s.todayDone, isTrue); + }); + + test('today still open keeps yesterday\'s Streak alive but unlit', () { + final s = Streak.compute([oct(8), oct(9)], now: oct(10, 12)); + + expect(s.current, 2); + expect(s.todayDone, isFalse); + }); + + test('a missed day without a Freeze resets the Streak, the longest is kept', () { + final s = Streak.compute([oct(1), oct(2), oct(3), oct(5)], now: oct(5, 20)); + + expect(s.current, 1); + expect(s.longest, 3); + }); + + test('yesterday is not missed yet during the Grace Hour', () { + final s = Streak.compute([oct(8), oct(9)], now: oct(11, 0, 20)); + + expect(s.current, 2); + }); + }); + + group('Streak Freezes', () { + test('one is earned per 7 Streak Days, holding at most 2', () { + final days = [for (var d = 1; d <= 21; d++) oct(d)]; + + expect(Streak.compute(days.take(6).toList(), now: oct(6, 20)).freezes, 0); + expect(Streak.compute(days.take(7).toList(), now: oct(7, 20)).freezes, 1); + expect(Streak.compute(days, now: oct(21, 20)).freezes, 2); + }); + + test('a Freeze covers a missed day automatically and shows on the calendar', () { + final days = [for (var d = 1; d <= 7; d++) oct(d), oct(9)]; + + final s = Streak.compute(days, now: oct(9, 20)); + + expect(s.current, 8); + expect(s.freezes, 0); + expect(s.frozenDays, {Day(2026, 10, 8)}); + }); + + test('more missed days than Freezes reset the Streak', () { + final days = [for (var d = 1; d <= 7; d++) oct(d), oct(10)]; + + final s = Streak.compute(days, now: oct(10, 20)); + + expect(s.current, 1); + expect(s.freezes, 0); + }); + }); +} diff --git a/test/support/test_app.dart b/test/support/test_app.dart index 86dd117..83e2246 100644 --- a/test/support/test_app.dart +++ b/test/support/test_app.dart @@ -10,6 +10,7 @@ import 'package:zenxercise/src/history/session_repository.dart'; import 'package:zenxercise/src/plans/plan_repository.dart'; import 'package:zenxercise/src/player/player_effects.dart'; import 'package:zenxercise/src/settings/settings_controller.dart'; +import 'package:zenxercise/src/streak/reminder_scheduler.dart'; import 'test_database.dart'; @@ -62,6 +63,31 @@ class FakePlayerEffects implements PlayerEffects { Future keepScreenOn(bool on) async => screenOn = on; } +/// Records what would be scheduled instead of talking to Android. +class FakeNotifier implements Notifier { + final scheduled = <(DateTime, String)>[]; + var permissionRequests = 0; + + @override + Future init() async {} + + @override + Future requestPermission() async { + permissionRequests++; + return true; + } + + @override + Future enabled() async => true; + + @override + Future cancelAll() async => scheduled.clear(); + + @override + Future schedule({required int id, required DateTime at, required String title, required String body}) async => + scheduled.add((at, body)); +} + /// A Pixel-sized screen instead of the default 800×600, so the floating /// navigation bar does not cover what a test taps. void usePhoneScreen(WidgetTester tester) { @@ -78,20 +104,25 @@ Future pumpApp( PlanRepository? plans, SessionRepository? sessions, PlayerEffects? effects, + FakeNotifier? notifier, }) async { SharedPreferences.setMockInitialValues(prefs); final settings = SettingsController(SettingsService()); await settings.loadSettings(); final db = testDatabase(); addTearDown(db.close); + final sessionRepository = sessions ?? SessionRepository(db); + final fakeNotifier = notifier ?? FakeNotifier(); await tester.pumpWidget( MultiProvider( providers: [ ChangeNotifierProvider.value(value: settings), Provider.value(value: library ?? sampleLibrary), Provider.value(value: plans ?? PlanRepository(db)), - Provider.value(value: sessions ?? SessionRepository(db)), + Provider.value(value: sessionRepository), Provider.value(value: effects ?? FakePlayerEffects()), + Provider.value(value: fakeNotifier), + Provider.value(value: ReminderScheduler(fakeNotifier, settings, sessionRepository)), ], child: const ZenXerciseApp(), ), diff --git a/test/views/player_view_test.dart b/test/views/player_view_test.dart index 8b54f0d..c730a65 100644 --- a/test/views/player_view_test.dart +++ b/test/views/player_view_test.dart @@ -12,6 +12,7 @@ void main() { late PlanRepository plans; late SessionRepository sessions; late FakePlayerEffects effects; + late FakeNotifier notifier; setUp(() { final db = testDatabase(); @@ -19,13 +20,21 @@ void main() { plans = PlanRepository(db); sessions = SessionRepository(db); effects = FakePlayerEffects(); + notifier = FakeNotifier(); }); Future startPlan(WidgetTester tester, Mode mode, List items) async { final plan = await plans.create(mode, 'Routine'); await plans.save(plan.copyWith(items: items)); usePhoneScreen(tester); - await pumpApp(tester, plans: plans, sessions: sessions, effects: effects, prefs: {'mode': mode.name}); + await pumpApp( + tester, + plans: plans, + sessions: sessions, + effects: effects, + notifier: notifier, + prefs: {'mode': mode.name}, + ); await tester.tap(find.byTooltip('Start')); await tester.pump(); await tester.pump(); @@ -66,6 +75,11 @@ void main() { await settle(tester); expect(find.text('Session complete'), findsOneWidget); + await tester.runAsync(() => Future.delayed(const Duration(milliseconds: 50))); + await tester.pumpAndSettle(); + expect(find.text('Day 1 of your streak'), findsOneWidget); + // The first finished Session is when Reminders ask for permission. + expect(notifier.permissionRequests, 1); expect(effects.chimes, [Mode.stretching, Mode.stretching]); expect(effects.screenOn, isFalse); expect((await sessionsOf(tester, Mode.stretching)).single.complete, isTrue); diff --git a/test/views/streak_and_history_test.dart b/test/views/streak_and_history_test.dart new file mode 100644 index 0000000..a314200 --- /dev/null +++ b/test/views/streak_and_history_test.dart @@ -0,0 +1,114 @@ +import 'package:clock/clock.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:zenxercise/src/history/session_repository.dart'; +import 'package:zenxercise/src/modes/mode.dart'; +import 'package:zenxercise/src/plans/models/plan.dart'; +import 'package:zenxercise/src/plans/plan_repository.dart'; +import 'package:zenxercise/src/player/session_runner.dart'; + +import '../support/test_app.dart'; +import '../support/test_database.dart'; + +/// Noon, so the Grace Hour never shifts what counts as today. +final noon = DateTime(2026, 10, 29, 12); + +void main() { + late PlanRepository plans; + late SessionRepository sessions; + late FakeNotifier notifier; + + setUp(() { + final db = testDatabase(); + addTearDown(db.close); + plans = PlanRepository(db); + sessions = SessionRepository(db); + notifier = FakeNotifier(); + }); + + Plan plan(Mode mode) => Plan( + id: 'p1', + mode: mode, + name: 'Legs', + items: const [WorkoutStep(exerciseId: 'Barbell_Squat', sets: 1, target: WorkoutTarget.reps(10))], + createdAt: DateTime(2026), + updatedAt: DateTime(2026), + ); + + Future finished(WidgetTester tester, Mode mode, DateTime end, {bool complete = true}) async => tester.runAsync( + () => sessions.save( + SessionResult( + plan: plan(mode), + startedAt: end.subtract(const Duration(minutes: 20)), + endedAt: end, + duration: const Duration(minutes: 20), + complete: complete, + setLogs: const [SetLog(exerciseId: 'Barbell_Squat', set: 1, reps: 10)], + ), + ), + ); + + /// Lets drift's streams deliver (they need real time), then settles. + Future settle(WidgetTester tester) async { + await tester.runAsync(() => Future.delayed(const Duration(milliseconds: 50))); + await tester.pumpAndSettle(); + } + + testWidgets('the flame counts finished days across both Modes and opens the Streak panel', (tester) async { + await withClock(Clock.fixed(noon), () async { + await finished(tester, Mode.workout, noon.subtract(const Duration(days: 1))); + await finished(tester, Mode.stretching, noon); + usePhoneScreen(tester); + await pumpApp(tester, plans: plans, sessions: sessions, notifier: notifier); + await settle(tester); + + expect(find.descendant(of: find.byTooltip('Streak'), matching: find.text('2')), findsOneWidget); + + await tester.tap(find.byTooltip('Streak')); + await settle(tester); + + expect(find.text('2-day streak'), findsOneWidget); + expect(find.text('Today is done.'), findsOneWidget); + expect(find.text('Freezes: 0 of 2'), findsOneWidget); + }); + }); + + testWidgets('History lists the Mode\'s Sessions by day and reopens a summary', (tester) async { + await withClock(Clock.fixed(noon), () async { + await finished(tester, Mode.workout, noon, complete: false); + await finished(tester, Mode.stretching, noon); + usePhoneScreen(tester); + await pumpApp(tester, plans: plans, sessions: sessions, prefs: {'mode': 'workout'}); + await tester.tap(find.byTooltip('History')); + await settle(tester); + + expect(find.text('Today'), findsOneWidget); + expect(find.text('Legs'), findsOneWidget); + expect(find.text('Ended early'), findsOneWidget); + + await tester.tap(find.text('Legs')); + await settle(tester); + + expect(find.text('Set 1 · 10 reps'), findsOneWidget); + }); + }); + + testWidgets('switching a Reminder off in Settings drops its notifications', (tester) async { + await withClock(Clock.fixed(noon), () async { + usePhoneScreen(tester); + await pumpApp(tester, plans: plans, sessions: sessions, notifier: notifier); + await tester.tap(find.byTooltip('Settings')); + await tester.pumpAndSettle(); + + expect(find.text('19:00'), findsOneWidget); + expect(find.text('22:30'), findsOneWidget); + + await tester.tap(find.byType(Switch).at(1)); + await settle(tester); + + expect(notifier.permissionRequests, 0); + expect(notifier.scheduled, isNotEmpty); + expect(notifier.scheduled.every((r) => r.$1.hour == 19), isTrue); + }); + }); +}