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 <noreply@anthropic.com>
This commit is contained in:
CedricandClaude Opus 5.5 committed 2026-10-29 00:34:49 +01:00
1 parent 971ff64117
commit 1ffe6269ea
31 files changed
+1958 -36

No files matched your search

+3 -2
View File
@@ -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. days than Freezes held resets the Streak to 0.
**Reminder**: **Reminder**:
A local notification sent only if no complete Session exists for the current Streak Day. Two by A local notification sent only if no complete Session exists for that day. Two by default (19:00 and
default (19:00 and 22:30); each can be switched on/off and moved in Settings. No more than two. 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).
+6
View File
@@ -11,6 +11,8 @@ android {
ndkVersion = "27.0.12077973" ndkVersion = "27.0.12077973"
compileOptions { compileOptions {
// Desugaring: flutter_local_notifications needs java.time on older Android versions.
isCoreLibraryDesugaringEnabled = true
sourceCompatibility = JavaVersion.VERSION_11 sourceCompatibility = JavaVersion.VERSION_11
targetCompatibility = JavaVersion.VERSION_11 targetCompatibility = JavaVersion.VERSION_11
} }
@@ -42,3 +44,7 @@ android {
flutter { flutter {
source = "../.." source = "../.."
} }
dependencies {
coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.4")
}
+12
View File
@@ -1,4 +1,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"> <manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- Reminders: shown as notifications and rescheduled after a reboot. -->
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED"/>
<application <application
android:label="ZenXercise" android:label="ZenXercise"
android:name="${applicationName}" android:name="${applicationName}"
@@ -25,6 +28,15 @@
<category android:name="android.intent.category.LAUNCHER"/> <category android:name="android.intent.category.LAUNCHER"/>
</intent-filter> </intent-filter>
</activity> </activity>
<receiver android:exported="false" android:name="com.dexterous.flutterlocalnotifications.ScheduledNotificationReceiver" />
<receiver android:exported="false" android:name="com.dexterous.flutterlocalnotifications.ScheduledNotificationBootReceiver">
<intent-filter>
<action android:name="android.intent.action.BOOT_COMPLETED"/>
<action android:name="android.intent.action.MY_PACKAGE_REPLACED"/>
<action android:name="android.intent.action.QUICKBOOT_POWERON" />
<action android:name="com.htc.intent.action.QUICKBOOT_POWERON"/>
</intent-filter>
</receiver>
<!-- Don't delete the meta-data below. <!-- Don't delete the meta-data below.
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java --> This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
<meta-data <meta-data
@@ -0,0 +1,25 @@
# ADR 0006 — The Streak is derived; Reminders are rescheduled, not repeating
Status: Accepted (2026-10-29)
## Streak
The Streak, its Freezes and the calendar are computed from scratch from the end times of all complete
Sessions (both Modes) every time they are shown (`Streak.compute`). Nothing about the Streak is stored:
a Freeze is earned, held and spent deterministically by walking the days from the first Streak Day to
today. The Grace Hour is applied while assigning Sessions to Streak Days, in time order, so the rule
"counts for yesterday unless yesterday is already done" is stable.
A few years of daily Sessions is a few thousand rows, which is cheap to walk.
## Reminders
Android cannot skip a repeating notification on days that are already done, so instead of repeating
alarms the app schedules one-off notifications for the next 14 days and replaces them whenever
something changes: on app start, after every saved Session and after a Reminder setting changes. A day
that is done gets none. If the app is not opened for two weeks, Reminders stop; that is acceptable for
a gentle nudge.
They use inexact scheduling (`inexactAllowWhileIdle`), which needs no exact-alarm permission but lets
Android deliver up to about an hour late. Permission to post notifications is asked for after the
first complete Session, or from Settings.
+1
View File
@@ -14,4 +14,5 @@ Things deliberately left out of v1.
- Translations (v1 is English only) - Translations (v1 is English only)
- Light/dark setting per Mode (v1 has a fixed look per Mode) - Light/dark setting per Mode (v1 has a fixed look per Mode)
- Full-screen Streak celebrations and milestones (7/30/100/365 days) - Full-screen Streak celebrations and milestones (7/30/100/365 days)
- Exact-time Reminders (needs Android's exact-alarm permission)
- Mixed Plans that combine Stretching and Workout (e.g. warm-up, workout, cool-down) - Mixed Plans that combine Stretching and Workout (e.g. warm-up, workout, cool-down)
+10 -1
View File
@@ -10,6 +10,7 @@ import 'src/history/session_repository.dart';
import 'src/plans/plan_repository.dart'; import 'src/plans/plan_repository.dart';
import 'src/player/player_effects.dart'; import 'src/player/player_effects.dart';
import 'src/settings/settings_controller.dart'; import 'src/settings/settings_controller.dart';
import 'src/streak/reminder_scheduler.dart';
void main() async { void main() async {
WidgetsFlutterBinding.ensureInitialized(); WidgetsFlutterBinding.ensureInitialized();
@@ -19,6 +20,12 @@ void main() async {
final library = await ExerciseLibrary.load(rootBundle); final library = await ExerciseLibrary.load(rootBundle);
final settings = SettingsController(SettingsService()); final settings = SettingsController(SettingsService());
await settings.loadSettings(); await settings.loadSettings();
final sessions = SessionRepository(db);
final notifier = LocalNotifier();
await notifier.init();
final reminders = ReminderScheduler(notifier, settings, sessions);
// Today may have been finished, or a day may have passed, since the last run.
reminders.sync();
runApp( runApp(
MultiProvider( MultiProvider(
@@ -26,7 +33,9 @@ void main() async {
ChangeNotifierProvider.value(value: settings), ChangeNotifierProvider.value(value: settings),
Provider.value(value: library), Provider.value(value: library),
Provider.value(value: PlanRepository(db)), Provider.value(value: PlanRepository(db)),
Provider.value(value: SessionRepository(db)), Provider.value(value: sessions),
Provider<Notifier>.value(value: notifier),
Provider.value(value: reminders),
Provider<PlayerEffects>(create: (_) => DevicePlayerEffects()), Provider<PlayerEffects>(create: (_) => DevicePlayerEffects()),
], ],
child: const ZenXerciseApp(), child: const ZenXerciseApp(),
+6 -1
View File
@@ -13,6 +13,7 @@ import 'plans/views/plan_editor_view.dart';
import 'player/views/player_view.dart'; import 'player/views/player_view.dart';
import 'player/views/summary_view.dart'; import 'player/views/summary_view.dart';
import 'settings/settings_controller.dart'; import 'settings/settings_controller.dart';
import 'settings/settings_view.dart';
class ZenXerciseApp extends StatelessWidget { class ZenXerciseApp extends StatelessWidget {
const ZenXerciseApp({super.key}); const ZenXerciseApp({super.key});
@@ -47,7 +48,11 @@ class ZenXerciseApp extends StatelessWidget {
PlanEditorView.routeName => PlanEditorView(planId: settings.arguments! as String), PlanEditorView.routeName => PlanEditorView(planId: settings.arguments! as String),
ExercisePickerView.routeName => ExercisePickerView(planId: settings.arguments! as String), ExercisePickerView.routeName => ExercisePickerView(planId: settings.arguments! as String),
PlayerView.routeName => PlayerView(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(), _ => const HomeView(),
}, },
), ),
+10 -5
View File
@@ -19,6 +19,12 @@ class ModeSwitch extends StatelessWidget {
final s = ZenStyle.of(context); final s = ZenStyle.of(context);
final l10n = AppLocalizations.of(context); final l10n = AppLocalizations.of(context);
final radius = BorderRadius.circular(s.controlRadius.clamp(0, height / 2)); 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( return Semantics(
label: l10n.modeSwitchLabel, label: l10n.modeSwitchLabel,
child: Container( child: Container(
@@ -45,13 +51,13 @@ class ModeSwitch extends StatelessWidget {
Row( Row(
children: [ children: [
_Segment( _Segment(
icon: Icons.self_improvement_rounded, icon: icons ? Icons.self_improvement_rounded : null,
label: l10n.modeStretching, label: l10n.modeStretching,
selected: mode == Mode.stretching, selected: mode == Mode.stretching,
onTap: () => onChanged(Mode.stretching), onTap: () => onChanged(Mode.stretching),
), ),
_Segment( _Segment(
icon: Icons.fitness_center_rounded, icon: icons ? Icons.fitness_center_rounded : null,
label: l10n.modeWorkout, label: l10n.modeWorkout,
selected: mode == Mode.workout, selected: mode == Mode.workout,
onTap: () => onChanged(Mode.workout), onTap: () => onChanged(Mode.workout),
@@ -68,7 +74,7 @@ class ModeSwitch extends StatelessWidget {
class _Segment extends StatelessWidget { class _Segment extends StatelessWidget {
const _Segment({required this.icon, required this.label, required this.selected, required this.onTap}); const _Segment({required this.icon, required this.label, required this.selected, required this.onTap});
final IconData icon; final IconData? icon;
final String label; final String label;
final bool selected; final bool selected;
final VoidCallback onTap; final VoidCallback onTap;
@@ -87,8 +93,7 @@ class _Segment extends StatelessWidget {
child: Row( child: Row(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
Icon(icon, size: 18, color: color), if (icon != null) ...[Icon(icon, size: 18, color: color), const SizedBox(width: 6)],
const SizedBox(width: 6),
Flexible( Flexible(
child: Text( child: Text(
label, label,
+12
View File
@@ -71,6 +71,18 @@ class SessionRepository {
return query.watch().map((rows) => rows.map(_toSession).toList()); return query.watch().map((rows) => rows.map(_toSession).toList());
} }
/// When every complete Session of either Mode ended: all the Streak needs.
Stream<List<DateTime>> watchCompleteEndTimes() => _completeEndTimes().watch();
Future<List<DateTime>> completeEndTimes() => _completeEndTimes().get();
Selectable<DateTime> _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( static Session _toSession(SessionRow row) => Session(
id: row.id, id: row.id,
mode: Mode.fromName(row.mode), mode: Mode.fromName(row.mode),
+112 -1
View File
@@ -1,15 +1,31 @@
import 'package:clock/clock.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import 'package:provider/provider.dart';
import '../../design/page_header.dart'; import '../../design/page_header.dart';
import '../../design/zen_nav_bar.dart';
import '../../design/zen_style.dart';
import '../../localization/app_localizations.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 { class HistoryView extends StatelessWidget {
const HistoryView({super.key}); const HistoryView({super.key});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context); final l10n = AppLocalizations.of(context);
final mode = context.watch<SettingsController>().mode;
return StreamBuilder(
stream: context.read<SessionRepository>().watchSessions(mode),
builder: (context, snapshot) {
final sessions = snapshot.data ?? const [];
if (sessions.isEmpty) {
return ListView( return ListView(
children: [ children: [
PageHeader(title: l10n.historyTitle), PageHeader(title: l10n.historyTitle),
@@ -18,4 +34,99 @@ class HistoryView extends StatelessWidget {
], ],
); );
} }
final today = Day.of(clock.now());
final rows = <Widget>[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)),
),
],
),
),
),
);
}
} }
+13 -1
View File
@@ -9,6 +9,8 @@ import '../history/views/history_view.dart';
import '../localization/app_localizations.dart'; import '../localization/app_localizations.dart';
import '../plans/views/plans_view.dart'; import '../plans/views/plans_view.dart';
import '../settings/settings_controller.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 /// The Mode switch on top, the current Mode's Plans, Library and History tabs
/// below, and the floating navigation bar. /// below, and the floating navigation bar.
@@ -47,12 +49,22 @@ class _HomeViewState extends State<HomeView> {
padding: const EdgeInsets.fromLTRB(16, 12, 16, 0), padding: const EdgeInsets.fromLTRB(16, 12, 16, 0),
child: Row( child: Row(
children: [ children: [
Flexible( Expanded(
child: Align(
alignment: Alignment.centerLeft,
child: ConstrainedBox( child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 300), constraints: const BoxConstraints(maxWidth: 300),
child: ModeSwitch(mode: settings.mode, onChanged: settings.updateMode), 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),
),
], ],
), ),
), ),
+84 -1
View File
@@ -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"
} }
+220 -5
View File
@@ -61,7 +61,8 @@ import 'app_localizations_en.dart';
/// be consistent with the languages listed in the AppLocalizations.supportedLocales /// be consistent with the languages listed in the AppLocalizations.supportedLocales
/// property. /// property.
abstract class AppLocalizations { abstract class AppLocalizations {
AppLocalizations(String locale) : localeName = intl.Intl.canonicalizedLocale(locale.toString()); AppLocalizations(String locale)
: localeName = intl.Intl.canonicalizedLocale(locale.toString());
final String localeName; final String localeName;
@@ -69,7 +70,8 @@ abstract class AppLocalizations {
return Localizations.of<AppLocalizations>(context, AppLocalizations)!; return Localizations.of<AppLocalizations>(context, AppLocalizations)!;
} }
static const LocalizationsDelegate<AppLocalizations> delegate = _AppLocalizationsDelegate(); static const LocalizationsDelegate<AppLocalizations> delegate =
_AppLocalizationsDelegate();
/// A list of this localizations delegate along with the default localizations /// A list of this localizations delegate along with the default localizations
/// delegates. /// delegates.
@@ -81,7 +83,8 @@ abstract class AppLocalizations {
/// Additional delegates can be added by appending to this list in /// 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 /// MaterialApp. This list does not have to be used at all if a custom list
/// of delegates is preferred or required. /// of delegates is preferred or required.
static const List<LocalizationsDelegate<dynamic>> localizationsDelegates = <LocalizationsDelegate<dynamic>>[ static const List<LocalizationsDelegate<dynamic>> localizationsDelegates =
<LocalizationsDelegate<dynamic>>[
delegate, delegate,
GlobalMaterialLocalizations.delegate, GlobalMaterialLocalizations.delegate,
GlobalCupertinoLocalizations.delegate, GlobalCupertinoLocalizations.delegate,
@@ -840,9 +843,220 @@ abstract class AppLocalizations {
/// In en, this message translates to: /// In en, this message translates to:
/// **'Add a weight'** /// **'Add a weight'**
String get logWeight; 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<AppLocalizations> { class _AppLocalizationsDelegate
extends LocalizationsDelegate<AppLocalizations> {
const _AppLocalizationsDelegate(); const _AppLocalizationsDelegate();
@override @override
@@ -851,7 +1065,8 @@ class _AppLocalizationsDelegate extends LocalizationsDelegate<AppLocalizations>
} }
@override @override
bool isSupported(Locale locale) => <String>['en'].contains(locale.languageCode); bool isSupported(Locale locale) =>
<String>['en'].contains(locale.languageCode);
@override @override
bool shouldReload(_AppLocalizationsDelegate old) => false; bool shouldReload(_AppLocalizationsDelegate old) => false;
+192 -14
View File
@@ -38,8 +38,10 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String plansEmptyBody(String mode) { String plansEmptyBody(String mode) {
String _temp0 = intl.Intl.selectLogic(mode, { String _temp0 = intl.Intl.selectLogic(mode, {
'stretching': 'Put together a stretch routine from the library and it will wait for you here.', 'stretching':
'other': 'Put together a workout from the library and it will wait for you here.', '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'; return '$_temp0';
} }
@@ -49,9 +51,22 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String librarySubtitle(String mode, int count) { String librarySubtitle(String mode, int count) {
String _temp0 = intl.Intl.pluralLogic(count, locale: localeName, other: '$count stretches', one: '1 stretch'); String _temp0 = intl.Intl.pluralLogic(
String _temp1 = intl.Intl.pluralLogic(count, locale: localeName, other: '$count exercises', one: '1 exercise'); count,
String _temp2 = intl.Intl.selectLogic(mode, {'stretching': '$_temp0', 'other': '$_temp1'}); 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'; return '$_temp2';
} }
@@ -62,7 +77,8 @@ class AppLocalizationsEn extends AppLocalizations {
String get historyEmptyTitle => 'Nothing logged yet'; String get historyEmptyTitle => 'Nothing logged yet';
@override @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 @override
String librarySubtitleFiltered(String mode, int shown, int total) { String librarySubtitleFiltered(String mode, int shown, int total) {
@@ -75,7 +91,10 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String searchHint(String mode) { 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'; return '$_temp0';
} }
@@ -110,7 +129,8 @@ class AppLocalizationsEn extends AppLocalizations {
String get noMatchesTitle => 'No matches'; String get noMatchesTitle => 'No matches';
@override @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 @override
String get detailMainMuscles => 'Main muscles'; String get detailMainMuscles => 'Main muscles';
@@ -147,7 +167,10 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String planNameHint(String mode) { 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'; return '$_temp0';
} }
@@ -195,7 +218,8 @@ class AppLocalizationsEn extends AppLocalizations {
} }
@override @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 @override
String planExerciseCount(int count) { String planExerciseCount(int count) {
@@ -245,7 +269,12 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String repsCount(int count) { 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'; return '$_temp0';
} }
@@ -269,13 +298,23 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String roundsCount(int count) { 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'; return '$_temp0';
} }
@override @override
String setsCount(int count) { 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'; return '$_temp0';
} }
@@ -316,7 +355,8 @@ class AppLocalizationsEn extends AppLocalizations {
String get restBetweenLabel => 'Rest between exercises'; String get restBetweenLabel => 'Rest between exercises';
@override @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 @override
String get supersetWithNext => 'Superset with next'; String get supersetWithNext => 'Superset with next';
@@ -482,4 +522,142 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get logWeight => 'Add a weight'; 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';
} }
+5 -1
View File
@@ -16,6 +16,7 @@ import '../../plans/models/plan.dart';
import '../../plans/plan_labels.dart'; import '../../plans/plan_labels.dart';
import '../../plans/plan_repository.dart'; import '../../plans/plan_repository.dart';
import '../../settings/settings_controller.dart'; import '../../settings/settings_controller.dart';
import '../../streak/reminder_scheduler.dart';
import '../player_effects.dart'; import '../player_effects.dart';
import '../segments.dart'; import '../segments.dart';
import '../session_runner.dart'; import '../session_runner.dart';
@@ -81,8 +82,11 @@ class _PlayerViewState extends State<PlayerView> {
navigator.pop(); navigator.pop();
return; return;
} }
final scheduler = context.read<ReminderScheduler>();
final session = await context.read<SessionRepository>().save(runner.result(clock.now())); final session = await context.read<SessionRepository>().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<void> _confirmEnd() async { Future<void> _confirmEnd() async {
+55 -1
View File
@@ -8,17 +8,23 @@ import '../../localization/app_localizations.dart';
import '../../modes/mode.dart'; import '../../modes/mode.dart';
import '../../plans/models/plan.dart'; import '../../plans/models/plan.dart';
import '../../plans/plan_labels.dart'; import '../../plans/plan_labels.dart';
import '../../streak/streak.dart';
import '../../streak/views/streak_views.dart';
import '../session_runner.dart'; import '../session_runner.dart';
/// Shown when a Session ends: how long it took, what was done and, for /// Shown when a Session ends: how long it took, what was done and, for
/// Workouts, every logged set. /// Workouts, every logged set.
class SummaryView extends StatelessWidget { 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'; static const routeName = '/summary';
final String sessionId; 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context); final l10n = AppLocalizations.of(context);
@@ -50,6 +56,7 @@ class SummaryView extends StatelessWidget {
Text(session.complete ? l10n.summaryComplete : l10n.summaryIncomplete, style: text.headlineLarge), Text(session.complete ? l10n.summaryComplete : l10n.summaryIncomplete, style: text.headlineLarge),
const SizedBox(height: 4), const SizedBox(height: 4),
Text(session.planName, style: text.titleMedium?.copyWith(color: s.inkMuted)), Text(session.planName, style: text.titleMedium?.copyWith(color: s.inkMuted)),
if (justFinished && session.complete) _StreakLine(session: session),
const SizedBox(height: 28), const SizedBox(height: 28),
Row( Row(
children: [ 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<SessionRepository>();
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 { class _Stat extends StatelessWidget {
const _Stat({required this.label, required this.value}); const _Stat({required this.label, required this.value});
+37
View File
@@ -2,11 +2,15 @@ import 'package:flutter/foundation.dart';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
import '../modes/mode.dart'; import '../modes/mode.dart';
import '../streak/reminders.dart';
/// Stores the user's app preferences on the device. /// Stores the user's app preferences on the device.
class SettingsService { class SettingsService {
static const _modeKey = 'mode'; static const _modeKey = 'mode';
static const _mutedKey = 'muted'; 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. /// The last-used Mode; Stretching on first launch.
Future<Mode> mode() async => Mode.fromName((await SharedPreferences.getInstance()).getString(_modeKey)); Future<Mode> mode() async => Mode.fromName((await SharedPreferences.getInstance()).getString(_modeKey));
@@ -17,6 +21,29 @@ class SettingsService {
Future<bool> muted() async => (await SharedPreferences.getInstance()).getBool(_mutedKey) ?? false; Future<bool> muted() async => (await SharedPreferences.getInstance()).getBool(_mutedKey) ?? false;
Future<void> updateMuted(bool muted) async => (await SharedPreferences.getInstance()).setBool(_mutedKey, muted); Future<void> updateMuted(bool muted) async => (await SharedPreferences.getInstance()).setBool(_mutedKey, muted);
/// The two Reminder slots, each falling back to its default.
Future<List<Reminder>> 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<void> 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<bool> notificationsAsked() async => (await SharedPreferences.getInstance()).getBool(_askedKey) ?? false;
Future<void> markNotificationsAsked() async => (await SharedPreferences.getInstance()).setBool(_askedKey, true);
} }
class SettingsController extends ChangeNotifier { class SettingsController extends ChangeNotifier {
@@ -25,16 +52,26 @@ class SettingsController extends ChangeNotifier {
final SettingsService _service; final SettingsService _service;
Mode _mode = Mode.stretching; Mode _mode = Mode.stretching;
bool _muted = false; bool _muted = false;
List<Reminder> _reminders = defaultReminders;
SettingsService get service => _service;
Mode get mode => _mode; Mode get mode => _mode;
bool get muted => _muted; bool get muted => _muted;
List<Reminder> get reminders => _reminders;
Future<void> loadSettings() async { Future<void> loadSettings() async {
_mode = await _service.mode(); _mode = await _service.mode();
_muted = await _service.muted(); _muted = await _service.muted();
_reminders = await _service.reminders();
notifyListeners(); notifyListeners();
} }
Future<void> updateReminder(int slot, Reminder reminder) async {
_reminders = [..._reminders]..[slot] = reminder;
notifyListeners();
await _service.updateReminder(slot, reminder);
}
Future<void> updateMuted(bool muted) async { Future<void> updateMuted(bool muted) async {
_muted = muted; _muted = muted;
notifyListeners(); notifyListeners();
+167
View File
@@ -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<SettingsController>();
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<bool> _enabled = context.read<Notifier>().enabled();
Future<void> _allow() async {
final notifier = context.read<Notifier>();
final scheduler = context.read<ReminderScheduler>();
await context.read<SettingsController>().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<void> _update(BuildContext context, Reminder updated) async {
final scheduler = context.read<ReminderScheduler>();
await context.read<SettingsController>().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)),
),
],
),
);
}
}
+42
View File
@@ -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> {
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')}';
}
+113
View File
@@ -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<void> init();
/// Asks Android for the notification permission; true when granted.
Future<bool> requestPermission();
Future<bool> enabled();
Future<void> cancelAll();
Future<void> 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<AndroidFlutterLocalNotificationsPlugin>();
@override
Future<void> 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<bool> requestPermission() async => await _android?.requestNotificationsPermission() ?? false;
@override
Future<bool> enabled() async => await _android?.areNotificationsEnabled() ?? false;
@override
Future<void> cancelAll() => _plugin.cancelAll();
@override
Future<void> 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<void> 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,
);
}
}
}
+67
View File
@@ -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<PlannedReminder> upcomingReminders(
List<Reminder> 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,
),
];
}
+93
View File
@@ -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<Day> streakDays(Iterable<DateTime> endTimes) {
final days = <Day>{};
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<Day> activeDays;
/// Missed days a Freeze covered.
final Set<Day> frozenDays;
static Streak compute(Iterable<DateTime> 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 = <Day>{};
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,
);
}
}
+285
View File
@@ -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<SessionRepository>().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<void> showStreakSheet(BuildContext context) => showModalBottomSheet<void>(
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<Day> 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,
),
),
);
}
}
+57 -1
View File
@@ -249,6 +249,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.2.8" version: "0.2.8"
equatable:
dependency: transitive
description:
name: equatable
sha256: ea36e500cbf0ec18361deabca755434f73e42fe53c5130021c7e2d6b5ebe1702
url: "https://pub.dev"
source: hosted
version: "3.0.0"
fake_async: fake_async:
dependency: transitive dependency: transitive
description: description:
@@ -294,6 +302,38 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "5.0.0" 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: flutter_localizations:
dependency: "direct main" dependency: "direct main"
description: flutter description: flutter
@@ -304,6 +344,14 @@ packages:
description: flutter description: flutter
source: sdk source: sdk
version: "0.0.0" 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: flutter_web_plugins:
dependency: transitive dependency: transitive
description: flutter description: flutter
@@ -770,6 +818,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.7.4" 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: typed_data:
dependency: transitive dependency: transitive
description: description:
@@ -884,4 +940,4 @@ packages:
version: "3.1.4" version: "3.1.4"
sdks: sdks:
dart: ">=3.8.1 <4.0.0" dart: ">=3.8.1 <4.0.0"
flutter: ">=3.29.0" flutter: ">=3.32.0"
+3
View File
@@ -13,11 +13,14 @@ dependencies:
drift_flutter: ^0.2.8 drift_flutter: ^0.2.8
flutter: flutter:
sdk: flutter sdk: flutter
flutter_local_notifications: ^20.1.0
flutter_localizations: flutter_localizations:
sdk: flutter sdk: flutter
flutter_timezone: ^5.1.1
intl: ^0.20.2 intl: ^0.20.2
provider: ^6.1.5+1 provider: ^6.1.5+1
shared_preferences: ^2.5.3 shared_preferences: ^2.5.3
timezone: ^0.10.1
uuid: ^4.6.0 uuid: ^4.6.0
wakelock_plus: ^1.4.0 wakelock_plus: ^1.4.0
@@ -55,4 +55,12 @@ void main() {
expect(workout.map((s) => s.endedAt.day), [29, 28]); 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)]));
});
} }
+69
View File
@@ -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]);
});
}
+90
View File
@@ -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);
});
});
}
+32 -1
View File
@@ -10,6 +10,7 @@ import 'package:zenxercise/src/history/session_repository.dart';
import 'package:zenxercise/src/plans/plan_repository.dart'; import 'package:zenxercise/src/plans/plan_repository.dart';
import 'package:zenxercise/src/player/player_effects.dart'; import 'package:zenxercise/src/player/player_effects.dart';
import 'package:zenxercise/src/settings/settings_controller.dart'; import 'package:zenxercise/src/settings/settings_controller.dart';
import 'package:zenxercise/src/streak/reminder_scheduler.dart';
import 'test_database.dart'; import 'test_database.dart';
@@ -62,6 +63,31 @@ class FakePlayerEffects implements PlayerEffects {
Future<void> keepScreenOn(bool on) async => screenOn = on; Future<void> 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<void> init() async {}
@override
Future<bool> requestPermission() async {
permissionRequests++;
return true;
}
@override
Future<bool> enabled() async => true;
@override
Future<void> cancelAll() async => scheduled.clear();
@override
Future<void> 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 /// A Pixel-sized screen instead of the default 800×600, so the floating
/// navigation bar does not cover what a test taps. /// navigation bar does not cover what a test taps.
void usePhoneScreen(WidgetTester tester) { void usePhoneScreen(WidgetTester tester) {
@@ -78,20 +104,25 @@ Future<SettingsController> pumpApp(
PlanRepository? plans, PlanRepository? plans,
SessionRepository? sessions, SessionRepository? sessions,
PlayerEffects? effects, PlayerEffects? effects,
FakeNotifier? notifier,
}) async { }) async {
SharedPreferences.setMockInitialValues(prefs); SharedPreferences.setMockInitialValues(prefs);
final settings = SettingsController(SettingsService()); final settings = SettingsController(SettingsService());
await settings.loadSettings(); await settings.loadSettings();
final db = testDatabase(); final db = testDatabase();
addTearDown(db.close); addTearDown(db.close);
final sessionRepository = sessions ?? SessionRepository(db);
final fakeNotifier = notifier ?? FakeNotifier();
await tester.pumpWidget( await tester.pumpWidget(
MultiProvider( MultiProvider(
providers: [ providers: [
ChangeNotifierProvider.value(value: settings), ChangeNotifierProvider.value(value: settings),
Provider.value(value: library ?? sampleLibrary), Provider.value(value: library ?? sampleLibrary),
Provider.value(value: plans ?? PlanRepository(db)), Provider.value(value: plans ?? PlanRepository(db)),
Provider.value(value: sessions ?? SessionRepository(db)), Provider.value(value: sessionRepository),
Provider<PlayerEffects>.value(value: effects ?? FakePlayerEffects()), Provider<PlayerEffects>.value(value: effects ?? FakePlayerEffects()),
Provider<Notifier>.value(value: fakeNotifier),
Provider.value(value: ReminderScheduler(fakeNotifier, settings, sessionRepository)),
], ],
child: const ZenXerciseApp(), child: const ZenXerciseApp(),
), ),
+15 -1
View File
@@ -12,6 +12,7 @@ void main() {
late PlanRepository plans; late PlanRepository plans;
late SessionRepository sessions; late SessionRepository sessions;
late FakePlayerEffects effects; late FakePlayerEffects effects;
late FakeNotifier notifier;
setUp(() { setUp(() {
final db = testDatabase(); final db = testDatabase();
@@ -19,13 +20,21 @@ void main() {
plans = PlanRepository(db); plans = PlanRepository(db);
sessions = SessionRepository(db); sessions = SessionRepository(db);
effects = FakePlayerEffects(); effects = FakePlayerEffects();
notifier = FakeNotifier();
}); });
Future<void> startPlan(WidgetTester tester, Mode mode, List<PlanItem> items) async { Future<void> startPlan(WidgetTester tester, Mode mode, List<PlanItem> items) async {
final plan = await plans.create(mode, 'Routine'); final plan = await plans.create(mode, 'Routine');
await plans.save(plan.copyWith(items: items)); await plans.save(plan.copyWith(items: items));
usePhoneScreen(tester); 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.tap(find.byTooltip('Start'));
await tester.pump(); await tester.pump();
await tester.pump(); await tester.pump();
@@ -66,6 +75,11 @@ void main() {
await settle(tester); await settle(tester);
expect(find.text('Session complete'), findsOneWidget); expect(find.text('Session complete'), findsOneWidget);
await tester.runAsync(() => Future<void>.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.chimes, [Mode.stretching, Mode.stretching]);
expect(effects.screenOn, isFalse); expect(effects.screenOn, isFalse);
expect((await sessionsOf(tester, Mode.stretching)).single.complete, isTrue); expect((await sessionsOf(tester, Mode.stretching)).single.complete, isTrue);
+114
View File
@@ -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<void> 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<void> settle(WidgetTester tester) async {
await tester.runAsync(() => Future<void>.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);
});
});
}