From 19f23f35a66f61495838d06881c531ff7f389c2d Mon Sep 17 00:00:00 2001 From: Krzysztof kuhy Rudnicki Date: Sun, 27 Sep 2026 20:53:46 +0200 Subject: [PATCH] Free days: a month calendar of the shared pool; tap a future date to book it, tap a booked one to release it Co-Authored-By: Claude Opus 5 (1M context) --- DOCS-free-days.md | 13 +- phone_app/lib/screens/free_day_calendar.dart | 201 +++++++++++++++++++ phone_app/lib/screens/free_days_body.dart | 15 +- phone_app/lib/screens/free_days_screen.dart | 80 +++----- phone_app/test/free_days_screen_test.dart | 166 +++++++++------ 5 files changed, 355 insertions(+), 120 deletions(-) create mode 100644 phone_app/lib/screens/free_day_calendar.dart diff --git a/DOCS-free-days.md b/DOCS-free-days.md index 6825ace..8a0ca54 100644 --- a/DOCS-free-days.md +++ b/DOCS-free-days.md @@ -11,11 +11,22 @@ event — the phone stays silent — rather than an absent dialog. | | | |---|---| -| Picker | `phone_app/lib/screens/free_days_screen.dart` (+ `free_days_body.dart`), the calendar icon on the home screen | +| Picker | `phone_app/lib/screens/free_days_screen.dart` (+ `free_days_body.dart`, `free_day_calendar.dart`), the calendar icon on the home screen | | Rules, Dart port | `phone_app/lib/services/free_day_pool.dart` — same record shape as the Python package, byte for byte | | Sync | `phone_app/lib/services/free_day_sync.dart` — Firebase only, `freedays-sync/devices//free_days.json` | | Ring path | `android/.../FreeDays.kt` + the check at the top of `AlarmReceiver.onReceive` | +## The calendar + +A Monday-first month grid (adapted from punchme's, whose own free days are a +separate concept and are not read here). One tap per future date: an empty day +is booked with no reason prompt, a booked day is released and refunded. Today +and the past are read-only — releasing a day that has started still spends +it. Tue/Wed/Thu (`workdays`, mirroring `freedays.WORKDAYS`) are shaded; past +free days stay visible, faded. Months run from January of this year to +December of next. The "Booked ahead" list below keeps reasons from PC marks +and its own Release buttons. + ## Why there is a native cache The alarm fires from Kotlin with no Flutter engine running, so at 08:00 the diff --git a/phone_app/lib/screens/free_day_calendar.dart b/phone_app/lib/screens/free_day_calendar.dart new file mode 100644 index 0000000..a649b53 --- /dev/null +++ b/phone_app/lib/screens/free_day_calendar.dart @@ -0,0 +1,201 @@ +import 'package:flutter/material.dart'; +import 'package:wake_alarm_sync/services/alarm_ramp.dart'; +import 'package:wake_alarm_sync/services/free_day_pool.dart'; + +/// A month grid of the shared free-day pool, one tap per day. +/// +/// Adapted from punchme's `FreeDayCalendar` (Monday-first grid, dumb cells); +/// punchme's own free days are a separate concept and are not read here. +/// Deciding what a tap *means* stays with the screen: this only reports which +/// future date was tapped. Past days and today are inert -- releasing a day +/// that has already started still spends it, so the grid does not offer it. +class FreeDayCalendar extends StatelessWidget { + /// Creates a grid over [month]. + const FreeDayCalendar({ + required this.month, + required this.today, + required this.isFree, + required this.busy, + required this.onTapDay, + required this.onMonth, + this.firstMonth, + this.lastMonth, + super.key, + }); + + /// The month on display; only its year and month are read. + final DateTime month; + + /// The reference date: it and everything before it are read-only. + final DateTime today; + + /// Whether a date is currently free in the pool. + final bool Function(DateTime day) isFree; + + /// Whether a sync is in flight, which makes every day inert. + final bool busy; + + /// Called with a tapped future date. + final void Function(DateTime day) onTapDay; + + /// Called with the month to show after an arrow tap. + final void Function(DateTime month) onMonth; + + /// Earliest month the back arrow reaches; null means no limit. + final DateTime? firstMonth; + + /// Latest month the forward arrow reaches; null means no limit. + final DateTime? lastMonth; + + static const _labels = ['M', 'T', 'W', 'T', 'F', 'S', 'S']; + static const _months = [ + 'January', 'February', 'March', 'April', 'May', 'June', 'July', // + 'August', 'September', 'October', 'November', 'December', + ]; + + bool _before(DateTime a, DateTime b) => + a.year < b.year || (a.year == b.year && a.month < b.month); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final first = firstMonth; + final last = lastMonth; + final canBack = first == null || _before(first, month); + final canForward = last == null || _before(month, last); + final blanks = DateTime(month.year, month.month).weekday - 1; + final total = DateTime(month.year, month.month + 1, 0).day; + return Column( + children: [ + Row( + children: [ + IconButton( + icon: const Icon(Icons.chevron_left), + tooltip: 'Previous month', + onPressed: canBack + ? () => onMonth(DateTime(month.year, month.month - 1)) + : null, + ), + Expanded( + child: Text( + '${_months[month.month - 1]} ${month.year}', + textAlign: TextAlign.center, + style: theme.textTheme.titleMedium, + ), + ), + IconButton( + icon: const Icon(Icons.chevron_right), + tooltip: 'Next month', + onPressed: canForward + ? () => onMonth(DateTime(month.year, month.month + 1)) + : null, + ), + ], + ), + Row( + children: [ + for (final label in _labels) + Expanded( + child: Center( + child: Text( + label, + style: theme.textTheme.labelSmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ), + ), + ], + ), + const SizedBox(height: 4), + GridView.count( + crossAxisCount: DateTime.daysPerWeek, + shrinkWrap: true, + // The screen's ListView owns scrolling. + physics: const NeverScrollableScrollPhysics(), + mainAxisSpacing: 4, + crossAxisSpacing: 4, + children: [ + for (var i = 0; i < blanks; i++) const SizedBox.shrink(), + for (var day = 1; day <= total; day++) _cell(day), + ], + ), + ], + ); + } + + Widget _cell(int day) { + final date = DateTime(month.year, month.month, day); + final future = date.isAfter(today); + return FreeDayCell( + key: ValueKey('free-day-${FreeDayPool.toIso(date)}'), + day: day, + isFree: isFree(date), + isWorkday: isWorkday(date), + isPast: !future, + onTap: future && !busy ? () => onTapDay(date) : null, + ); + } +} + +/// One day box: renders the state it is handed and reports taps. +class FreeDayCell extends StatelessWidget { + /// Creates a cell for [day] of the month. + const FreeDayCell({ + required this.day, + required this.isFree, + required this.isWorkday, + required this.isPast, + required this.onTap, + super.key, + }); + + /// The day of the month, as shown in the box. + final int day; + + /// Whether this date is a free day. + final bool isFree; + + /// Whether this date is a weekly workday (Tue/Wed/Thu), shaded. + final bool isWorkday; + + /// Whether this date is today or earlier, and so read-only. + final bool isPast; + + /// Called on tap, or null when the cell is inert. + final VoidCallback? onTap; + + @override + Widget build(BuildContext context) { + final colors = Theme.of(context).colorScheme; + // Past days keep their marker, faded: the record is history, not a + // choice still open. + final fade = isPast ? 0.38 : 1.0; + final background = isFree + ? colors.primary.withValues(alpha: fade) + : isWorkday + ? colors.surfaceContainerHigh + : null; + final foreground = isFree + ? colors.onPrimary + : colors.onSurface.withValues(alpha: fade); + return InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(8), + child: Container( + alignment: Alignment.center, + decoration: BoxDecoration( + color: background, + borderRadius: BorderRadius.circular(8), + ), + child: Text( + '$day', + style: TextStyle( + color: foreground, + fontWeight: isFree ? FontWeight.w600 : FontWeight.normal, + ), + ), + ), + ); + } +} diff --git a/phone_app/lib/screens/free_days_body.dart b/phone_app/lib/screens/free_days_body.dart index 39567e1..5120641 100644 --- a/phone_app/lib/screens/free_days_body.dart +++ b/phone_app/lib/screens/free_days_body.dart @@ -1,11 +1,13 @@ import 'package:flutter/material.dart'; +import 'package:wake_alarm_sync/screens/free_day_calendar.dart'; import 'package:wake_alarm_sync/services/free_day_pool.dart'; import 'package:wake_alarm_sync/ui/theme.dart'; /// One upcoming free day, as the list shows it. typedef UpcomingFreeDay = ({DateTime day, String reason}); -/// The picker's body: the year's budget, the upcoming days, the status line. +/// The picker's body: the year's budget, the month calendar, the upcoming +/// days and the status line. /// /// Stateless and fed plain values, like `HomeBody`: the screen owns the pool /// and the sync ordering, this only draws. @@ -18,9 +20,13 @@ class FreeDaysBody extends StatelessWidget { required this.status, required this.busy, required this.onRelease, + required this.calendar, super.key, }); + /// The month grid, built by the screen, which owns the displayed month. + final FreeDayCalendar calendar; + /// The calendar year the budget line describes. final int year; @@ -50,8 +56,7 @@ class FreeDaysBody extends StatelessWidget { final statusColors = theme.extension()!; return SafeArea( child: ListView( - // Bottom room for the extended FAB, so the last row stays tappable. - padding: const EdgeInsets.fromLTRB(16, 16, 16, 96), + padding: const EdgeInsets.all(16), children: [ Text( '$remaining of ${FreeDayPool.annualBudget}', @@ -86,12 +91,14 @@ class FreeDaysBody extends StatelessWidget { ), ), ], + const SizedBox(height: 16), + calendar, const SizedBox(height: 24), Text('Booked ahead', style: theme.textTheme.titleMedium), const SizedBox(height: 8), if (upcoming.isEmpty) Text( - 'None. Tap "Take a day" to book one.', + 'None. Tap a date above to book one.', style: TextStyle(color: theme.colorScheme.onSurfaceVariant), ), for (final entry in upcoming) diff --git a/phone_app/lib/screens/free_days_screen.dart b/phone_app/lib/screens/free_days_screen.dart index 448b49f..4fa6d32 100644 --- a/phone_app/lib/screens/free_days_screen.dart +++ b/phone_app/lib/screens/free_days_screen.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'package:flutter/material.dart'; +import 'package:wake_alarm_sync/screens/free_day_calendar.dart'; import 'package:wake_alarm_sync/screens/free_days_body.dart'; import 'package:wake_alarm_sync/services/free_day_pool.dart'; import 'package:wake_alarm_sync/services/free_day_sync.dart'; @@ -39,6 +40,9 @@ class _FreeDaysScreenState extends State { bool _busy = true; String _status = ''; + /// The month the calendar shows; starts on the current one. + late DateTime _month = DateTime(_today.year, _today.month); + late final FreeDaySync _syncer = widget.syncer ?? FreeDaySync(); DateTime get _today => @@ -79,56 +83,19 @@ class _FreeDaysScreenState extends State { }); } - Future _pickDay() async { + /// One tap per day: a free day is given back, any other is taken. + /// + /// No reason prompt and no confirmation -- a future release refunds the + /// budget, so a mis-tap costs one more tap. The calendar only reports + /// future dates, so today and the past never reach here. + Future _toggle(DateTime day) async { final pool = _pool; if (pool == null) return; final today = _today; - // showDatePicker asserts that initialDate satisfies the predicate, so - // when today is already free the cursor starts on the next day that is - // not. Day arithmetic, not Duration: adding 24h across a DST change - // lands on the same date twice. - var initial = today; - while (pool.isFree(initial)) { - initial = DateTime(initial.year, initial.month, initial.day + 1); - } - final picked = await showDatePicker( - context: context, - initialDate: initial, - firstDate: today, - lastDate: DateTime(today.year + 1, 12, 31), - helpText: 'Take a free day', - selectableDayPredicate: (day) => !pool.isFree(day), - ); - if (picked == null || !mounted) return; - final reason = await _askReason(); - // Null is Cancel or a barrier tap; an empty string is "no reason". - if (reason == null || !mounted) return; - await _apply(() => pool.mark(picked, reason: reason, now: today)); - } - - /// Optional, by design. Enter with an empty field and nothing is stored. - Future _askReason() { - final controller = TextEditingController(); - return showDialog( - context: context, - builder: (context) => AlertDialog( - title: const Text('Reason (optional)'), - content: TextField( - controller: controller, - autofocus: true, - decoration: const InputDecoration(hintText: 'e.g. travelling'), - ), - actions: [ - TextButton( - onPressed: () => Navigator.of(context).pop(), - child: const Text('Cancel'), - ), - TextButton( - onPressed: () => Navigator.of(context).pop(controller.text), - child: const Text('Mark free'), - ), - ], - ), + await _apply( + () => pool.isFree(day) + ? pool.release(day, now: today) + : pool.mark(day, now: today), ); } @@ -176,13 +143,18 @@ class _FreeDaysScreenState extends State { status: _status, busy: _busy, onRelease: (day) => unawaited(_release(day)), - ), - floatingActionButton: pool == null - ? null - : FloatingActionButton.extended( - onPressed: _busy ? null : () => unawaited(_pickDay()), - icon: const Icon(Icons.event_available), - label: const Text('Take a day'), + calendar: FreeDayCalendar( + month: _month, + today: _today, + isFree: pool.isFree, + busy: _busy, + onTapDay: (day) => unawaited(_toggle(day)), + onMonth: (month) => setState(() => _month = month), + // Back to January shows this year's spent days; forward to + // the end of next year, the pool's booking horizon. + firstMonth: DateTime(_today.year), + lastMonth: DateTime(_today.year + 1, 12), + ), ), ); } diff --git a/phone_app/test/free_days_screen_test.dart b/phone_app/test/free_days_screen_test.dart index 60cfca4..9206813 100644 --- a/phone_app/test/free_days_screen_test.dart +++ b/phone_app/test/free_days_screen_test.dart @@ -41,6 +41,14 @@ void main() { syncs = 0; }); + /// A Pixel 6a-sized surface, so the header, grid and list all fit. + void phoneSize(WidgetTester tester) { + tester.view + ..physicalSize = const Size(1080, 2400) + ..devicePixelRatio = 2.625; + addTearDown(tester.view.reset); + } + Widget app() => MaterialApp( theme: buildAppTheme(), home: FreeDaysScreen( @@ -62,6 +70,7 @@ void main() { await pool.mark(DateTime(2026, 9, 20), reason: 'travelling', now: _today); // A past free day is not "booked ahead". await pool.mark(DateTime(2026, 9, 10), now: DateTime(2026, 9, 10)); + phoneSize(tester); await tester.pumpWidget(app()); await tester.pumpAndSettle(); @@ -77,106 +86,141 @@ void main() { ); }); - testWidgets('release gives a future day back and re-syncs', (tester) async { + Finder cell(DateTime day) => + find.byKey(ValueKey('free-day-${FreeDayPool.toIso(day)}')); + + /// Scrolls the day into view, then taps it. + Future tapDay(WidgetTester tester, DateTime day) async { + await tester.ensureVisible(cell(day)); + await tester.tap(cell(day)); + await tester.pumpAndSettle(); + } + + testWidgets('the list Release gives a future day back and re-syncs', ( + tester, + ) async { await pool.mark(DateTime(2026, 9, 20), now: _today); + phoneSize(tester); await tester.pumpWidget(app()); await tester.pumpAndSettle(); + await tester.ensureVisible(find.text('Release')); await tester.tap(find.text('Release')); await tester.pumpAndSettle(); expect(pool.isFree(DateTime(2026, 9, 20)), isFalse); expect(find.text('35 of 35'), findsOneWidget); - expect(find.textContaining('Tap "Take a day"'), findsOneWidget); + expect(find.textContaining('Tap a date above'), findsOneWidget); expect(syncs, 2); }); - testWidgets('take a day walks the date picker and the reason dialog', ( - tester, - ) async { + testWidgets('the calendar opens on the current month', (tester) async { + phoneSize(tester); await tester.pumpWidget(app()); await tester.pumpAndSettle(); - await tester.tap(find.text('Take a day')); - await tester.pumpAndSettle(); - expect(find.text('Take a free day'), findsOneWidget); - // Tomorrow, in the calendar grid of the current month. - await tester.tap(find.text('12')); - await tester.tap(find.text('OK')); + expect(find.text('September 2026'), findsOneWidget); + expect(find.text('Take a day'), findsNothing); + }); + + testWidgets('tapping a future day books it, no reason asked', (tester) async { + phoneSize(tester); + await tester.pumpWidget(app()); await tester.pumpAndSettle(); - expect(find.text('Reason (optional)'), findsOneWidget); - await tester.enterText(find.byType(TextField), 'dentist'); - await tester.tap(find.text('Mark free')); - await tester.pumpAndSettle(); + await tapDay(tester, DateTime(2026, 9, 12)); expect(pool.isFree(DateTime(2026, 9, 12)), isTrue); - expect(pool.reasonFor(DateTime(2026, 9, 12)), 'dentist'); + expect(pool.reasonFor(DateTime(2026, 9, 12)), ''); expect(find.text('34 of 35'), findsOneWidget); expect(find.text('Sat 2026-09-12'), findsOneWidget); expect(syncs, 2); }); - testWidgets('a refused edit shows the reason and does not sync', ( + testWidgets('tapping a booked future day releases it and refunds', ( + tester, + ) async { + await pool.mark(DateTime(2026, 9, 20), now: _today); + phoneSize(tester); + await tester.pumpWidget(app()); + await tester.pumpAndSettle(); + expect(find.text('34 of 35'), findsOneWidget); + + await tapDay(tester, DateTime(2026, 9, 20)); + + expect(pool.isFree(DateTime(2026, 9, 20)), isFalse); + expect(find.text('35 of 35'), findsOneWidget); + expect(syncs, 2); + }); + + testWidgets('today and past days are read-only', (tester) async { + await pool.mark(DateTime(2026, 9, 10), now: DateTime(2026, 9, 10)); + phoneSize(tester); + await tester.pumpWidget(app()); + await tester.pumpAndSettle(); + + await tapDay(tester, _today); + await tapDay(tester, DateTime(2026, 9, 10)); + await tapDay(tester, DateTime(2026, 9, 2)); + + expect(pool.isFree(_today), isFalse); + expect(pool.isFree(DateTime(2026, 9, 10)), isTrue); + expect(pool.isFree(DateTime(2026, 9, 2)), isFalse); + expect(syncs, 1); + }); + + testWidgets('month arrows move and stop at the horizon', (tester) async { + phoneSize(tester); + await tester.pumpWidget(app()); + await tester.pumpAndSettle(); + + Future press(String tooltip, int times) async { + for (var i = 0; i < times; i++) { + await tester.tap(find.byTooltip(tooltip)); + await tester.pumpAndSettle(); + } + } + + await press('Next month', 1); + expect(find.text('October 2026'), findsOneWidget); + await tapDay(tester, DateTime(2026, 10, 2)); + expect(pool.isFree(DateTime(2026, 10, 2)), isTrue); + + // Back to January of this year, and no further. + await press('Previous month', 9); + expect(find.text('January 2026'), findsOneWidget); + await press('Previous month', 1); + expect(find.text('January 2026'), findsOneWidget); + + // Forward to December of next year, and no further. + await press('Next month', 23); + expect(find.text('December 2027'), findsOneWidget); + await press('Next month', 1); + expect(find.text('December 2027'), findsOneWidget); + }); + + testWidgets('a refused booking shows the reason and does not sync', ( tester, ) async { // Exhaust the year first. for (var i = 0; i < FreeDayPool.annualBudget; i++) { await pool.mark(DateTime(2026, 10, 1 + i), now: _today); } + phoneSize(tester); await tester.pumpWidget(app()); await tester.pumpAndSettle(); expect(find.text('0 of 35'), findsOneWidget); - await tester.tap(find.text('Take a day')); - await tester.pumpAndSettle(); - await tester.tap(find.text('12')); - await tester.tap(find.text('OK')); - await tester.pumpAndSettle(); - await tester.tap(find.text('Mark free')); - await tester.pumpAndSettle(); + await tapDay(tester, DateTime(2026, 9, 12)); + expect(pool.isFree(DateTime(2026, 9, 12)), isFalse); + // The 35 booked days make the page long; back to the status line. + await tester.drag(find.byType(ListView), const Offset(0, 3000)); + await tester.pumpAndSettle(); expect(find.textContaining('No free days left in 2026'), findsOneWidget); expect(syncs, 1); }); - testWidgets('the picker opens even when today is already free', ( - tester, - ) async { - await pool.mark(_today, now: _today); - await tester.pumpWidget(app()); - await tester.pumpAndSettle(); - - await tester.tap(find.text('Take a day')); - await tester.pumpAndSettle(); - // The cursor moved to the 12th; today is disabled, not asserted on. - expect(find.text('Take a free day'), findsOneWidget); - await tester.tap(find.text('OK')); - await tester.pumpAndSettle(); - await tester.tap(find.text('Mark free')); - await tester.pumpAndSettle(); - - expect(pool.isFree(DateTime(2026, 9, 12)), isTrue); - expect(find.text('33 of 35'), findsOneWidget); - }); - - testWidgets('cancelling the reason dialog marks nothing', (tester) async { - await tester.pumpWidget(app()); - await tester.pumpAndSettle(); - - await tester.tap(find.text('Take a day')); - await tester.pumpAndSettle(); - await tester.tap(find.text('12')); - await tester.tap(find.text('OK')); - await tester.pumpAndSettle(); - await tester.tap(find.text('Cancel')); - await tester.pumpAndSettle(); - - expect(pool.freeDays(), isEmpty); - expect(find.text('35 of 35'), findsOneWidget); - expect(syncs, 1); - }); - testWidgets('a pool that cannot open reports it', (tester) async { await tester.pumpWidget( MaterialApp(