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
+1972 -50

No files matched your search

+10 -1
View File
@@ -10,6 +10,7 @@ import 'src/history/session_repository.dart';
import 'src/plans/plan_repository.dart';
import 'src/player/player_effects.dart';
import 'src/settings/settings_controller.dart';
import 'src/streak/reminder_scheduler.dart';
void main() async {
WidgetsFlutterBinding.ensureInitialized();
@@ -19,6 +20,12 @@ void main() async {
final library = await ExerciseLibrary.load(rootBundle);
final settings = SettingsController(SettingsService());
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(
MultiProvider(
@@ -26,7 +33,9 @@ void main() async {
ChangeNotifierProvider.value(value: settings),
Provider.value(value: library),
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()),
],
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/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(),
},
),
+10 -5
View File
@@ -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,
+12
View File
@@ -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<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(
id: row.id,
mode: Mode.fromName(row.mode),
+118 -7
View File
@@ -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<SettingsController>().mode;
return StreamBuilder(
stream: context.read<SessionRepository>().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 = <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)),
),
],
),
),
),
);
}
}
+16 -4
View File
@@ -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<HomeView> {
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),
),
],
),
),
+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"
}
+225 -10
View File
@@ -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<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
/// 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<LocalizationsDelegate<dynamic>> localizationsDelegates = <LocalizationsDelegate<dynamic>>[
delegate,
GlobalMaterialLocalizations.delegate,
GlobalCupertinoLocalizations.delegate,
GlobalWidgetsLocalizations.delegate,
];
static const List<LocalizationsDelegate<dynamic>> localizationsDelegates =
<LocalizationsDelegate<dynamic>>[
delegate,
GlobalMaterialLocalizations.delegate,
GlobalCupertinoLocalizations.delegate,
GlobalWidgetsLocalizations.delegate,
];
/// A list of this localizations delegate's supported locales.
static const List<Locale> supportedLocales = <Locale>[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<AppLocalizations> {
class _AppLocalizationsDelegate
extends LocalizationsDelegate<AppLocalizations> {
const _AppLocalizationsDelegate();
@override
@@ -851,7 +1065,8 @@ class _AppLocalizationsDelegate extends LocalizationsDelegate<AppLocalizations>
}
@override
bool isSupported(Locale locale) => <String>['en'].contains(locale.languageCode);
bool isSupported(Locale locale) =>
<String>['en'].contains(locale.languageCode);
@override
bool shouldReload(_AppLocalizationsDelegate old) => false;
+192 -14
View File
@@ -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';
}
+5 -1
View File
@@ -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<PlayerView> {
navigator.pop();
return;
}
final scheduler = context.read<ReminderScheduler>();
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 {
+55 -1
View File
@@ -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<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 {
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 '../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> 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<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 {
@@ -25,16 +52,26 @@ class SettingsController extends ChangeNotifier {
final SettingsService _service;
Mode _mode = Mode.stretching;
bool _muted = false;
List<Reminder> _reminders = defaultReminders;
SettingsService get service => _service;
Mode get mode => _mode;
bool get muted => _muted;
List<Reminder> get reminders => _reminders;
Future<void> loadSettings() async {
_mode = await _service.mode();
_muted = await _service.muted();
_reminders = await _service.reminders();
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 {
_muted = muted;
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,
),
),
);
}
}