Stage 5: History, the shared daily Streak and Reminders

- Streak computed from all complete Sessions: Grace Hour (00:00-01:00
  counts for yesterday unless it is done), a Freeze per 7 Streak Days
  (max 2) spent automatically on a missed day (ADR 0006)
- Top bar flame (grey until today is done) opening a panel with current
  and longest Streak, Freezes and a month calendar; 'Day N of your
  streak' on a fresh summary
- History per Mode grouped by day, reopening each summary
- Two adjustable Reminders (19:00, 22:30) scheduled 14 days ahead with
  flutter_local_notifications, none on a done day; permission asked
  after the first complete Session or from Settings
- Settings: Reminders, the Player chime, exercise credits

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
CedricandClaude Opus 5.5 committed 2026-10-29 00:34:49 +01:00
1 parent 971ff64117
commit 1ffe6269ea
31 files changed
+1972 -50

No files matched your search

+69
View File
@@ -0,0 +1,69 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:zenxercise/src/streak/reminders.dart';
import 'package:zenxercise/src/streak/streak.dart';
void main() {
const evening = Reminder(enabled: true, minutes: 19 * 60);
const late = Reminder(enabled: true, minutes: 22 * 60 + 30);
Streak streak({bool todayDone = false, required DateTime now}) =>
Streak.compute([if (todayDone) now.subtract(const Duration(minutes: 1))], now: now);
test('both Reminders are planned for each of the next days', () {
final now = DateTime(2026, 10, 29, 9);
final planned = upcomingReminders(
[evening, late],
streak: streak(now: now),
now: now,
days: 2,
);
expect(planned.map((r) => r.at), [
DateTime(2026, 10, 29, 19),
DateTime(2026, 10, 29, 22, 30),
DateTime(2026, 10, 30, 19),
DateTime(2026, 10, 30, 22, 30),
]);
expect(planned.map((r) => r.id).toSet(), hasLength(4));
});
test('a done day gets no Reminders, and times already past are skipped', () {
final now = DateTime(2026, 10, 29, 20);
final done = upcomingReminders(
[evening, late],
streak: streak(todayDone: true, now: now),
now: now,
days: 2,
);
final open = upcomingReminders(
[evening, late],
streak: streak(now: now),
now: now,
days: 1,
);
expect(done.map((r) => r.at), [DateTime(2026, 10, 30, 19), DateTime(2026, 10, 30, 22, 30)]);
expect(open.map((r) => r.at), [DateTime(2026, 10, 29, 22, 30)]);
});
test('a switched-off Reminder is never planned', () {
final now = DateTime(2026, 10, 29, 9);
final planned = upcomingReminders(
[evening, const Reminder(enabled: false, minutes: 22 * 60 + 30)],
streak: streak(now: now),
now: now,
days: 2,
);
expect(planned.map((r) => r.at.hour), [19, 19]);
});
test('only today\'s Reminders mention the running Streak; later ones cannot know it', () {
final now = DateTime(2026, 10, 29, 9);
final s = Streak.compute([DateTime(2026, 10, 27, 18), DateTime(2026, 10, 28, 18)], now: now);
final planned = upcomingReminders([evening], streak: s, now: now, days: 2);
expect(planned.map((r) => r.streakDays), [2, null]);
});
}
+90
View File
@@ -0,0 +1,90 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:zenxercise/src/streak/day.dart';
import 'package:zenxercise/src/streak/streak.dart';
/// A complete Session ending on October [day] at [hour]:[minute].
DateTime oct(int day, [int hour = 18, int minute = 0]) => DateTime(2026, 10, day, hour, minute);
void main() {
group('Streak Day', () {
test('a Session counts for its calendar day', () {
expect(streakDays([oct(3, 18)]), {Day(2026, 10, 3)});
});
test('in the Grace Hour it counts for the previous day if that day is not done yet', () {
expect(streakDays([oct(4, 0, 30)]), {Day(2026, 10, 3)});
});
test('in the Grace Hour it counts for the current day once the previous day is done', () {
expect(streakDays([oct(3, 20), oct(4, 0, 30)]), {Day(2026, 10, 3), Day(2026, 10, 4)});
});
test('from 01:00 on it is the current day again', () {
expect(streakDays([oct(4, 1, 0)]), {Day(2026, 10, 4)});
});
});
group('counting', () {
test('no Sessions, no Streak', () {
final s = Streak.compute(const [], now: oct(10, 9));
expect((s.current, s.longest, s.freezes, s.todayDone), (0, 0, 0, false));
});
test('consecutive days count up and today being done lights it', () {
final s = Streak.compute([oct(8), oct(9), oct(10)], now: oct(10, 20));
expect(s.current, 3);
expect(s.todayDone, isTrue);
});
test('today still open keeps yesterday\'s Streak alive but unlit', () {
final s = Streak.compute([oct(8), oct(9)], now: oct(10, 12));
expect(s.current, 2);
expect(s.todayDone, isFalse);
});
test('a missed day without a Freeze resets the Streak, the longest is kept', () {
final s = Streak.compute([oct(1), oct(2), oct(3), oct(5)], now: oct(5, 20));
expect(s.current, 1);
expect(s.longest, 3);
});
test('yesterday is not missed yet during the Grace Hour', () {
final s = Streak.compute([oct(8), oct(9)], now: oct(11, 0, 20));
expect(s.current, 2);
});
});
group('Streak Freezes', () {
test('one is earned per 7 Streak Days, holding at most 2', () {
final days = [for (var d = 1; d <= 21; d++) oct(d)];
expect(Streak.compute(days.take(6).toList(), now: oct(6, 20)).freezes, 0);
expect(Streak.compute(days.take(7).toList(), now: oct(7, 20)).freezes, 1);
expect(Streak.compute(days, now: oct(21, 20)).freezes, 2);
});
test('a Freeze covers a missed day automatically and shows on the calendar', () {
final days = [for (var d = 1; d <= 7; d++) oct(d), oct(9)];
final s = Streak.compute(days, now: oct(9, 20));
expect(s.current, 8);
expect(s.freezes, 0);
expect(s.frozenDays, {Day(2026, 10, 8)});
});
test('more missed days than Freezes reset the Streak', () {
final days = [for (var d = 1; d <= 7; d++) oct(d), oct(10)];
final s = Streak.compute(days, now: oct(10, 20));
expect(s.current, 1);
expect(s.freezes, 0);
});
});
}