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
+1958
-36
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.
|
days than Freezes held resets the Streak to 0.
|
||||||
|
|
||||||
**Reminder**:
|
**Reminder**:
|
||||||
A local notification sent only if no complete Session exists for the current Streak Day. Two by
|
A local notification sent only if no complete Session exists for that day. Two by default (19:00 and
|
||||||
default (19:00 and 22:30); each can be switched on/off and moved in Settings. No more than two.
|
22:30); each can be switched on/off and moved in Settings. No more than two. Android may deliver one up
|
||||||
|
to about an hour late (see docs/adr/0006).
|
||||||
@@ -11,6 +11,8 @@ android {
|
|||||||
ndkVersion = "27.0.12077973"
|
ndkVersion = "27.0.12077973"
|
||||||
|
|
||||||
compileOptions {
|
compileOptions {
|
||||||
|
// Desugaring: flutter_local_notifications needs java.time on older Android versions.
|
||||||
|
isCoreLibraryDesugaringEnabled = true
|
||||||
sourceCompatibility = JavaVersion.VERSION_11
|
sourceCompatibility = JavaVersion.VERSION_11
|
||||||
targetCompatibility = JavaVersion.VERSION_11
|
targetCompatibility = JavaVersion.VERSION_11
|
||||||
}
|
}
|
||||||
@@ -42,3 +44,7 @@ android {
|
|||||||
flutter {
|
flutter {
|
||||||
source = "../.."
|
source = "../.."
|
||||||
}
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.4")
|
||||||
|
}
|
||||||
@@ -1,4 +1,7 @@
|
|||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<!-- Reminders: shown as notifications and rescheduled after a reboot. -->
|
||||||
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
|
||||||
|
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED"/>
|
||||||
<application
|
<application
|
||||||
android:label="ZenXercise"
|
android:label="ZenXercise"
|
||||||
android:name="${applicationName}"
|
android:name="${applicationName}"
|
||||||
@@ -25,6 +28,15 @@
|
|||||||
<category android:name="android.intent.category.LAUNCHER"/>
|
<category android:name="android.intent.category.LAUNCHER"/>
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
</activity>
|
</activity>
|
||||||
|
<receiver android:exported="false" android:name="com.dexterous.flutterlocalnotifications.ScheduledNotificationReceiver" />
|
||||||
|
<receiver android:exported="false" android:name="com.dexterous.flutterlocalnotifications.ScheduledNotificationBootReceiver">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.BOOT_COMPLETED"/>
|
||||||
|
<action android:name="android.intent.action.MY_PACKAGE_REPLACED"/>
|
||||||
|
<action android:name="android.intent.action.QUICKBOOT_POWERON" />
|
||||||
|
<action android:name="com.htc.intent.action.QUICKBOOT_POWERON"/>
|
||||||
|
</intent-filter>
|
||||||
|
</receiver>
|
||||||
<!-- Don't delete the meta-data below.
|
<!-- Don't delete the meta-data below.
|
||||||
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
|
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
|
||||||
<meta-data
|
<meta-data
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
# ADR 0006 — The Streak is derived; Reminders are rescheduled, not repeating
|
||||||
|
|
||||||
|
Status: Accepted (2026-10-29)
|
||||||
|
|
||||||
|
## Streak
|
||||||
|
|
||||||
|
The Streak, its Freezes and the calendar are computed from scratch from the end times of all complete
|
||||||
|
Sessions (both Modes) every time they are shown (`Streak.compute`). Nothing about the Streak is stored:
|
||||||
|
a Freeze is earned, held and spent deterministically by walking the days from the first Streak Day to
|
||||||
|
today. The Grace Hour is applied while assigning Sessions to Streak Days, in time order, so the rule
|
||||||
|
"counts for yesterday unless yesterday is already done" is stable.
|
||||||
|
|
||||||
|
A few years of daily Sessions is a few thousand rows, which is cheap to walk.
|
||||||
|
|
||||||
|
## Reminders
|
||||||
|
|
||||||
|
Android cannot skip a repeating notification on days that are already done, so instead of repeating
|
||||||
|
alarms the app schedules one-off notifications for the next 14 days and replaces them whenever
|
||||||
|
something changes: on app start, after every saved Session and after a Reminder setting changes. A day
|
||||||
|
that is done gets none. If the app is not opened for two weeks, Reminders stop; that is acceptable for
|
||||||
|
a gentle nudge.
|
||||||
|
|
||||||
|
They use inexact scheduling (`inexactAllowWhileIdle`), which needs no exact-alarm permission but lets
|
||||||
|
Android deliver up to about an hour late. Permission to post notifications is asked for after the
|
||||||
|
first complete Session, or from Settings.
|
||||||
@@ -14,4 +14,5 @@ Things deliberately left out of v1.
|
|||||||
- Translations (v1 is English only)
|
- Translations (v1 is English only)
|
||||||
- Light/dark setting per Mode (v1 has a fixed look per Mode)
|
- Light/dark setting per Mode (v1 has a fixed look per Mode)
|
||||||
- Full-screen Streak celebrations and milestones (7/30/100/365 days)
|
- Full-screen Streak celebrations and milestones (7/30/100/365 days)
|
||||||
|
- Exact-time Reminders (needs Android's exact-alarm permission)
|
||||||
- Mixed Plans that combine Stretching and Workout (e.g. warm-up, workout, cool-down)
|
- Mixed Plans that combine Stretching and Workout (e.g. warm-up, workout, cool-down)
|
||||||
+10
-1
@@ -10,6 +10,7 @@ import 'src/history/session_repository.dart';
|
|||||||
import 'src/plans/plan_repository.dart';
|
import 'src/plans/plan_repository.dart';
|
||||||
import 'src/player/player_effects.dart';
|
import 'src/player/player_effects.dart';
|
||||||
import 'src/settings/settings_controller.dart';
|
import 'src/settings/settings_controller.dart';
|
||||||
|
import 'src/streak/reminder_scheduler.dart';
|
||||||
|
|
||||||
void main() async {
|
void main() async {
|
||||||
WidgetsFlutterBinding.ensureInitialized();
|
WidgetsFlutterBinding.ensureInitialized();
|
||||||
@@ -19,6 +20,12 @@ void main() async {
|
|||||||
final library = await ExerciseLibrary.load(rootBundle);
|
final library = await ExerciseLibrary.load(rootBundle);
|
||||||
final settings = SettingsController(SettingsService());
|
final settings = SettingsController(SettingsService());
|
||||||
await settings.loadSettings();
|
await settings.loadSettings();
|
||||||
|
final sessions = SessionRepository(db);
|
||||||
|
final notifier = LocalNotifier();
|
||||||
|
await notifier.init();
|
||||||
|
final reminders = ReminderScheduler(notifier, settings, sessions);
|
||||||
|
// Today may have been finished, or a day may have passed, since the last run.
|
||||||
|
reminders.sync();
|
||||||
|
|
||||||
runApp(
|
runApp(
|
||||||
MultiProvider(
|
MultiProvider(
|
||||||
@@ -26,7 +33,9 @@ void main() async {
|
|||||||
ChangeNotifierProvider.value(value: settings),
|
ChangeNotifierProvider.value(value: settings),
|
||||||
Provider.value(value: library),
|
Provider.value(value: library),
|
||||||
Provider.value(value: PlanRepository(db)),
|
Provider.value(value: PlanRepository(db)),
|
||||||
Provider.value(value: SessionRepository(db)),
|
Provider.value(value: sessions),
|
||||||
|
Provider<Notifier>.value(value: notifier),
|
||||||
|
Provider.value(value: reminders),
|
||||||
Provider<PlayerEffects>(create: (_) => DevicePlayerEffects()),
|
Provider<PlayerEffects>(create: (_) => DevicePlayerEffects()),
|
||||||
],
|
],
|
||||||
child: const ZenXerciseApp(),
|
child: const ZenXerciseApp(),
|
||||||
|
|||||||
+6
-1
@@ -13,6 +13,7 @@ import 'plans/views/plan_editor_view.dart';
|
|||||||
import 'player/views/player_view.dart';
|
import 'player/views/player_view.dart';
|
||||||
import 'player/views/summary_view.dart';
|
import 'player/views/summary_view.dart';
|
||||||
import 'settings/settings_controller.dart';
|
import 'settings/settings_controller.dart';
|
||||||
|
import 'settings/settings_view.dart';
|
||||||
|
|
||||||
class ZenXerciseApp extends StatelessWidget {
|
class ZenXerciseApp extends StatelessWidget {
|
||||||
const ZenXerciseApp({super.key});
|
const ZenXerciseApp({super.key});
|
||||||
@@ -47,7 +48,11 @@ class ZenXerciseApp extends StatelessWidget {
|
|||||||
PlanEditorView.routeName => PlanEditorView(planId: settings.arguments! as String),
|
PlanEditorView.routeName => PlanEditorView(planId: settings.arguments! as String),
|
||||||
ExercisePickerView.routeName => ExercisePickerView(planId: settings.arguments! as String),
|
ExercisePickerView.routeName => ExercisePickerView(planId: settings.arguments! as String),
|
||||||
PlayerView.routeName => PlayerView(planId: settings.arguments! as String),
|
PlayerView.routeName => PlayerView(planId: settings.arguments! as String),
|
||||||
SummaryView.routeName => SummaryView(sessionId: settings.arguments! as String),
|
SummaryView.routeName => switch (settings.arguments) {
|
||||||
|
final SummaryArguments a => SummaryView(sessionId: a.sessionId, justFinished: a.justFinished),
|
||||||
|
final id => SummaryView(sessionId: id! as String),
|
||||||
|
},
|
||||||
|
SettingsView.routeName => const SettingsView(),
|
||||||
_ => const HomeView(),
|
_ => const HomeView(),
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -19,6 +19,12 @@ class ModeSwitch extends StatelessWidget {
|
|||||||
final s = ZenStyle.of(context);
|
final s = ZenStyle.of(context);
|
||||||
final l10n = AppLocalizations.of(context);
|
final l10n = AppLocalizations.of(context);
|
||||||
final radius = BorderRadius.circular(s.controlRadius.clamp(0, height / 2));
|
final radius = BorderRadius.circular(s.controlRadius.clamp(0, height / 2));
|
||||||
|
return LayoutBuilder(
|
||||||
|
builder: (context, constraints) => _build(context, l10n, s, radius, constraints.maxWidth >= 260),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _build(BuildContext context, AppLocalizations l10n, ZenStyle s, BorderRadius radius, bool icons) {
|
||||||
return Semantics(
|
return Semantics(
|
||||||
label: l10n.modeSwitchLabel,
|
label: l10n.modeSwitchLabel,
|
||||||
child: Container(
|
child: Container(
|
||||||
@@ -45,13 +51,13 @@ class ModeSwitch extends StatelessWidget {
|
|||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
_Segment(
|
_Segment(
|
||||||
icon: Icons.self_improvement_rounded,
|
icon: icons ? Icons.self_improvement_rounded : null,
|
||||||
label: l10n.modeStretching,
|
label: l10n.modeStretching,
|
||||||
selected: mode == Mode.stretching,
|
selected: mode == Mode.stretching,
|
||||||
onTap: () => onChanged(Mode.stretching),
|
onTap: () => onChanged(Mode.stretching),
|
||||||
),
|
),
|
||||||
_Segment(
|
_Segment(
|
||||||
icon: Icons.fitness_center_rounded,
|
icon: icons ? Icons.fitness_center_rounded : null,
|
||||||
label: l10n.modeWorkout,
|
label: l10n.modeWorkout,
|
||||||
selected: mode == Mode.workout,
|
selected: mode == Mode.workout,
|
||||||
onTap: () => onChanged(Mode.workout),
|
onTap: () => onChanged(Mode.workout),
|
||||||
@@ -68,7 +74,7 @@ class ModeSwitch extends StatelessWidget {
|
|||||||
class _Segment extends StatelessWidget {
|
class _Segment extends StatelessWidget {
|
||||||
const _Segment({required this.icon, required this.label, required this.selected, required this.onTap});
|
const _Segment({required this.icon, required this.label, required this.selected, required this.onTap});
|
||||||
|
|
||||||
final IconData icon;
|
final IconData? icon;
|
||||||
final String label;
|
final String label;
|
||||||
final bool selected;
|
final bool selected;
|
||||||
final VoidCallback onTap;
|
final VoidCallback onTap;
|
||||||
@@ -87,8 +93,7 @@ class _Segment extends StatelessWidget {
|
|||||||
child: Row(
|
child: Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
Icon(icon, size: 18, color: color),
|
if (icon != null) ...[Icon(icon, size: 18, color: color), const SizedBox(width: 6)],
|
||||||
const SizedBox(width: 6),
|
|
||||||
Flexible(
|
Flexible(
|
||||||
child: Text(
|
child: Text(
|
||||||
label,
|
label,
|
||||||
|
|||||||
@@ -71,6 +71,18 @@ class SessionRepository {
|
|||||||
return query.watch().map((rows) => rows.map(_toSession).toList());
|
return query.watch().map((rows) => rows.map(_toSession).toList());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// When every complete Session of either Mode ended: all the Streak needs.
|
||||||
|
Stream<List<DateTime>> watchCompleteEndTimes() => _completeEndTimes().watch();
|
||||||
|
|
||||||
|
Future<List<DateTime>> completeEndTimes() => _completeEndTimes().get();
|
||||||
|
|
||||||
|
Selectable<DateTime> _completeEndTimes() {
|
||||||
|
final query = _db.selectOnly(_db.sessions)
|
||||||
|
..addColumns([_db.sessions.endedAt])
|
||||||
|
..where(_db.sessions.complete.equals(true));
|
||||||
|
return query.map((row) => row.read(_db.sessions.endedAt)!);
|
||||||
|
}
|
||||||
|
|
||||||
static Session _toSession(SessionRow row) => Session(
|
static Session _toSession(SessionRow row) => Session(
|
||||||
id: row.id,
|
id: row.id,
|
||||||
mode: Mode.fromName(row.mode),
|
mode: Mode.fromName(row.mode),
|
||||||
|
|||||||
@@ -1,15 +1,31 @@
|
|||||||
|
import 'package:clock/clock.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:intl/intl.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
import '../../design/page_header.dart';
|
import '../../design/page_header.dart';
|
||||||
|
import '../../design/zen_nav_bar.dart';
|
||||||
|
import '../../design/zen_style.dart';
|
||||||
import '../../localization/app_localizations.dart';
|
import '../../localization/app_localizations.dart';
|
||||||
|
import '../../player/views/summary_view.dart';
|
||||||
|
import '../../settings/settings_controller.dart';
|
||||||
|
import '../../streak/day.dart';
|
||||||
|
import '../session_repository.dart';
|
||||||
|
|
||||||
/// The current Mode's finished Sessions.
|
/// The current Mode's finished Sessions by day, the latest first; each opens
|
||||||
|
/// its summary again.
|
||||||
class HistoryView extends StatelessWidget {
|
class HistoryView extends StatelessWidget {
|
||||||
const HistoryView({super.key});
|
const HistoryView({super.key});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final l10n = AppLocalizations.of(context);
|
final l10n = AppLocalizations.of(context);
|
||||||
|
final mode = context.watch<SettingsController>().mode;
|
||||||
|
return StreamBuilder(
|
||||||
|
stream: context.read<SessionRepository>().watchSessions(mode),
|
||||||
|
builder: (context, snapshot) {
|
||||||
|
final sessions = snapshot.data ?? const [];
|
||||||
|
if (sessions.isEmpty) {
|
||||||
return ListView(
|
return ListView(
|
||||||
children: [
|
children: [
|
||||||
PageHeader(title: l10n.historyTitle),
|
PageHeader(title: l10n.historyTitle),
|
||||||
@@ -18,4 +34,99 @@ class HistoryView extends StatelessWidget {
|
|||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
final today = Day.of(clock.now());
|
||||||
|
final rows = <Widget>[PageHeader(title: l10n.historyTitle)];
|
||||||
|
Day? shown;
|
||||||
|
for (final session in sessions) {
|
||||||
|
final day = Day.of(session.endedAt);
|
||||||
|
if (day != shown) {
|
||||||
|
shown = day;
|
||||||
|
rows.add(_DayHeader(label: _dayLabel(l10n, day, today)));
|
||||||
|
}
|
||||||
|
rows.add(
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 0, 16, 10),
|
||||||
|
child: _SessionTile(session: session),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return ListView(
|
||||||
|
padding: EdgeInsets.only(bottom: ZenNavBar.height + 32 + MediaQuery.paddingOf(context).bottom),
|
||||||
|
children: rows,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
static String _dayLabel(AppLocalizations l10n, Day day, Day today) {
|
||||||
|
if (day == today) return l10n.historyToday;
|
||||||
|
if (day == today.add(-1)) return l10n.historyYesterday;
|
||||||
|
final date = day.at(0, 0);
|
||||||
|
return day.year == today.year ? DateFormat.MMMEd().format(date) : DateFormat.yMMMEd().format(date);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _DayHeader extends StatelessWidget {
|
||||||
|
const _DayHeader({required this.label});
|
||||||
|
|
||||||
|
final String label;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) => Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(20, 8, 20, 8),
|
||||||
|
child: Text(label, style: Theme.of(context).textTheme.titleSmall?.copyWith(color: ZenStyle.of(context).inkMuted)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
class _SessionTile extends StatelessWidget {
|
||||||
|
const _SessionTile({required this.session});
|
||||||
|
|
||||||
|
final Session session;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final l10n = AppLocalizations.of(context);
|
||||||
|
final s = ZenStyle.of(context);
|
||||||
|
final text = Theme.of(context).textTheme;
|
||||||
|
final line = l10n.historySessionLine(
|
||||||
|
DateFormat.Hm().format(session.endedAt),
|
||||||
|
l10n.durationMinutes((session.duration.inSeconds / 60).round()),
|
||||||
|
);
|
||||||
|
return Card(
|
||||||
|
child: InkWell(
|
||||||
|
onTap: () => Navigator.of(context).pushNamed(SummaryView.routeName, arguments: session.id),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 14, 12, 14),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
session.complete ? Icons.check_circle_rounded : Icons.flag_rounded,
|
||||||
|
color: session.complete ? s.accent : s.inkMuted,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 14),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(session.planName, style: text.titleMedium, maxLines: 1, overflow: TextOverflow.ellipsis),
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
Text(line, style: text.bodySmall?.copyWith(color: s.inkMuted)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (!session.complete)
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: s.surfaceSoft,
|
||||||
|
borderRadius: BorderRadius.circular(s.controlRadius.clamp(0, 12)),
|
||||||
|
),
|
||||||
|
child: Text(l10n.historyEndedEarly, style: text.labelSmall?.copyWith(color: s.inkMuted)),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -9,6 +9,8 @@ import '../history/views/history_view.dart';
|
|||||||
import '../localization/app_localizations.dart';
|
import '../localization/app_localizations.dart';
|
||||||
import '../plans/views/plans_view.dart';
|
import '../plans/views/plans_view.dart';
|
||||||
import '../settings/settings_controller.dart';
|
import '../settings/settings_controller.dart';
|
||||||
|
import '../settings/settings_view.dart';
|
||||||
|
import '../streak/views/streak_views.dart';
|
||||||
|
|
||||||
/// The Mode switch on top, the current Mode's Plans, Library and History tabs
|
/// The Mode switch on top, the current Mode's Plans, Library and History tabs
|
||||||
/// below, and the floating navigation bar.
|
/// below, and the floating navigation bar.
|
||||||
@@ -47,12 +49,22 @@ class _HomeViewState extends State<HomeView> {
|
|||||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 0),
|
padding: const EdgeInsets.fromLTRB(16, 12, 16, 0),
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
Flexible(
|
Expanded(
|
||||||
|
child: Align(
|
||||||
|
alignment: Alignment.centerLeft,
|
||||||
child: ConstrainedBox(
|
child: ConstrainedBox(
|
||||||
constraints: const BoxConstraints(maxWidth: 300),
|
constraints: const BoxConstraints(maxWidth: 300),
|
||||||
child: ModeSwitch(mode: settings.mode, onChanged: settings.updateMode),
|
child: ModeSwitch(mode: settings.mode, onChanged: settings.updateMode),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
const StreakFlame(),
|
||||||
|
IconButton(
|
||||||
|
tooltip: l10n.settingsTitle,
|
||||||
|
onPressed: () => Navigator.of(context).pushNamed(SettingsView.routeName),
|
||||||
|
icon: const Icon(Icons.settings_rounded),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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
|
/// be consistent with the languages listed in the AppLocalizations.supportedLocales
|
||||||
/// property.
|
/// property.
|
||||||
abstract class AppLocalizations {
|
abstract class AppLocalizations {
|
||||||
AppLocalizations(String locale) : localeName = intl.Intl.canonicalizedLocale(locale.toString());
|
AppLocalizations(String locale)
|
||||||
|
: localeName = intl.Intl.canonicalizedLocale(locale.toString());
|
||||||
|
|
||||||
final String localeName;
|
final String localeName;
|
||||||
|
|
||||||
@@ -69,7 +70,8 @@ abstract class AppLocalizations {
|
|||||||
return Localizations.of<AppLocalizations>(context, AppLocalizations)!;
|
return Localizations.of<AppLocalizations>(context, AppLocalizations)!;
|
||||||
}
|
}
|
||||||
|
|
||||||
static const LocalizationsDelegate<AppLocalizations> delegate = _AppLocalizationsDelegate();
|
static const LocalizationsDelegate<AppLocalizations> delegate =
|
||||||
|
_AppLocalizationsDelegate();
|
||||||
|
|
||||||
/// A list of this localizations delegate along with the default localizations
|
/// A list of this localizations delegate along with the default localizations
|
||||||
/// delegates.
|
/// delegates.
|
||||||
@@ -81,7 +83,8 @@ abstract class AppLocalizations {
|
|||||||
/// Additional delegates can be added by appending to this list in
|
/// Additional delegates can be added by appending to this list in
|
||||||
/// MaterialApp. This list does not have to be used at all if a custom list
|
/// MaterialApp. This list does not have to be used at all if a custom list
|
||||||
/// of delegates is preferred or required.
|
/// of delegates is preferred or required.
|
||||||
static const List<LocalizationsDelegate<dynamic>> localizationsDelegates = <LocalizationsDelegate<dynamic>>[
|
static const List<LocalizationsDelegate<dynamic>> localizationsDelegates =
|
||||||
|
<LocalizationsDelegate<dynamic>>[
|
||||||
delegate,
|
delegate,
|
||||||
GlobalMaterialLocalizations.delegate,
|
GlobalMaterialLocalizations.delegate,
|
||||||
GlobalCupertinoLocalizations.delegate,
|
GlobalCupertinoLocalizations.delegate,
|
||||||
@@ -840,9 +843,220 @@ abstract class AppLocalizations {
|
|||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
/// **'Add a weight'**
|
/// **'Add a weight'**
|
||||||
String get logWeight;
|
String get logWeight;
|
||||||
|
|
||||||
|
/// No description provided for @streakTooltip.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Streak'**
|
||||||
|
String get streakTooltip;
|
||||||
|
|
||||||
|
/// No description provided for @streakTitle.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'{count, plural, =0{No streak yet} =1{1-day streak} other{{count}-day streak}}'**
|
||||||
|
String streakTitle(int count);
|
||||||
|
|
||||||
|
/// No description provided for @streakLongest.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'{count, plural, =0{Longest: none yet} =1{Longest: 1 day} other{Longest: {count} days}}'**
|
||||||
|
String streakLongest(int count);
|
||||||
|
|
||||||
|
/// No description provided for @streakTodayDone.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Today is done.'**
|
||||||
|
String get streakTodayDone;
|
||||||
|
|
||||||
|
/// No description provided for @streakTodayOpen.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Finish a session today to keep it going.'**
|
||||||
|
String get streakTodayOpen;
|
||||||
|
|
||||||
|
/// No description provided for @streakStart.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Finish any session to start one.'**
|
||||||
|
String get streakStart;
|
||||||
|
|
||||||
|
/// No description provided for @streakFreezes.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Freezes: {count} of {max}'**
|
||||||
|
String streakFreezes(int count, int max);
|
||||||
|
|
||||||
|
/// No description provided for @streakFreezeHint.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'You earn one every 7 days. A freeze covers a missed day by itself.'**
|
||||||
|
String get streakFreezeHint;
|
||||||
|
|
||||||
|
/// No description provided for @streakGraceHint.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Sessions finished before 1:00 still count for the day before.'**
|
||||||
|
String get streakGraceHint;
|
||||||
|
|
||||||
|
/// No description provided for @previousMonth.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Previous month'**
|
||||||
|
String get previousMonth;
|
||||||
|
|
||||||
|
/// No description provided for @nextMonth.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Next month'**
|
||||||
|
String get nextMonth;
|
||||||
|
|
||||||
|
/// No description provided for @summaryStreak.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Day {count} of your streak'**
|
||||||
|
String summaryStreak(int count);
|
||||||
|
|
||||||
|
/// No description provided for @settingsTitle.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Settings'**
|
||||||
|
String get settingsTitle;
|
||||||
|
|
||||||
|
/// No description provided for @settingsReminders.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Reminders'**
|
||||||
|
String get settingsReminders;
|
||||||
|
|
||||||
|
/// No description provided for @settingsRemindersHint.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Only sent on days you haven\'t finished a session yet.'**
|
||||||
|
String get settingsRemindersHint;
|
||||||
|
|
||||||
|
/// No description provided for @reminderFirst.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'First reminder'**
|
||||||
|
String get reminderFirst;
|
||||||
|
|
||||||
|
/// No description provided for @reminderLate.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Late reminder'**
|
||||||
|
String get reminderLate;
|
||||||
|
|
||||||
|
/// No description provided for @settingsSound.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Sound'**
|
||||||
|
String get settingsSound;
|
||||||
|
|
||||||
|
/// No description provided for @settingsChime.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Chime in the player'**
|
||||||
|
String get settingsChime;
|
||||||
|
|
||||||
|
/// No description provided for @settingsChimeHint.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'A soft sound when a timer ends or something new starts.'**
|
||||||
|
String get settingsChimeHint;
|
||||||
|
|
||||||
|
/// No description provided for @settingsAbout.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'About'**
|
||||||
|
String get settingsAbout;
|
||||||
|
|
||||||
|
/// No description provided for @settingsCredits.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Exercises and photos come from free-exercise-db, released into the public domain.'**
|
||||||
|
String get settingsCredits;
|
||||||
|
|
||||||
|
/// No description provided for @reminderTitleFirst.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Time for a few minutes?'**
|
||||||
|
String get reminderTitleFirst;
|
||||||
|
|
||||||
|
/// No description provided for @reminderTitleLate.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Still time today'**
|
||||||
|
String get reminderTitleLate;
|
||||||
|
|
||||||
|
/// No description provided for @reminderBodyStreak.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Keep your {count}-day streak going 🔥'**
|
||||||
|
String reminderBodyStreak(int count);
|
||||||
|
|
||||||
|
/// No description provided for @reminderBodyFirst.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'A short session keeps your streak going.'**
|
||||||
|
String get reminderBodyFirst;
|
||||||
|
|
||||||
|
/// No description provided for @reminderBodyLate.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'A quick stretch before bed still counts.'**
|
||||||
|
String get reminderBodyLate;
|
||||||
|
|
||||||
|
/// No description provided for @notificationChannel.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Reminders'**
|
||||||
|
String get notificationChannel;
|
||||||
|
|
||||||
|
/// No description provided for @notificationChannelDescription.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'A gentle nudge on days you haven\'t finished a session yet.'**
|
||||||
|
String get notificationChannelDescription;
|
||||||
|
|
||||||
|
/// No description provided for @historyToday.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Today'**
|
||||||
|
String get historyToday;
|
||||||
|
|
||||||
|
/// No description provided for @historyYesterday.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Yesterday'**
|
||||||
|
String get historyYesterday;
|
||||||
|
|
||||||
|
/// No description provided for @historyEndedEarly.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Ended early'**
|
||||||
|
String get historyEndedEarly;
|
||||||
|
|
||||||
|
/// No description provided for @historySessionLine.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'{time} · {duration}'**
|
||||||
|
String historySessionLine(String time, String duration);
|
||||||
|
|
||||||
|
/// No description provided for @remindersNeedPermission.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Reminders need permission to show notifications.'**
|
||||||
|
String get remindersNeedPermission;
|
||||||
|
|
||||||
|
/// No description provided for @allowNotifications.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Allow'**
|
||||||
|
String get allowNotifications;
|
||||||
}
|
}
|
||||||
|
|
||||||
class _AppLocalizationsDelegate extends LocalizationsDelegate<AppLocalizations> {
|
class _AppLocalizationsDelegate
|
||||||
|
extends LocalizationsDelegate<AppLocalizations> {
|
||||||
const _AppLocalizationsDelegate();
|
const _AppLocalizationsDelegate();
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -851,7 +1065,8 @@ class _AppLocalizationsDelegate extends LocalizationsDelegate<AppLocalizations>
|
|||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
bool isSupported(Locale locale) => <String>['en'].contains(locale.languageCode);
|
bool isSupported(Locale locale) =>
|
||||||
|
<String>['en'].contains(locale.languageCode);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
bool shouldReload(_AppLocalizationsDelegate old) => false;
|
bool shouldReload(_AppLocalizationsDelegate old) => false;
|
||||||
|
|||||||
@@ -38,8 +38,10 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String plansEmptyBody(String mode) {
|
String plansEmptyBody(String mode) {
|
||||||
String _temp0 = intl.Intl.selectLogic(mode, {
|
String _temp0 = intl.Intl.selectLogic(mode, {
|
||||||
'stretching': 'Put together a stretch routine from the library and it will wait for you here.',
|
'stretching':
|
||||||
'other': 'Put together a workout from the library and it will wait for you here.',
|
'Put together a stretch routine from the library and it will wait for you here.',
|
||||||
|
'other':
|
||||||
|
'Put together a workout from the library and it will wait for you here.',
|
||||||
});
|
});
|
||||||
return '$_temp0';
|
return '$_temp0';
|
||||||
}
|
}
|
||||||
@@ -49,9 +51,22 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String librarySubtitle(String mode, int count) {
|
String librarySubtitle(String mode, int count) {
|
||||||
String _temp0 = intl.Intl.pluralLogic(count, locale: localeName, other: '$count stretches', one: '1 stretch');
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
String _temp1 = intl.Intl.pluralLogic(count, locale: localeName, other: '$count exercises', one: '1 exercise');
|
count,
|
||||||
String _temp2 = intl.Intl.selectLogic(mode, {'stretching': '$_temp0', 'other': '$_temp1'});
|
locale: localeName,
|
||||||
|
other: '$count stretches',
|
||||||
|
one: '1 stretch',
|
||||||
|
);
|
||||||
|
String _temp1 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: '$count exercises',
|
||||||
|
one: '1 exercise',
|
||||||
|
);
|
||||||
|
String _temp2 = intl.Intl.selectLogic(mode, {
|
||||||
|
'stretching': '$_temp0',
|
||||||
|
'other': '$_temp1',
|
||||||
|
});
|
||||||
return '$_temp2';
|
return '$_temp2';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -62,7 +77,8 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||||||
String get historyEmptyTitle => 'Nothing logged yet';
|
String get historyEmptyTitle => 'Nothing logged yet';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get historyEmptyBody => 'Finished sessions show up here with their date and duration.';
|
String get historyEmptyBody =>
|
||||||
|
'Finished sessions show up here with their date and duration.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String librarySubtitleFiltered(String mode, int shown, int total) {
|
String librarySubtitleFiltered(String mode, int shown, int total) {
|
||||||
@@ -75,7 +91,10 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String searchHint(String mode) {
|
String searchHint(String mode) {
|
||||||
String _temp0 = intl.Intl.selectLogic(mode, {'stretching': 'Search stretches', 'other': 'Search exercises'});
|
String _temp0 = intl.Intl.selectLogic(mode, {
|
||||||
|
'stretching': 'Search stretches',
|
||||||
|
'other': 'Search exercises',
|
||||||
|
});
|
||||||
return '$_temp0';
|
return '$_temp0';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -110,7 +129,8 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||||||
String get noMatchesTitle => 'No matches';
|
String get noMatchesTitle => 'No matches';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get noMatchesBody => 'Try fewer words, or clear the filters to see everything again.';
|
String get noMatchesBody =>
|
||||||
|
'Try fewer words, or clear the filters to see everything again.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get detailMainMuscles => 'Main muscles';
|
String get detailMainMuscles => 'Main muscles';
|
||||||
@@ -147,7 +167,10 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String planNameHint(String mode) {
|
String planNameHint(String mode) {
|
||||||
String _temp0 = intl.Intl.selectLogic(mode, {'stretching': 'e.g. Morning stretch', 'other': 'e.g. Push day'});
|
String _temp0 = intl.Intl.selectLogic(mode, {
|
||||||
|
'stretching': 'e.g. Morning stretch',
|
||||||
|
'other': 'e.g. Push day',
|
||||||
|
});
|
||||||
return '$_temp0';
|
return '$_temp0';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -195,7 +218,8 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get deletePlanBody => 'The plan is removed. Sessions you already finished stay in your history.';
|
String get deletePlanBody =>
|
||||||
|
'The plan is removed. Sessions you already finished stay in your history.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String planExerciseCount(int count) {
|
String planExerciseCount(int count) {
|
||||||
@@ -245,7 +269,12 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String repsCount(int count) {
|
String repsCount(int count) {
|
||||||
String _temp0 = intl.Intl.pluralLogic(count, locale: localeName, other: '$count reps', one: '1 rep');
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: '$count reps',
|
||||||
|
one: '1 rep',
|
||||||
|
);
|
||||||
return '$_temp0';
|
return '$_temp0';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -269,13 +298,23 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String roundsCount(int count) {
|
String roundsCount(int count) {
|
||||||
String _temp0 = intl.Intl.pluralLogic(count, locale: localeName, other: '$count rounds', one: '1 round');
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: '$count rounds',
|
||||||
|
one: '1 round',
|
||||||
|
);
|
||||||
return '$_temp0';
|
return '$_temp0';
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String setsCount(int count) {
|
String setsCount(int count) {
|
||||||
String _temp0 = intl.Intl.pluralLogic(count, locale: localeName, other: '$count sets', one: '1 set');
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: '$count sets',
|
||||||
|
one: '1 set',
|
||||||
|
);
|
||||||
return '$_temp0';
|
return '$_temp0';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -316,7 +355,8 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||||||
String get restBetweenLabel => 'Rest between exercises';
|
String get restBetweenLabel => 'Rest between exercises';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get restBetweenHint => 'When off, you go straight to the next exercise and rest after each round.';
|
String get restBetweenHint =>
|
||||||
|
'When off, you go straight to the next exercise and rest after each round.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get supersetWithNext => 'Superset with next';
|
String get supersetWithNext => 'Superset with next';
|
||||||
@@ -482,4 +522,142 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get logWeight => 'Add a weight';
|
String get logWeight => 'Add a weight';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get streakTooltip => 'Streak';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String streakTitle(int count) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: '$count-day streak',
|
||||||
|
one: '1-day streak',
|
||||||
|
zero: 'No streak yet',
|
||||||
|
);
|
||||||
|
return '$_temp0';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String streakLongest(int count) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: 'Longest: $count days',
|
||||||
|
one: 'Longest: 1 day',
|
||||||
|
zero: 'Longest: none yet',
|
||||||
|
);
|
||||||
|
return '$_temp0';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get streakTodayDone => 'Today is done.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get streakTodayOpen => 'Finish a session today to keep it going.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get streakStart => 'Finish any session to start one.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String streakFreezes(int count, int max) {
|
||||||
|
return 'Freezes: $count of $max';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get streakFreezeHint =>
|
||||||
|
'You earn one every 7 days. A freeze covers a missed day by itself.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get streakGraceHint =>
|
||||||
|
'Sessions finished before 1:00 still count for the day before.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get previousMonth => 'Previous month';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get nextMonth => 'Next month';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String summaryStreak(int count) {
|
||||||
|
return 'Day $count of your streak';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settingsTitle => 'Settings';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settingsReminders => 'Reminders';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settingsRemindersHint =>
|
||||||
|
'Only sent on days you haven\'t finished a session yet.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get reminderFirst => 'First reminder';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get reminderLate => 'Late reminder';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settingsSound => 'Sound';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settingsChime => 'Chime in the player';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settingsChimeHint =>
|
||||||
|
'A soft sound when a timer ends or something new starts.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settingsAbout => 'About';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settingsCredits =>
|
||||||
|
'Exercises and photos come from free-exercise-db, released into the public domain.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get reminderTitleFirst => 'Time for a few minutes?';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get reminderTitleLate => 'Still time today';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String reminderBodyStreak(int count) {
|
||||||
|
return 'Keep your $count-day streak going 🔥';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get reminderBodyFirst => 'A short session keeps your streak going.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get reminderBodyLate => 'A quick stretch before bed still counts.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notificationChannel => 'Reminders';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notificationChannelDescription =>
|
||||||
|
'A gentle nudge on days you haven\'t finished a session yet.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get historyToday => 'Today';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get historyYesterday => 'Yesterday';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get historyEndedEarly => 'Ended early';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String historySessionLine(String time, String duration) {
|
||||||
|
return '$time · $duration';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get remindersNeedPermission =>
|
||||||
|
'Reminders need permission to show notifications.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get allowNotifications => 'Allow';
|
||||||
}
|
}
|
||||||
@@ -16,6 +16,7 @@ import '../../plans/models/plan.dart';
|
|||||||
import '../../plans/plan_labels.dart';
|
import '../../plans/plan_labels.dart';
|
||||||
import '../../plans/plan_repository.dart';
|
import '../../plans/plan_repository.dart';
|
||||||
import '../../settings/settings_controller.dart';
|
import '../../settings/settings_controller.dart';
|
||||||
|
import '../../streak/reminder_scheduler.dart';
|
||||||
import '../player_effects.dart';
|
import '../player_effects.dart';
|
||||||
import '../segments.dart';
|
import '../segments.dart';
|
||||||
import '../session_runner.dart';
|
import '../session_runner.dart';
|
||||||
@@ -81,8 +82,11 @@ class _PlayerViewState extends State<PlayerView> {
|
|||||||
navigator.pop();
|
navigator.pop();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
final scheduler = context.read<ReminderScheduler>();
|
||||||
final session = await context.read<SessionRepository>().save(runner.result(clock.now()));
|
final session = await context.read<SessionRepository>().save(runner.result(clock.now()));
|
||||||
navigator.pushReplacementNamed(SummaryView.routeName, arguments: session.id);
|
navigator.pushReplacementNamed(SummaryView.routeName, arguments: SummaryArguments(session.id, justFinished: true));
|
||||||
|
// A finished day needs no more Reminders; the first finished Session is when to ask for them.
|
||||||
|
await scheduler.sync(askPermission: session.complete);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _confirmEnd() async {
|
Future<void> _confirmEnd() async {
|
||||||
|
|||||||
@@ -8,17 +8,23 @@ import '../../localization/app_localizations.dart';
|
|||||||
import '../../modes/mode.dart';
|
import '../../modes/mode.dart';
|
||||||
import '../../plans/models/plan.dart';
|
import '../../plans/models/plan.dart';
|
||||||
import '../../plans/plan_labels.dart';
|
import '../../plans/plan_labels.dart';
|
||||||
|
import '../../streak/streak.dart';
|
||||||
|
import '../../streak/views/streak_views.dart';
|
||||||
import '../session_runner.dart';
|
import '../session_runner.dart';
|
||||||
|
|
||||||
/// Shown when a Session ends: how long it took, what was done and, for
|
/// Shown when a Session ends: how long it took, what was done and, for
|
||||||
/// Workouts, every logged set.
|
/// Workouts, every logged set.
|
||||||
class SummaryView extends StatelessWidget {
|
class SummaryView extends StatelessWidget {
|
||||||
const SummaryView({super.key, required this.sessionId});
|
const SummaryView({super.key, required this.sessionId, this.justFinished = false});
|
||||||
|
|
||||||
static const routeName = '/summary';
|
static const routeName = '/summary';
|
||||||
|
|
||||||
final String sessionId;
|
final String sessionId;
|
||||||
|
|
||||||
|
/// Straight from the Player rather than opened again from History: only
|
||||||
|
/// then is the Session's Streak Day worth pointing out.
|
||||||
|
final bool justFinished;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final l10n = AppLocalizations.of(context);
|
final l10n = AppLocalizations.of(context);
|
||||||
@@ -50,6 +56,7 @@ class SummaryView extends StatelessWidget {
|
|||||||
Text(session.complete ? l10n.summaryComplete : l10n.summaryIncomplete, style: text.headlineLarge),
|
Text(session.complete ? l10n.summaryComplete : l10n.summaryIncomplete, style: text.headlineLarge),
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
Text(session.planName, style: text.titleMedium?.copyWith(color: s.inkMuted)),
|
Text(session.planName, style: text.titleMedium?.copyWith(color: s.inkMuted)),
|
||||||
|
if (justFinished && session.complete) _StreakLine(session: session),
|
||||||
const SizedBox(height: 28),
|
const SizedBox(height: 28),
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
@@ -100,6 +107,53 @@ class SummaryView extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// "Day 12 of your streak", when this Session was the first to complete its day.
|
||||||
|
class _StreakLine extends StatelessWidget {
|
||||||
|
const _StreakLine({required this.session});
|
||||||
|
|
||||||
|
final Session session;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final l10n = AppLocalizations.of(context);
|
||||||
|
final s = ZenStyle.of(context);
|
||||||
|
final sessions = context.read<SessionRepository>();
|
||||||
|
return StreakBuilder(
|
||||||
|
builder: (context, streak) => FutureBuilder(
|
||||||
|
future: sessions.completeEndTimes(),
|
||||||
|
builder: (context, snapshot) {
|
||||||
|
final times = snapshot.data;
|
||||||
|
if (times == null) return const SizedBox.shrink();
|
||||||
|
final before = streakDays(times.where((t) => t != session.endedAt));
|
||||||
|
final fresh = streakDays(times).length > before.length;
|
||||||
|
if (!fresh || streak.current == 0) return const SizedBox.shrink();
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 12),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(Icons.local_fire_department_rounded, color: s.accent),
|
||||||
|
const SizedBox(width: 6),
|
||||||
|
Text(
|
||||||
|
l10n.summaryStreak(streak.current),
|
||||||
|
style: Theme.of(context).textTheme.titleMedium?.copyWith(color: s.accent),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What the summary route takes.
|
||||||
|
class SummaryArguments {
|
||||||
|
const SummaryArguments(this.sessionId, {this.justFinished = false});
|
||||||
|
|
||||||
|
final String sessionId;
|
||||||
|
final bool justFinished;
|
||||||
|
}
|
||||||
|
|
||||||
class _Stat extends StatelessWidget {
|
class _Stat extends StatelessWidget {
|
||||||
const _Stat({required this.label, required this.value});
|
const _Stat({required this.label, required this.value});
|
||||||
|
|
||||||
|
|||||||
@@ -2,11 +2,15 @@ import 'package:flutter/foundation.dart';
|
|||||||
import 'package:shared_preferences/shared_preferences.dart';
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
import '../modes/mode.dart';
|
import '../modes/mode.dart';
|
||||||
|
import '../streak/reminders.dart';
|
||||||
|
|
||||||
/// Stores the user's app preferences on the device.
|
/// Stores the user's app preferences on the device.
|
||||||
class SettingsService {
|
class SettingsService {
|
||||||
static const _modeKey = 'mode';
|
static const _modeKey = 'mode';
|
||||||
static const _mutedKey = 'muted';
|
static const _mutedKey = 'muted';
|
||||||
|
static const _askedKey = 'notificationsAsked';
|
||||||
|
static String _reminderOnKey(int slot) => 'reminder${slot}On';
|
||||||
|
static String _reminderAtKey(int slot) => 'reminder${slot}At';
|
||||||
|
|
||||||
/// The last-used Mode; Stretching on first launch.
|
/// The last-used Mode; Stretching on first launch.
|
||||||
Future<Mode> mode() async => Mode.fromName((await SharedPreferences.getInstance()).getString(_modeKey));
|
Future<Mode> mode() async => Mode.fromName((await SharedPreferences.getInstance()).getString(_modeKey));
|
||||||
@@ -17,6 +21,29 @@ class SettingsService {
|
|||||||
Future<bool> muted() async => (await SharedPreferences.getInstance()).getBool(_mutedKey) ?? false;
|
Future<bool> muted() async => (await SharedPreferences.getInstance()).getBool(_mutedKey) ?? false;
|
||||||
|
|
||||||
Future<void> updateMuted(bool muted) async => (await SharedPreferences.getInstance()).setBool(_mutedKey, muted);
|
Future<void> updateMuted(bool muted) async => (await SharedPreferences.getInstance()).setBool(_mutedKey, muted);
|
||||||
|
|
||||||
|
/// The two Reminder slots, each falling back to its default.
|
||||||
|
Future<List<Reminder>> reminders() async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
return [
|
||||||
|
for (final (slot, fallback) in defaultReminders.indexed)
|
||||||
|
Reminder(
|
||||||
|
enabled: prefs.getBool(_reminderOnKey(slot)) ?? fallback.enabled,
|
||||||
|
minutes: prefs.getInt(_reminderAtKey(slot)) ?? fallback.minutes,
|
||||||
|
),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> updateReminder(int slot, Reminder reminder) async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
await prefs.setBool(_reminderOnKey(slot), reminder.enabled);
|
||||||
|
await prefs.setInt(_reminderAtKey(slot), reminder.minutes);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether the notification permission was asked for already.
|
||||||
|
Future<bool> notificationsAsked() async => (await SharedPreferences.getInstance()).getBool(_askedKey) ?? false;
|
||||||
|
|
||||||
|
Future<void> markNotificationsAsked() async => (await SharedPreferences.getInstance()).setBool(_askedKey, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
class SettingsController extends ChangeNotifier {
|
class SettingsController extends ChangeNotifier {
|
||||||
@@ -25,16 +52,26 @@ class SettingsController extends ChangeNotifier {
|
|||||||
final SettingsService _service;
|
final SettingsService _service;
|
||||||
Mode _mode = Mode.stretching;
|
Mode _mode = Mode.stretching;
|
||||||
bool _muted = false;
|
bool _muted = false;
|
||||||
|
List<Reminder> _reminders = defaultReminders;
|
||||||
|
|
||||||
|
SettingsService get service => _service;
|
||||||
Mode get mode => _mode;
|
Mode get mode => _mode;
|
||||||
bool get muted => _muted;
|
bool get muted => _muted;
|
||||||
|
List<Reminder> get reminders => _reminders;
|
||||||
|
|
||||||
Future<void> loadSettings() async {
|
Future<void> loadSettings() async {
|
||||||
_mode = await _service.mode();
|
_mode = await _service.mode();
|
||||||
_muted = await _service.muted();
|
_muted = await _service.muted();
|
||||||
|
_reminders = await _service.reminders();
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> updateReminder(int slot, Reminder reminder) async {
|
||||||
|
_reminders = [..._reminders]..[slot] = reminder;
|
||||||
|
notifyListeners();
|
||||||
|
await _service.updateReminder(slot, reminder);
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> updateMuted(bool muted) async {
|
Future<void> updateMuted(bool muted) async {
|
||||||
_muted = muted;
|
_muted = muted;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
|
|||||||
@@ -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"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.2.8"
|
version: "0.2.8"
|
||||||
|
equatable:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: equatable
|
||||||
|
sha256: ea36e500cbf0ec18361deabca755434f73e42fe53c5130021c7e2d6b5ebe1702
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "3.0.0"
|
||||||
fake_async:
|
fake_async:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -294,6 +302,38 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "5.0.0"
|
version: "5.0.0"
|
||||||
|
flutter_local_notifications:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: flutter_local_notifications
|
||||||
|
sha256: "2b50e938a275e1ad77352d6a25e25770f4130baa61eaf02de7a9a884680954ad"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "20.1.0"
|
||||||
|
flutter_local_notifications_linux:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: flutter_local_notifications_linux
|
||||||
|
sha256: dce0116868cedd2cdf768af0365fc37ff1cbef7c02c4f51d0587482e625868d0
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "7.0.0"
|
||||||
|
flutter_local_notifications_platform_interface:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: flutter_local_notifications_platform_interface
|
||||||
|
sha256: "23de31678a48c084169d7ae95866df9de5c9d2a44be3e5915a2ff067aeeba899"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "10.0.0"
|
||||||
|
flutter_local_notifications_windows:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: flutter_local_notifications_windows
|
||||||
|
sha256: e97a1a3016512437d9c0b12fae7d1491c3c7b9aa7f03a69b974308840656b02a
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.0.1"
|
||||||
flutter_localizations:
|
flutter_localizations:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description: flutter
|
description: flutter
|
||||||
@@ -304,6 +344,14 @@ packages:
|
|||||||
description: flutter
|
description: flutter
|
||||||
source: sdk
|
source: sdk
|
||||||
version: "0.0.0"
|
version: "0.0.0"
|
||||||
|
flutter_timezone:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: flutter_timezone
|
||||||
|
sha256: "7830bc64bef0b003468fc1f58fc3f3faf54817f8cd6e68fa818b306560410d68"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "5.1.1"
|
||||||
flutter_web_plugins:
|
flutter_web_plugins:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description: flutter
|
description: flutter
|
||||||
@@ -770,6 +818,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.7.4"
|
version: "0.7.4"
|
||||||
|
timezone:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: timezone
|
||||||
|
sha256: dd14a3b83cfd7cb19e7888f1cbc20f258b8d71b54c06f79ac585f14093a287d1
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.10.1"
|
||||||
typed_data:
|
typed_data:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -884,4 +940,4 @@ packages:
|
|||||||
version: "3.1.4"
|
version: "3.1.4"
|
||||||
sdks:
|
sdks:
|
||||||
dart: ">=3.8.1 <4.0.0"
|
dart: ">=3.8.1 <4.0.0"
|
||||||
flutter: ">=3.29.0"
|
flutter: ">=3.32.0"
|
||||||
@@ -13,11 +13,14 @@ dependencies:
|
|||||||
drift_flutter: ^0.2.8
|
drift_flutter: ^0.2.8
|
||||||
flutter:
|
flutter:
|
||||||
sdk: flutter
|
sdk: flutter
|
||||||
|
flutter_local_notifications: ^20.1.0
|
||||||
flutter_localizations:
|
flutter_localizations:
|
||||||
sdk: flutter
|
sdk: flutter
|
||||||
|
flutter_timezone: ^5.1.1
|
||||||
intl: ^0.20.2
|
intl: ^0.20.2
|
||||||
provider: ^6.1.5+1
|
provider: ^6.1.5+1
|
||||||
shared_preferences: ^2.5.3
|
shared_preferences: ^2.5.3
|
||||||
|
timezone: ^0.10.1
|
||||||
uuid: ^4.6.0
|
uuid: ^4.6.0
|
||||||
wakelock_plus: ^1.4.0
|
wakelock_plus: ^1.4.0
|
||||||
|
|
||||||
|
|||||||
@@ -55,4 +55,12 @@ void main() {
|
|||||||
|
|
||||||
expect(workout.map((s) => s.endedAt.day), [29, 28]);
|
expect(workout.map((s) => s.endedAt.day), [29, 28]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('the Streak sees complete Sessions of both Modes only', () async {
|
||||||
|
await sessions.save(result(Mode.workout, DateTime(2026, 10, 28)));
|
||||||
|
await sessions.save(result(Mode.stretching, DateTime(2026, 10, 29)));
|
||||||
|
await sessions.save(result(Mode.stretching, DateTime(2026, 10, 30), complete: false));
|
||||||
|
|
||||||
|
expect(await sessions.completeEndTimes(), unorderedEquals([DateTime(2026, 10, 28), DateTime(2026, 10, 29)]));
|
||||||
|
});
|
||||||
}
|
}
|
||||||
@@ -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/plans/plan_repository.dart';
|
||||||
import 'package:zenxercise/src/player/player_effects.dart';
|
import 'package:zenxercise/src/player/player_effects.dart';
|
||||||
import 'package:zenxercise/src/settings/settings_controller.dart';
|
import 'package:zenxercise/src/settings/settings_controller.dart';
|
||||||
|
import 'package:zenxercise/src/streak/reminder_scheduler.dart';
|
||||||
|
|
||||||
import 'test_database.dart';
|
import 'test_database.dart';
|
||||||
|
|
||||||
@@ -62,6 +63,31 @@ class FakePlayerEffects implements PlayerEffects {
|
|||||||
Future<void> keepScreenOn(bool on) async => screenOn = on;
|
Future<void> keepScreenOn(bool on) async => screenOn = on;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Records what would be scheduled instead of talking to Android.
|
||||||
|
class FakeNotifier implements Notifier {
|
||||||
|
final scheduled = <(DateTime, String)>[];
|
||||||
|
var permissionRequests = 0;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> init() async {}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<bool> requestPermission() async {
|
||||||
|
permissionRequests++;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<bool> enabled() async => true;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> cancelAll() async => scheduled.clear();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> schedule({required int id, required DateTime at, required String title, required String body}) async =>
|
||||||
|
scheduled.add((at, body));
|
||||||
|
}
|
||||||
|
|
||||||
/// A Pixel-sized screen instead of the default 800×600, so the floating
|
/// A Pixel-sized screen instead of the default 800×600, so the floating
|
||||||
/// navigation bar does not cover what a test taps.
|
/// navigation bar does not cover what a test taps.
|
||||||
void usePhoneScreen(WidgetTester tester) {
|
void usePhoneScreen(WidgetTester tester) {
|
||||||
@@ -78,20 +104,25 @@ Future<SettingsController> pumpApp(
|
|||||||
PlanRepository? plans,
|
PlanRepository? plans,
|
||||||
SessionRepository? sessions,
|
SessionRepository? sessions,
|
||||||
PlayerEffects? effects,
|
PlayerEffects? effects,
|
||||||
|
FakeNotifier? notifier,
|
||||||
}) async {
|
}) async {
|
||||||
SharedPreferences.setMockInitialValues(prefs);
|
SharedPreferences.setMockInitialValues(prefs);
|
||||||
final settings = SettingsController(SettingsService());
|
final settings = SettingsController(SettingsService());
|
||||||
await settings.loadSettings();
|
await settings.loadSettings();
|
||||||
final db = testDatabase();
|
final db = testDatabase();
|
||||||
addTearDown(db.close);
|
addTearDown(db.close);
|
||||||
|
final sessionRepository = sessions ?? SessionRepository(db);
|
||||||
|
final fakeNotifier = notifier ?? FakeNotifier();
|
||||||
await tester.pumpWidget(
|
await tester.pumpWidget(
|
||||||
MultiProvider(
|
MultiProvider(
|
||||||
providers: [
|
providers: [
|
||||||
ChangeNotifierProvider.value(value: settings),
|
ChangeNotifierProvider.value(value: settings),
|
||||||
Provider.value(value: library ?? sampleLibrary),
|
Provider.value(value: library ?? sampleLibrary),
|
||||||
Provider.value(value: plans ?? PlanRepository(db)),
|
Provider.value(value: plans ?? PlanRepository(db)),
|
||||||
Provider.value(value: sessions ?? SessionRepository(db)),
|
Provider.value(value: sessionRepository),
|
||||||
Provider<PlayerEffects>.value(value: effects ?? FakePlayerEffects()),
|
Provider<PlayerEffects>.value(value: effects ?? FakePlayerEffects()),
|
||||||
|
Provider<Notifier>.value(value: fakeNotifier),
|
||||||
|
Provider.value(value: ReminderScheduler(fakeNotifier, settings, sessionRepository)),
|
||||||
],
|
],
|
||||||
child: const ZenXerciseApp(),
|
child: const ZenXerciseApp(),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ void main() {
|
|||||||
late PlanRepository plans;
|
late PlanRepository plans;
|
||||||
late SessionRepository sessions;
|
late SessionRepository sessions;
|
||||||
late FakePlayerEffects effects;
|
late FakePlayerEffects effects;
|
||||||
|
late FakeNotifier notifier;
|
||||||
|
|
||||||
setUp(() {
|
setUp(() {
|
||||||
final db = testDatabase();
|
final db = testDatabase();
|
||||||
@@ -19,13 +20,21 @@ void main() {
|
|||||||
plans = PlanRepository(db);
|
plans = PlanRepository(db);
|
||||||
sessions = SessionRepository(db);
|
sessions = SessionRepository(db);
|
||||||
effects = FakePlayerEffects();
|
effects = FakePlayerEffects();
|
||||||
|
notifier = FakeNotifier();
|
||||||
});
|
});
|
||||||
|
|
||||||
Future<void> startPlan(WidgetTester tester, Mode mode, List<PlanItem> items) async {
|
Future<void> startPlan(WidgetTester tester, Mode mode, List<PlanItem> items) async {
|
||||||
final plan = await plans.create(mode, 'Routine');
|
final plan = await plans.create(mode, 'Routine');
|
||||||
await plans.save(plan.copyWith(items: items));
|
await plans.save(plan.copyWith(items: items));
|
||||||
usePhoneScreen(tester);
|
usePhoneScreen(tester);
|
||||||
await pumpApp(tester, plans: plans, sessions: sessions, effects: effects, prefs: {'mode': mode.name});
|
await pumpApp(
|
||||||
|
tester,
|
||||||
|
plans: plans,
|
||||||
|
sessions: sessions,
|
||||||
|
effects: effects,
|
||||||
|
notifier: notifier,
|
||||||
|
prefs: {'mode': mode.name},
|
||||||
|
);
|
||||||
await tester.tap(find.byTooltip('Start'));
|
await tester.tap(find.byTooltip('Start'));
|
||||||
await tester.pump();
|
await tester.pump();
|
||||||
await tester.pump();
|
await tester.pump();
|
||||||
@@ -66,6 +75,11 @@ void main() {
|
|||||||
await settle(tester);
|
await settle(tester);
|
||||||
|
|
||||||
expect(find.text('Session complete'), findsOneWidget);
|
expect(find.text('Session complete'), findsOneWidget);
|
||||||
|
await tester.runAsync(() => Future<void>.delayed(const Duration(milliseconds: 50)));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.text('Day 1 of your streak'), findsOneWidget);
|
||||||
|
// The first finished Session is when Reminders ask for permission.
|
||||||
|
expect(notifier.permissionRequests, 1);
|
||||||
expect(effects.chimes, [Mode.stretching, Mode.stretching]);
|
expect(effects.chimes, [Mode.stretching, Mode.stretching]);
|
||||||
expect(effects.screenOn, isFalse);
|
expect(effects.screenOn, isFalse);
|
||||||
expect((await sessionsOf(tester, Mode.stretching)).single.complete, isTrue);
|
expect((await sessionsOf(tester, Mode.stretching)).single.complete, isTrue);
|
||||||
|
|||||||
@@ -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