Stage 5: History, the shared daily Streak and Reminders
- Streak computed from all complete Sessions: Grace Hour (00:00-01:00 counts for yesterday unless it is done), a Freeze per 7 Streak Days (max 2) spent automatically on a missed day (ADR 0006) - Top bar flame (grey until today is done) opening a panel with current and longest Streak, Freezes and a month calendar; 'Day N of your streak' on a fresh summary - History per Mode grouped by day, reopening each summary - Two adjustable Reminders (19:00, 22:30) scheduled 14 days ahead with flutter_local_notifications, none on a done day; permission asked after the first complete Session or from Settings - Settings: Reminders, the Player chime, exercise credits Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
971ff64117
commit
1ffe6269ea
31 files changed
+1972
-50
No files matched your search
@@ -55,4 +55,12 @@ void main() {
|
||||
|
||||
expect(workout.map((s) => s.endedAt.day), [29, 28]);
|
||||
});
|
||||
|
||||
test('the Streak sees complete Sessions of both Modes only', () async {
|
||||
await sessions.save(result(Mode.workout, DateTime(2026, 10, 28)));
|
||||
await sessions.save(result(Mode.stretching, DateTime(2026, 10, 29)));
|
||||
await sessions.save(result(Mode.stretching, DateTime(2026, 10, 30), complete: false));
|
||||
|
||||
expect(await sessions.completeEndTimes(), unorderedEquals([DateTime(2026, 10, 28), DateTime(2026, 10, 29)]));
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:zenxercise/src/streak/reminders.dart';
|
||||
import 'package:zenxercise/src/streak/streak.dart';
|
||||
|
||||
void main() {
|
||||
const evening = Reminder(enabled: true, minutes: 19 * 60);
|
||||
const late = Reminder(enabled: true, minutes: 22 * 60 + 30);
|
||||
|
||||
Streak streak({bool todayDone = false, required DateTime now}) =>
|
||||
Streak.compute([if (todayDone) now.subtract(const Duration(minutes: 1))], now: now);
|
||||
|
||||
test('both Reminders are planned for each of the next days', () {
|
||||
final now = DateTime(2026, 10, 29, 9);
|
||||
final planned = upcomingReminders(
|
||||
[evening, late],
|
||||
streak: streak(now: now),
|
||||
now: now,
|
||||
days: 2,
|
||||
);
|
||||
|
||||
expect(planned.map((r) => r.at), [
|
||||
DateTime(2026, 10, 29, 19),
|
||||
DateTime(2026, 10, 29, 22, 30),
|
||||
DateTime(2026, 10, 30, 19),
|
||||
DateTime(2026, 10, 30, 22, 30),
|
||||
]);
|
||||
expect(planned.map((r) => r.id).toSet(), hasLength(4));
|
||||
});
|
||||
|
||||
test('a done day gets no Reminders, and times already past are skipped', () {
|
||||
final now = DateTime(2026, 10, 29, 20);
|
||||
final done = upcomingReminders(
|
||||
[evening, late],
|
||||
streak: streak(todayDone: true, now: now),
|
||||
now: now,
|
||||
days: 2,
|
||||
);
|
||||
final open = upcomingReminders(
|
||||
[evening, late],
|
||||
streak: streak(now: now),
|
||||
now: now,
|
||||
days: 1,
|
||||
);
|
||||
|
||||
expect(done.map((r) => r.at), [DateTime(2026, 10, 30, 19), DateTime(2026, 10, 30, 22, 30)]);
|
||||
expect(open.map((r) => r.at), [DateTime(2026, 10, 29, 22, 30)]);
|
||||
});
|
||||
|
||||
test('a switched-off Reminder is never planned', () {
|
||||
final now = DateTime(2026, 10, 29, 9);
|
||||
final planned = upcomingReminders(
|
||||
[evening, const Reminder(enabled: false, minutes: 22 * 60 + 30)],
|
||||
streak: streak(now: now),
|
||||
now: now,
|
||||
days: 2,
|
||||
);
|
||||
|
||||
expect(planned.map((r) => r.at.hour), [19, 19]);
|
||||
});
|
||||
|
||||
test('only today\'s Reminders mention the running Streak; later ones cannot know it', () {
|
||||
final now = DateTime(2026, 10, 29, 9);
|
||||
final s = Streak.compute([DateTime(2026, 10, 27, 18), DateTime(2026, 10, 28, 18)], now: now);
|
||||
|
||||
final planned = upcomingReminders([evening], streak: s, now: now, days: 2);
|
||||
|
||||
expect(planned.map((r) => r.streakDays), [2, null]);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:zenxercise/src/streak/day.dart';
|
||||
import 'package:zenxercise/src/streak/streak.dart';
|
||||
|
||||
/// A complete Session ending on October [day] at [hour]:[minute].
|
||||
DateTime oct(int day, [int hour = 18, int minute = 0]) => DateTime(2026, 10, day, hour, minute);
|
||||
|
||||
void main() {
|
||||
group('Streak Day', () {
|
||||
test('a Session counts for its calendar day', () {
|
||||
expect(streakDays([oct(3, 18)]), {Day(2026, 10, 3)});
|
||||
});
|
||||
|
||||
test('in the Grace Hour it counts for the previous day if that day is not done yet', () {
|
||||
expect(streakDays([oct(4, 0, 30)]), {Day(2026, 10, 3)});
|
||||
});
|
||||
|
||||
test('in the Grace Hour it counts for the current day once the previous day is done', () {
|
||||
expect(streakDays([oct(3, 20), oct(4, 0, 30)]), {Day(2026, 10, 3), Day(2026, 10, 4)});
|
||||
});
|
||||
|
||||
test('from 01:00 on it is the current day again', () {
|
||||
expect(streakDays([oct(4, 1, 0)]), {Day(2026, 10, 4)});
|
||||
});
|
||||
});
|
||||
|
||||
group('counting', () {
|
||||
test('no Sessions, no Streak', () {
|
||||
final s = Streak.compute(const [], now: oct(10, 9));
|
||||
|
||||
expect((s.current, s.longest, s.freezes, s.todayDone), (0, 0, 0, false));
|
||||
});
|
||||
|
||||
test('consecutive days count up and today being done lights it', () {
|
||||
final s = Streak.compute([oct(8), oct(9), oct(10)], now: oct(10, 20));
|
||||
|
||||
expect(s.current, 3);
|
||||
expect(s.todayDone, isTrue);
|
||||
});
|
||||
|
||||
test('today still open keeps yesterday\'s Streak alive but unlit', () {
|
||||
final s = Streak.compute([oct(8), oct(9)], now: oct(10, 12));
|
||||
|
||||
expect(s.current, 2);
|
||||
expect(s.todayDone, isFalse);
|
||||
});
|
||||
|
||||
test('a missed day without a Freeze resets the Streak, the longest is kept', () {
|
||||
final s = Streak.compute([oct(1), oct(2), oct(3), oct(5)], now: oct(5, 20));
|
||||
|
||||
expect(s.current, 1);
|
||||
expect(s.longest, 3);
|
||||
});
|
||||
|
||||
test('yesterday is not missed yet during the Grace Hour', () {
|
||||
final s = Streak.compute([oct(8), oct(9)], now: oct(11, 0, 20));
|
||||
|
||||
expect(s.current, 2);
|
||||
});
|
||||
});
|
||||
|
||||
group('Streak Freezes', () {
|
||||
test('one is earned per 7 Streak Days, holding at most 2', () {
|
||||
final days = [for (var d = 1; d <= 21; d++) oct(d)];
|
||||
|
||||
expect(Streak.compute(days.take(6).toList(), now: oct(6, 20)).freezes, 0);
|
||||
expect(Streak.compute(days.take(7).toList(), now: oct(7, 20)).freezes, 1);
|
||||
expect(Streak.compute(days, now: oct(21, 20)).freezes, 2);
|
||||
});
|
||||
|
||||
test('a Freeze covers a missed day automatically and shows on the calendar', () {
|
||||
final days = [for (var d = 1; d <= 7; d++) oct(d), oct(9)];
|
||||
|
||||
final s = Streak.compute(days, now: oct(9, 20));
|
||||
|
||||
expect(s.current, 8);
|
||||
expect(s.freezes, 0);
|
||||
expect(s.frozenDays, {Day(2026, 10, 8)});
|
||||
});
|
||||
|
||||
test('more missed days than Freezes reset the Streak', () {
|
||||
final days = [for (var d = 1; d <= 7; d++) oct(d), oct(10)];
|
||||
|
||||
final s = Streak.compute(days, now: oct(10, 20));
|
||||
|
||||
expect(s.current, 1);
|
||||
expect(s.freezes, 0);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import 'package:zenxercise/src/history/session_repository.dart';
|
||||
import 'package:zenxercise/src/plans/plan_repository.dart';
|
||||
import 'package:zenxercise/src/player/player_effects.dart';
|
||||
import 'package:zenxercise/src/settings/settings_controller.dart';
|
||||
import 'package:zenxercise/src/streak/reminder_scheduler.dart';
|
||||
|
||||
import 'test_database.dart';
|
||||
|
||||
@@ -62,6 +63,31 @@ class FakePlayerEffects implements PlayerEffects {
|
||||
Future<void> keepScreenOn(bool on) async => screenOn = on;
|
||||
}
|
||||
|
||||
/// Records what would be scheduled instead of talking to Android.
|
||||
class FakeNotifier implements Notifier {
|
||||
final scheduled = <(DateTime, String)>[];
|
||||
var permissionRequests = 0;
|
||||
|
||||
@override
|
||||
Future<void> init() async {}
|
||||
|
||||
@override
|
||||
Future<bool> requestPermission() async {
|
||||
permissionRequests++;
|
||||
return true;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<bool> enabled() async => true;
|
||||
|
||||
@override
|
||||
Future<void> cancelAll() async => scheduled.clear();
|
||||
|
||||
@override
|
||||
Future<void> schedule({required int id, required DateTime at, required String title, required String body}) async =>
|
||||
scheduled.add((at, body));
|
||||
}
|
||||
|
||||
/// A Pixel-sized screen instead of the default 800×600, so the floating
|
||||
/// navigation bar does not cover what a test taps.
|
||||
void usePhoneScreen(WidgetTester tester) {
|
||||
@@ -78,20 +104,25 @@ Future<SettingsController> pumpApp(
|
||||
PlanRepository? plans,
|
||||
SessionRepository? sessions,
|
||||
PlayerEffects? effects,
|
||||
FakeNotifier? notifier,
|
||||
}) async {
|
||||
SharedPreferences.setMockInitialValues(prefs);
|
||||
final settings = SettingsController(SettingsService());
|
||||
await settings.loadSettings();
|
||||
final db = testDatabase();
|
||||
addTearDown(db.close);
|
||||
final sessionRepository = sessions ?? SessionRepository(db);
|
||||
final fakeNotifier = notifier ?? FakeNotifier();
|
||||
await tester.pumpWidget(
|
||||
MultiProvider(
|
||||
providers: [
|
||||
ChangeNotifierProvider.value(value: settings),
|
||||
Provider.value(value: library ?? sampleLibrary),
|
||||
Provider.value(value: plans ?? PlanRepository(db)),
|
||||
Provider.value(value: sessions ?? SessionRepository(db)),
|
||||
Provider.value(value: sessionRepository),
|
||||
Provider<PlayerEffects>.value(value: effects ?? FakePlayerEffects()),
|
||||
Provider<Notifier>.value(value: fakeNotifier),
|
||||
Provider.value(value: ReminderScheduler(fakeNotifier, settings, sessionRepository)),
|
||||
],
|
||||
child: const ZenXerciseApp(),
|
||||
),
|
||||
|
||||
@@ -12,6 +12,7 @@ void main() {
|
||||
late PlanRepository plans;
|
||||
late SessionRepository sessions;
|
||||
late FakePlayerEffects effects;
|
||||
late FakeNotifier notifier;
|
||||
|
||||
setUp(() {
|
||||
final db = testDatabase();
|
||||
@@ -19,13 +20,21 @@ void main() {
|
||||
plans = PlanRepository(db);
|
||||
sessions = SessionRepository(db);
|
||||
effects = FakePlayerEffects();
|
||||
notifier = FakeNotifier();
|
||||
});
|
||||
|
||||
Future<void> startPlan(WidgetTester tester, Mode mode, List<PlanItem> items) async {
|
||||
final plan = await plans.create(mode, 'Routine');
|
||||
await plans.save(plan.copyWith(items: items));
|
||||
usePhoneScreen(tester);
|
||||
await pumpApp(tester, plans: plans, sessions: sessions, effects: effects, prefs: {'mode': mode.name});
|
||||
await pumpApp(
|
||||
tester,
|
||||
plans: plans,
|
||||
sessions: sessions,
|
||||
effects: effects,
|
||||
notifier: notifier,
|
||||
prefs: {'mode': mode.name},
|
||||
);
|
||||
await tester.tap(find.byTooltip('Start'));
|
||||
await tester.pump();
|
||||
await tester.pump();
|
||||
@@ -66,6 +75,11 @@ void main() {
|
||||
await settle(tester);
|
||||
|
||||
expect(find.text('Session complete'), findsOneWidget);
|
||||
await tester.runAsync(() => Future<void>.delayed(const Duration(milliseconds: 50)));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Day 1 of your streak'), findsOneWidget);
|
||||
// The first finished Session is when Reminders ask for permission.
|
||||
expect(notifier.permissionRequests, 1);
|
||||
expect(effects.chimes, [Mode.stretching, Mode.stretching]);
|
||||
expect(effects.screenOn, isFalse);
|
||||
expect((await sessionsOf(tester, Mode.stretching)).single.complete, isTrue);
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
import 'package:clock/clock.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:zenxercise/src/history/session_repository.dart';
|
||||
import 'package:zenxercise/src/modes/mode.dart';
|
||||
import 'package:zenxercise/src/plans/models/plan.dart';
|
||||
import 'package:zenxercise/src/plans/plan_repository.dart';
|
||||
import 'package:zenxercise/src/player/session_runner.dart';
|
||||
|
||||
import '../support/test_app.dart';
|
||||
import '../support/test_database.dart';
|
||||
|
||||
/// Noon, so the Grace Hour never shifts what counts as today.
|
||||
final noon = DateTime(2026, 10, 29, 12);
|
||||
|
||||
void main() {
|
||||
late PlanRepository plans;
|
||||
late SessionRepository sessions;
|
||||
late FakeNotifier notifier;
|
||||
|
||||
setUp(() {
|
||||
final db = testDatabase();
|
||||
addTearDown(db.close);
|
||||
plans = PlanRepository(db);
|
||||
sessions = SessionRepository(db);
|
||||
notifier = FakeNotifier();
|
||||
});
|
||||
|
||||
Plan plan(Mode mode) => Plan(
|
||||
id: 'p1',
|
||||
mode: mode,
|
||||
name: 'Legs',
|
||||
items: const [WorkoutStep(exerciseId: 'Barbell_Squat', sets: 1, target: WorkoutTarget.reps(10))],
|
||||
createdAt: DateTime(2026),
|
||||
updatedAt: DateTime(2026),
|
||||
);
|
||||
|
||||
Future<void> finished(WidgetTester tester, Mode mode, DateTime end, {bool complete = true}) async => tester.runAsync(
|
||||
() => sessions.save(
|
||||
SessionResult(
|
||||
plan: plan(mode),
|
||||
startedAt: end.subtract(const Duration(minutes: 20)),
|
||||
endedAt: end,
|
||||
duration: const Duration(minutes: 20),
|
||||
complete: complete,
|
||||
setLogs: const [SetLog(exerciseId: 'Barbell_Squat', set: 1, reps: 10)],
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
/// Lets drift's streams deliver (they need real time), then settles.
|
||||
Future<void> settle(WidgetTester tester) async {
|
||||
await tester.runAsync(() => Future<void>.delayed(const Duration(milliseconds: 50)));
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
testWidgets('the flame counts finished days across both Modes and opens the Streak panel', (tester) async {
|
||||
await withClock(Clock.fixed(noon), () async {
|
||||
await finished(tester, Mode.workout, noon.subtract(const Duration(days: 1)));
|
||||
await finished(tester, Mode.stretching, noon);
|
||||
usePhoneScreen(tester);
|
||||
await pumpApp(tester, plans: plans, sessions: sessions, notifier: notifier);
|
||||
await settle(tester);
|
||||
|
||||
expect(find.descendant(of: find.byTooltip('Streak'), matching: find.text('2')), findsOneWidget);
|
||||
|
||||
await tester.tap(find.byTooltip('Streak'));
|
||||
await settle(tester);
|
||||
|
||||
expect(find.text('2-day streak'), findsOneWidget);
|
||||
expect(find.text('Today is done.'), findsOneWidget);
|
||||
expect(find.text('Freezes: 0 of 2'), findsOneWidget);
|
||||
});
|
||||
});
|
||||
|
||||
testWidgets('History lists the Mode\'s Sessions by day and reopens a summary', (tester) async {
|
||||
await withClock(Clock.fixed(noon), () async {
|
||||
await finished(tester, Mode.workout, noon, complete: false);
|
||||
await finished(tester, Mode.stretching, noon);
|
||||
usePhoneScreen(tester);
|
||||
await pumpApp(tester, plans: plans, sessions: sessions, prefs: {'mode': 'workout'});
|
||||
await tester.tap(find.byTooltip('History'));
|
||||
await settle(tester);
|
||||
|
||||
expect(find.text('Today'), findsOneWidget);
|
||||
expect(find.text('Legs'), findsOneWidget);
|
||||
expect(find.text('Ended early'), findsOneWidget);
|
||||
|
||||
await tester.tap(find.text('Legs'));
|
||||
await settle(tester);
|
||||
|
||||
expect(find.text('Set 1 · 10 reps'), findsOneWidget);
|
||||
});
|
||||
});
|
||||
|
||||
testWidgets('switching a Reminder off in Settings drops its notifications', (tester) async {
|
||||
await withClock(Clock.fixed(noon), () async {
|
||||
usePhoneScreen(tester);
|
||||
await pumpApp(tester, plans: plans, sessions: sessions, notifier: notifier);
|
||||
await tester.tap(find.byTooltip('Settings'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('19:00'), findsOneWidget);
|
||||
expect(find.text('22:30'), findsOneWidget);
|
||||
|
||||
await tester.tap(find.byType(Switch).at(1));
|
||||
await settle(tester);
|
||||
|
||||
expect(notifier.permissionRequests, 0);
|
||||
expect(notifier.scheduled, isNotEmpty);
|
||||
expect(notifier.scheduled.every((r) => r.$1.hour == 19), isTrue);
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user