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:
1 parent
971ff64117
commit
1ffe6269ea
31 files changed
+1972
-50
No files matched your search
+3
-2
@@ -100,5 +100,6 @@ per 7 consecutive Streak Days; at most 2 are held. A frozen day shows ❄️ in
|
||||
days than Freezes held resets the Streak to 0.
|
||||
|
||||
**Reminder**:
|
||||
A local notification sent only if no complete Session exists for the current Streak Day. Two by
|
||||
default (19:00 and 22:30); each can be switched on/off and moved in Settings. No more than two.
|
||||
A local notification sent only if no complete Session exists for that day. Two by default (19:00 and
|
||||
22:30); each can be switched on/off and moved in Settings. No more than two. Android may deliver one up
|
||||
to about an hour late (see docs/adr/0006).
|
||||
@@ -11,6 +11,8 @@ android {
|
||||
ndkVersion = "27.0.12077973"
|
||||
|
||||
compileOptions {
|
||||
// Desugaring: flutter_local_notifications needs java.time on older Android versions.
|
||||
isCoreLibraryDesugaringEnabled = true
|
||||
sourceCompatibility = JavaVersion.VERSION_11
|
||||
targetCompatibility = JavaVersion.VERSION_11
|
||||
}
|
||||
@@ -42,3 +44,7 @@ android {
|
||||
flutter {
|
||||
source = "../.."
|
||||
}
|
||||
|
||||
dependencies {
|
||||
coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.4")
|
||||
}
|
||||
@@ -1,4 +1,7 @@
|
||||
<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
|
||||
android:label="ZenXercise"
|
||||
android:name="${applicationName}"
|
||||
@@ -25,6 +28,15 @@
|
||||
<category android:name="android.intent.category.LAUNCHER"/>
|
||||
</intent-filter>
|
||||
</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.
|
||||
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
|
||||
<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.
|
||||
@@ -14,4 +14,5 @@ Things deliberately left out of v1.
|
||||
- Translations (v1 is English only)
|
||||
- Light/dark setting per Mode (v1 has a fixed look per Mode)
|
||||
- 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)
|
||||
+10
-1
@@ -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
@@ -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(),
|
||||
},
|
||||
),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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)),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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});
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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)),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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')}';
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
];
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -249,6 +249,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.2.8"
|
||||
equatable:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: equatable
|
||||
sha256: ea36e500cbf0ec18361deabca755434f73e42fe53c5130021c7e2d6b5ebe1702
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.0.0"
|
||||
fake_async:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -294,6 +302,38 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "5.0.0"
|
||||
flutter_local_notifications:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: flutter_local_notifications
|
||||
sha256: "2b50e938a275e1ad77352d6a25e25770f4130baa61eaf02de7a9a884680954ad"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "20.1.0"
|
||||
flutter_local_notifications_linux:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_local_notifications_linux
|
||||
sha256: dce0116868cedd2cdf768af0365fc37ff1cbef7c02c4f51d0587482e625868d0
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "7.0.0"
|
||||
flutter_local_notifications_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_local_notifications_platform_interface
|
||||
sha256: "23de31678a48c084169d7ae95866df9de5c9d2a44be3e5915a2ff067aeeba899"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "10.0.0"
|
||||
flutter_local_notifications_windows:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_local_notifications_windows
|
||||
sha256: e97a1a3016512437d9c0b12fae7d1491c3c7b9aa7f03a69b974308840656b02a
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.0.1"
|
||||
flutter_localizations:
|
||||
dependency: "direct main"
|
||||
description: flutter
|
||||
@@ -304,6 +344,14 @@ packages:
|
||||
description: flutter
|
||||
source: sdk
|
||||
version: "0.0.0"
|
||||
flutter_timezone:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: flutter_timezone
|
||||
sha256: "7830bc64bef0b003468fc1f58fc3f3faf54817f8cd6e68fa818b306560410d68"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "5.1.1"
|
||||
flutter_web_plugins:
|
||||
dependency: transitive
|
||||
description: flutter
|
||||
@@ -770,6 +818,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.7.4"
|
||||
timezone:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: timezone
|
||||
sha256: dd14a3b83cfd7cb19e7888f1cbc20f258b8d71b54c06f79ac585f14093a287d1
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.10.1"
|
||||
typed_data:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -884,4 +940,4 @@ packages:
|
||||
version: "3.1.4"
|
||||
sdks:
|
||||
dart: ">=3.8.1 <4.0.0"
|
||||
flutter: ">=3.29.0"
|
||||
flutter: ">=3.32.0"
|
||||
@@ -13,11 +13,14 @@ dependencies:
|
||||
drift_flutter: ^0.2.8
|
||||
flutter:
|
||||
sdk: flutter
|
||||
flutter_local_notifications: ^20.1.0
|
||||
flutter_localizations:
|
||||
sdk: flutter
|
||||
flutter_timezone: ^5.1.1
|
||||
intl: ^0.20.2
|
||||
provider: ^6.1.5+1
|
||||
shared_preferences: ^2.5.3
|
||||
timezone: ^0.10.1
|
||||
uuid: ^4.6.0
|
||||
wakelock_plus: ^1.4.0
|
||||
|
||||
|
||||
@@ -55,4 +55,12 @@ void main() {
|
||||
|
||||
expect(workout.map((s) => s.endedAt.day), [29, 28]);
|
||||
});
|
||||
|
||||
test('the Streak sees complete Sessions of both Modes only', () async {
|
||||
await sessions.save(result(Mode.workout, DateTime(2026, 10, 28)));
|
||||
await sessions.save(result(Mode.stretching, DateTime(2026, 10, 29)));
|
||||
await sessions.save(result(Mode.stretching, DateTime(2026, 10, 30), complete: false));
|
||||
|
||||
expect(await sessions.completeEndTimes(), unorderedEquals([DateTime(2026, 10, 28), DateTime(2026, 10, 29)]));
|
||||
});
|
||||
}
|
||||
@@ -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]);
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import 'package:zenxercise/src/history/session_repository.dart';
|
||||
import 'package:zenxercise/src/plans/plan_repository.dart';
|
||||
import 'package:zenxercise/src/player/player_effects.dart';
|
||||
import 'package:zenxercise/src/settings/settings_controller.dart';
|
||||
import 'package:zenxercise/src/streak/reminder_scheduler.dart';
|
||||
|
||||
import 'test_database.dart';
|
||||
|
||||
@@ -62,6 +63,31 @@ class FakePlayerEffects implements PlayerEffects {
|
||||
Future<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
|
||||
/// navigation bar does not cover what a test taps.
|
||||
void usePhoneScreen(WidgetTester tester) {
|
||||
@@ -78,20 +104,25 @@ Future<SettingsController> pumpApp(
|
||||
PlanRepository? plans,
|
||||
SessionRepository? sessions,
|
||||
PlayerEffects? effects,
|
||||
FakeNotifier? notifier,
|
||||
}) async {
|
||||
SharedPreferences.setMockInitialValues(prefs);
|
||||
final settings = SettingsController(SettingsService());
|
||||
await settings.loadSettings();
|
||||
final db = testDatabase();
|
||||
addTearDown(db.close);
|
||||
final sessionRepository = sessions ?? SessionRepository(db);
|
||||
final fakeNotifier = notifier ?? FakeNotifier();
|
||||
await tester.pumpWidget(
|
||||
MultiProvider(
|
||||
providers: [
|
||||
ChangeNotifierProvider.value(value: settings),
|
||||
Provider.value(value: library ?? sampleLibrary),
|
||||
Provider.value(value: plans ?? PlanRepository(db)),
|
||||
Provider.value(value: sessions ?? SessionRepository(db)),
|
||||
Provider.value(value: sessionRepository),
|
||||
Provider<PlayerEffects>.value(value: effects ?? FakePlayerEffects()),
|
||||
Provider<Notifier>.value(value: fakeNotifier),
|
||||
Provider.value(value: ReminderScheduler(fakeNotifier, settings, sessionRepository)),
|
||||
],
|
||||
child: const ZenXerciseApp(),
|
||||
),
|
||||
|
||||
@@ -12,6 +12,7 @@ void main() {
|
||||
late PlanRepository plans;
|
||||
late SessionRepository sessions;
|
||||
late FakePlayerEffects effects;
|
||||
late FakeNotifier notifier;
|
||||
|
||||
setUp(() {
|
||||
final db = testDatabase();
|
||||
@@ -19,13 +20,21 @@ void main() {
|
||||
plans = PlanRepository(db);
|
||||
sessions = SessionRepository(db);
|
||||
effects = FakePlayerEffects();
|
||||
notifier = FakeNotifier();
|
||||
});
|
||||
|
||||
Future<void> startPlan(WidgetTester tester, Mode mode, List<PlanItem> items) async {
|
||||
final plan = await plans.create(mode, 'Routine');
|
||||
await plans.save(plan.copyWith(items: items));
|
||||
usePhoneScreen(tester);
|
||||
await pumpApp(tester, plans: plans, sessions: sessions, effects: effects, prefs: {'mode': mode.name});
|
||||
await pumpApp(
|
||||
tester,
|
||||
plans: plans,
|
||||
sessions: sessions,
|
||||
effects: effects,
|
||||
notifier: notifier,
|
||||
prefs: {'mode': mode.name},
|
||||
);
|
||||
await tester.tap(find.byTooltip('Start'));
|
||||
await tester.pump();
|
||||
await tester.pump();
|
||||
@@ -66,6 +75,11 @@ void main() {
|
||||
await settle(tester);
|
||||
|
||||
expect(find.text('Session complete'), findsOneWidget);
|
||||
await tester.runAsync(() => Future<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.screenOn, isFalse);
|
||||
expect((await sessionsOf(tester, Mode.stretching)).single.complete, isTrue);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user