diff --git a/phone_app/lib/screens/day_calendar.dart b/phone_app/lib/screens/day_calendar.dart index 5d50cdb..59965dc 100644 --- a/phone_app/lib/screens/day_calendar.dart +++ b/phone_app/lib/screens/day_calendar.dart @@ -1,7 +1,10 @@ import 'package:flutter/material.dart'; +import 'package:wake_alarm_sync/screens/day_cell.dart'; import 'package:wake_alarm_sync/services/alarm_ramp.dart'; import 'package:wake_alarm_sync/services/free_day_pool.dart'; +export 'package:wake_alarm_sync/screens/day_cell.dart'; + /// A month grid of one day pool (free days or sick days), one tap per day. /// /// Adapted from punchme's `FreeDayCalendar` (Monday-first grid, dumb cells); @@ -26,9 +29,14 @@ class DayCalendar extends StatelessWidget { this.keyPrefix = 'free-day', this.markColor, this.onMarkColor, + this.hasBadge, super.key, }); + /// Whether a marked date carries a dot (a sick day keeping lockers); + /// null means never. + final bool Function(DateTime day)? hasBadge; + /// The month on display; only its year and month are read. final DateTime month; @@ -150,6 +158,7 @@ class DayCalendar extends StatelessWidget { key: ValueKey('$keyPrefix-${FreeDayPool.toIso(date)}'), day: day, isMarked: isMarked(date), + badge: hasBadge?.call(date) ?? false, isWorkday: isWorkday(date), isPast: !open, markColor: markColor ?? theme.colorScheme.primary, @@ -158,73 +167,3 @@ class DayCalendar extends StatelessWidget { ); } } - -/// One day box: renders the state it is handed and reports taps. -class DayCell extends StatelessWidget { - /// Creates a cell for [day] of the month. - const DayCell({ - required this.day, - required this.isMarked, - required this.isWorkday, - required this.isPast, - required this.markColor, - required this.onMarkColor, - required this.onTap, - super.key, - }); - - /// The day of the month, as shown in the box. - final int day; - - /// Whether this date is marked in the pool. - final bool isMarked; - - /// A marked day's fill. - final Color markColor; - - /// Text on [markColor]. - final Color onMarkColor; - - /// Whether this date is a weekly workday (Tue/Wed/Thu), shaded. - final bool isWorkday; - - /// Whether this date is read-only (past, or today on the free grid). - 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 = isMarked - ? markColor.withValues(alpha: fade) - : isWorkday - ? colors.surfaceContainerHigh - : null; - final foreground = isMarked - ? onMarkColor - : 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: isMarked ? FontWeight.w600 : FontWeight.normal, - ), - ), - ), - ); - } -} diff --git a/phone_app/lib/screens/day_cell.dart b/phone_app/lib/screens/day_cell.dart new file mode 100644 index 0000000..f35dda3 --- /dev/null +++ b/phone_app/lib/screens/day_cell.dart @@ -0,0 +1,88 @@ +import 'package:flutter/material.dart'; + +Widget _dot(Color color) => Container( + key: const ValueKey('day-badge'), + width: 6, + height: 6, + decoration: BoxDecoration(color: color, shape: BoxShape.circle), +); + +/// One day box: renders the state it is handed and reports taps. +class DayCell extends StatelessWidget { + /// Creates a cell for [day] of the month. + const DayCell({ + required this.day, + required this.isMarked, + required this.isWorkday, + required this.isPast, + required this.markColor, + required this.onMarkColor, + required this.onTap, + this.badge = false, + super.key, + }); + + /// Whether a small dot sits in the corner, in [onMarkColor]. + final bool badge; + + /// The day of the month, as shown in the box. + final int day; + + /// Whether this date is marked in the pool. + final bool isMarked; + + /// A marked day's fill. + final Color markColor; + + /// Text on [markColor]. + final Color onMarkColor; + + /// Whether this date is a weekly workday (Tue/Wed/Thu), shaded. + final bool isWorkday; + + /// Whether this date is read-only (past, or today on the free grid). + 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 = isMarked + ? markColor.withValues(alpha: fade) + : isWorkday + ? colors.surfaceContainerHigh + : null; + final foreground = isMarked + ? onMarkColor + : 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: Stack( + alignment: Alignment.center, + children: [ + Text( + '$day', + style: TextStyle( + color: foreground, + fontWeight: isMarked ? FontWeight.w600 : FontWeight.normal, + ), + ), + if (badge) Positioned(top: 4, right: 4, child: _dot(foreground)), + ], + ), + ), + ); + } +} diff --git a/phone_app/lib/screens/room_proof_screen.dart b/phone_app/lib/screens/room_proof_screen.dart index 951ebdc..27592ab 100644 --- a/phone_app/lib/screens/room_proof_screen.dart +++ b/phone_app/lib/screens/room_proof_screen.dart @@ -67,7 +67,8 @@ class _RoomProofScreenState extends State { if (!mounted) return; setState(() { _scanningSlot = null; - _status = 'That is already tag ${slot == 0 ? 2 : 1}. Use a different ' + _status = + 'That is already tag ${slot == 0 ? 2 : 1}. Use a different ' 'tag, in a different room.'; }); return; diff --git a/phone_app/lib/screens/sick_day_sheet.dart b/phone_app/lib/screens/sick_day_sheet.dart new file mode 100644 index 0000000..2b30096 --- /dev/null +++ b/phone_app/lib/screens/sick_day_sheet.dart @@ -0,0 +1,183 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:wake_alarm_sync/services/free_day_pool.dart'; +import 'package:wake_alarm_sync/services/sick_day_keep.dart'; +import 'package:wake_alarm_sync/services/sick_day_pool.dart'; + +/// What each locker is called on screen, in `sickdays.LOCKERS` order. +const sickLockerLabels = { + SickLocker.workout: 'Workout lock', + SickLocker.leetcode: 'LeetCode', + SickLocker.book: 'Book', + SickLocker.diet: 'Diet', + SickLocker.alarm: 'Wake alarm', +}; + +/// The kept lockers of a day as one line, in [sickLockerLabels] order. +String keptLabel(Set kept) => [ + for (final MapEntry(:key, :value) in sickLockerLabels.entries) + if (kept.contains(key)) value, +].join(', '); + +/// What the sheet was closed with. +enum SickSheetAction { + /// Mark a normal day sick, keeping the switched-on lockers. + mark, + + /// Replace a sick day's kept lockers (a keep-only edit). + save, + + /// Clear a sick day back to normal. + clear, +} + +/// The sheet's answer: an action and the switches as they stood. +typedef SickSheetResult = ({SickSheetAction action, Set keep}); + +/// Why [day] cannot be marked sick for the free-day pool, or null. +/// +/// An unopenable free-day pool ([free] null) refuses: "could not check" +/// must never count as "not a free day". +String? freeDayRefusal(FreeDayPool? free, DateTime day) { + if (free == null) { + return 'Free-day pool unavailable — cannot rule out a free day on ' + '${FreeDayPool.toIso(day)}.'; + } + if (!free.isFree(day)) return null; + return '${FreeDayPool.toIso(day)} is a free day — release it in Free ' + 'days first.'; +} + +/// Applies [result] to [day] in [pool]; true when anything was written. +/// +/// A Save that changes nothing writes nothing: a fresh tick on an unchanged +/// keep set would only churn the sync. A mark re-checks [free] through the +/// pool (null refuses). Throws what the pool throws. +Future applySickSheet( + SickDayPool pool, + DateTime day, + SickSheetResult result, { + required FreeDayPool? free, + required DateTime now, +}) async { + switch (result.action) { + case SickSheetAction.mark: + final refusal = freeDayRefusal(free, day); + if (refusal != null) throw SickDayPolicyError(refusal); + await pool.add(day, isFree: free!.isFree, now: now, keep: result.keep); + case SickSheetAction.save: + if (setEquals(result.keep, pool.keptLockers(day))) return false; + await pool.setKept(day, result.keep, now: now); + case SickSheetAction.clear: + await pool.remove(day, now: now); + } + return true; +} + +/// Opens [SickDaySheet] for [day]; null when dismissed. +Future showSickDaySheet( + BuildContext context, { + required DateTime day, + required bool isSick, + required Set kept, +}) => showModalBottomSheet( + context: context, + isScrollControlled: true, + showDragHandle: true, + builder: (_) => SickDaySheet(day: day, isSick: isSick, kept: kept), +); + +/// One switch per locker for one day: on = the locker still applies, +/// off = it stands down. A normal day opens with every switch off and a +/// "Mark sick" confirm; a sick day opens with its current switches, "Save" +/// (a keep-only edit) and "Clear sick day". +class SickDaySheet extends StatefulWidget { + /// Creates the sheet for [day]. + const SickDaySheet({ + required this.day, + required this.isSick, + required this.kept, + super.key, + }); + + /// The date being marked or edited. + final DateTime day; + + /// Whether [day] is already sick (edit) rather than normal (mark). + final bool isSick; + + /// The switches to start from. + final Set kept; + + @override + State createState() => _SickDaySheetState(); +} + +class _SickDaySheetState extends State { + late final Set _keep = {...widget.kept}; + + static const _weekdays = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']; + + void _close(SickSheetAction action) => Navigator.of( + context, + ).pop((action: action, keep: {..._keep})); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final day = widget.day; + return SafeArea( + child: SingleChildScrollView( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + '${widget.isSick ? 'Sick day' : 'Mark sick'}: ' + '${_weekdays[day.weekday - 1]} ${FreeDayPool.toIso(day)}', + style: theme.textTheme.titleMedium, + ), + const SizedBox(height: 8), + Text( + 'Off = stands down for the day. Switch on any locker that ' + 'should still apply; it then behaves exactly as on a normal ' + 'day.', + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 8), + for (final MapEntry(:key, :value) in sickLockerLabels.entries) + SwitchListTile( + key: ValueKey('keep-$key'), + contentPadding: EdgeInsets.zero, + title: Text(value), + value: _keep.contains(key), + onChanged: (on) => + setState(() => on ? _keep.add(key) : _keep.remove(key)), + ), + const SizedBox(height: 16), + if (widget.isSick) ...[ + FilledButton( + onPressed: () => _close(SickSheetAction.save), + child: const Text('Save'), + ), + const SizedBox(height: 8), + TextButton( + style: TextButton.styleFrom( + foregroundColor: theme.colorScheme.error, + ), + onPressed: () => _close(SickSheetAction.clear), + child: const Text('Clear sick day'), + ), + ] else + FilledButton( + onPressed: () => _close(SickSheetAction.mark), + child: const Text('Mark sick'), + ), + ], + ), + ), + ); + } +} diff --git a/phone_app/lib/screens/sick_days_body.dart b/phone_app/lib/screens/sick_days_body.dart index c76d6e6..25a0833 100644 --- a/phone_app/lib/screens/sick_days_body.dart +++ b/phone_app/lib/screens/sick_days_body.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:wake_alarm_sync/screens/day_calendar.dart'; +import 'package:wake_alarm_sync/screens/sick_day_sheet.dart'; import 'package:wake_alarm_sync/services/free_day_pool.dart'; import 'package:wake_alarm_sync/ui/theme.dart'; @@ -17,9 +18,13 @@ class SickDaysBody extends StatelessWidget { required this.cache, required this.refused, required this.calendar, + required this.keptOf, super.key, }); + /// The lockers a sick day keeps active, for its line in the list. + final Set Function(DateTime day) keptOf; + /// The month grid, built by the screen, which owns the displayed month. final DayCalendar calendar; @@ -51,14 +56,17 @@ class SickDaysBody extends StatelessWidget { padding: const EdgeInsets.all(16), children: [ Text( - 'Tap a day to mark it sick; tap it again to clear it. Today or ' + 'Tap a day to mark it sick and choose which lockers still ' + 'apply; tap a sick day to change them or clear it. Today or ' 'later only. No limit and no reason needed.', style: theme.textTheme.bodyMedium, ), const SizedBox(height: 8), Text( - 'On a sick day the alarm stays silent, nothing locks, and work ' - 'left undone is dropped rather than carried forward.', + 'On a sick day every locker left off stands down: no alarm, no ' + 'lock, and work left undone is dropped rather than carried ' + 'forward. A locker switched on behaves as on a normal day; a dot ' + 'marks a sick day that keeps one.', style: theme.textTheme.bodySmall?.copyWith(color: muted), ), if (status.isNotEmpty) ...[ @@ -84,7 +92,14 @@ class SickDaysBody extends StatelessWidget { if (upcoming.isEmpty) Text('None ahead.', style: TextStyle(color: muted)), for (final day in upcoming) - ListTile(contentPadding: EdgeInsets.zero, title: Text(_label(day))), + ListTile( + contentPadding: EdgeInsets.zero, + title: Text(_label(day)), + subtitle: switch (keptLabel(keptOf(day))) { + '' => null, + final kept => Text('Kept: $kept'), + }, + ), ], ), ); diff --git a/phone_app/lib/screens/sick_days_screen.dart b/phone_app/lib/screens/sick_days_screen.dart index d3c6592..7e2f639 100644 --- a/phone_app/lib/screens/sick_days_screen.dart +++ b/phone_app/lib/screens/sick_days_screen.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:wake_alarm_sync/screens/day_calendar.dart'; +import 'package:wake_alarm_sync/screens/sick_day_sheet.dart'; import 'package:wake_alarm_sync/screens/sick_days_body.dart'; import 'package:wake_alarm_sync/services/alarm_scheduler.dart'; import 'package:wake_alarm_sync/services/free_day_pool.dart'; @@ -13,13 +14,15 @@ import 'package:wake_alarm_sync/ui/theme.dart'; /// The phone's picker for the shared sick-day pool (`~/src/utils/sickdays`). /// /// Not the free-day pool, and drawn so it cannot be mistaken for it: its own -/// name, icon and fill. Every day is normal until tapped; one tap marks a -/// day sick, another clears it. Today and later only -- past days are -/// inert. No cap, no reason, no confirmation. +/// name, icon and fill. Every day is normal until marked. Tapping today or +/// a later day opens [SickDaySheet]: one switch per locker, all off, and +/// "Mark sick"; on a sick day, its current switches, "Save" (a keep-only +/// edit) and "Clear sick day". Past days are inert. No cap, no reason. /// -/// On a sick day every gate stands down and this phone does not ring. A -/// free day is refused here (with the pool named), so the free-day pool is -/// synced on open as well. +/// On a sick day every locker left off stands down; a kept one behaves as +/// on a normal day (the cell carries a dot). This phone does not ring only +/// when the alarm is not kept. A free day is refused before the sheet opens +/// (with the pool named), so the free-day pool is synced on open as well. class SickDaysScreen extends StatefulWidget { /// Creates the picker; the seams are injectable for tests. const SickDaysScreen({ @@ -109,11 +112,12 @@ class _SickDaysScreenState extends State { } if (!mounted) return; setState(() { - _cache = 'Alarm cache: ${switch (dates) { - null => 'unreadable', - [] => 'none', - final d => d.join(', '), - }}'; + _cache = + 'Alarm cache: ${switch (dates) { + null => 'unreadable', + [] => 'none', + final d => d.join(', '), + }}'; }); } @@ -146,24 +150,29 @@ class _SickDaysScreenState extends State { }); } - /// One tap per day: a sick day is cleared, any other is marked sick. - Future _toggle(DateTime day) async { + /// Opens the per-locker sheet for [day] and applies what it returns. + Future _onTapDay(DateTime day) async { final pool = _pool; if (pool == null) return; - final today = _today; + final sick = pool.isSick(day); + final refusal = sick ? null : freeDayRefusal(_free, day); + if (refusal != null) { + setState(() { + _refused = true; + _status = refusal; + }); + return; + } + final result = await showSickDaySheet( + context, + day: day, + isSick: sick, + kept: pool.keptLockers(day), + ); + if (result == null || !mounted) return; + final bool wrote; try { - if (pool.isSick(day)) { - await pool.remove(day, now: today); - } else { - final free = _free; - if (free == null) { - throw SickDayPolicyError( - 'Free-day pool unavailable — cannot rule out a free day on ' - '${FreeDayPool.toIso(day)}.', - ); - } - await pool.add(day, isFree: free.isFree, now: today); - } + wrote = await applySickSheet(pool, day, result, free: _free, now: _today); } on SickDayPolicyError catch (e) { setState(() { _refused = true; @@ -171,7 +180,7 @@ class _SickDaysScreenState extends State { }); return; } - await _sync(); + if (wrote) await _sync(); } @override @@ -202,6 +211,7 @@ class _SickDaysScreenState extends State { for (final day in pool.sickDays()) if (!day.isBefore(_today)) day, ], + keptOf: pool.keptLockers, status: _status, cache: _cache, refused: _refused, @@ -209,12 +219,13 @@ class _SickDaysScreenState extends State { month: _month, today: _today, isMarked: pool.isSick, + hasBadge: (day) => pool.keptLockers(day).isNotEmpty, busy: _busy, todayTappable: true, keyPrefix: 'sick-day', markColor: marks.sick, onMarkColor: marks.onSick, - onTapDay: (day) => unawaited(_toggle(day)), + onTapDay: (day) => unawaited(_onTapDay(day)), onMonth: (month) => setState(() => _month = month), // Back to January for this year's history; forward to the // end of next year, the same horizon as the free days. diff --git a/phone_app/test/sick_day_apply_test.dart b/phone_app/test/sick_day_apply_test.dart new file mode 100644 index 0000000..7f1686b --- /dev/null +++ b/phone_app/test/sick_day_apply_test.dart @@ -0,0 +1,54 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:wake_alarm_sync/screens/sick_day_sheet.dart'; +import 'package:wake_alarm_sync/services/free_day_pool.dart'; +import 'package:wake_alarm_sync/services/sick_day_pool.dart'; + +import 'fake_day_pool_store.dart'; + +/// `applySickSheet` on its own: the free-day check holds even when the +/// screen's own pre-check is bypassed. +final _today = DateTime(2026, 10, 16); +final _day = DateTime(2026, 10, 20); +const SickSheetResult _mark = (action: SickSheetAction.mark, keep: {'diet'}); + +void main() { + late SickDayPool sick; + late FreeDayPool free; + + setUp(() async { + sick = await SickDayPool.open(persistence: MemPersistence(), deviceId: 'p'); + free = await FreeDayPool.open(persistence: MemPersistence(), deviceId: 'p'); + }); + + test('a mark with no free-day pool is refused', () async { + await expectLater( + applySickSheet(sick, _day, _mark, free: null, now: _today), + throwsA( + isA().having( + (e) => e.message, + 'message', + contains('Free-day pool unavailable'), + ), + ), + ); + expect(sick.isSick(_day), isFalse); + }); + + test('a mark on a free day is refused, naming the pool', () async { + await free.mark(_day, now: _today); + expect(freeDayRefusal(free, _day), contains('is a free day')); + await expectLater( + applySickSheet(sick, _day, _mark, free: free, now: _today), + throwsA(isA()), + ); + }); + + test('a mark keeps exactly the switched-on lockers', () async { + expect( + await applySickSheet(sick, _day, _mark, free: free, now: _today), + isTrue, + ); + expect(sick.keptLockers(_day), {'diet'}); + expect(keptLabel(sick.keptLockers(_day)), 'Diet'); + }); +} diff --git a/phone_app/test/sick_day_sheet_test.dart b/phone_app/test/sick_day_sheet_test.dart new file mode 100644 index 0000000..bcaff96 --- /dev/null +++ b/phone_app/test/sick_day_sheet_test.dart @@ -0,0 +1,237 @@ +import 'package:crdt_sync/crdt_sync.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:wake_alarm_sync/screens/day_calendar.dart'; +import 'package:wake_alarm_sync/screens/sick_days_screen.dart'; +import 'package:wake_alarm_sync/services/alarm_scheduler.dart'; +import 'package:wake_alarm_sync/services/free_day_pool.dart'; +import 'package:wake_alarm_sync/services/free_day_sync.dart'; +import 'package:wake_alarm_sync/services/sick_day_keep.dart'; +import 'package:wake_alarm_sync/services/sick_day_pool.dart'; +import 'package:wake_alarm_sync/services/sick_day_sync.dart'; +import 'package:wake_alarm_sync/ui/theme.dart'; + +import 'fake_day_pool_store.dart'; + +/// The per-locker sheet on the sick-day calendar (sickdays 0.2.0 keep). +final _today = DateTime(2026, 10, 16); +final _day = DateTime(2026, 10, 20); +bool _never(DateTime _) => false; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + late SickDayPool sick; + late int syncs; + late List> caches; + + setUp(() async { + caches = []; + syncs = 0; + const channel = MethodChannel(AlarmScheduler.channelName); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + if (call.method == 'setSickDays') { + caches.add( + ((call.arguments as Map)['dates'] as List).cast(), + ); + } + if (call.method == 'getSickDays') { + return caches.isEmpty ? [] : caches.last; + } + return null; + }); + sick = await SickDayPool.open( + persistence: MemPersistence(), + deviceId: 'phone', + ); + }); + + Future open(WidgetTester tester) async { + tester.view + ..physicalSize = const Size(1080, 2400) + ..devicePixelRatio = 2.625; + addTearDown(tester.view.reset); + final scheduler = AlarmScheduler( + channel: const MethodChannel(AlarmScheduler.channelName), + ); + final free = await FreeDayPool.open( + persistence: MemPersistence(), + deviceId: 'phone', + ); + await tester.pumpWidget( + MaterialApp( + theme: buildAppTheme(), + home: SickDaysScreen( + openPool: () async => sick, + syncer: SickDaySync( + openClient: () async { + syncs++; + return null; + }, + scheduler: scheduler, + ), + openFreePool: () async => free, + freeSyncer: FreeDaySync( + openClient: () async => null, + scheduler: scheduler, + ), + now: () => _today, + scheduler: scheduler, + ), + ), + ); + await tester.pumpAndSettle(); + } + + Finder cell(DateTime day) => + find.byKey(ValueKey('sick-day-${FreeDayPool.toIso(day)}')); + Finder toggle(String locker) => find.byKey(ValueKey('keep-$locker')); + bool isOn(WidgetTester tester, String locker) => + tester.widget(toggle(locker)).value; + + Future tap(WidgetTester tester, Finder finder) async { + await tester.ensureVisible(finder); + await tester.pumpAndSettle(); + await tester.tap(finder); + await tester.pumpAndSettle(); + } + + testWidgets('a normal day opens with every locker off', (tester) async { + await open(tester); + await tap(tester, cell(_day)); + + expect(find.text('Mark sick: Tue 2026-10-20'), findsOneWidget); + for (final locker in SickLocker.all) { + expect(isOn(tester, locker), isFalse, reason: locker); + } + for (final label in [ + 'Workout lock', + 'LeetCode', + 'Book', + 'Diet', + 'Wake alarm', + ]) { + expect(find.text(label), findsOneWidget); + } + expect(find.text('Clear sick day'), findsNothing); + expect(sick.isSick(_day), isFalse, reason: 'nothing until confirmed'); + + await tap(tester, find.text('Mark sick')); + expect(sick.isSick(_day), isTrue); + expect(sick.keptLockers(_day), isEmpty); + expect(caches.last, ['2026-10-20'], reason: 'the alarm stands down'); + expect(syncs, 2); + }); + + testWidgets('switched-on lockers are kept; a kept alarm still rings', ( + tester, + ) async { + await open(tester); + await tap(tester, cell(_day)); + await tap(tester, toggle(SickLocker.alarm)); + await tap(tester, toggle(SickLocker.leetcode)); + await tap(tester, find.text('Mark sick')); + + expect(sick.keptLockers(_day), {SickLocker.alarm, SickLocker.leetcode}); + expect(caches.last, isEmpty); + expect(find.text('Alarm cache: none'), findsOneWidget); + final box = tester.widget(cell(_day)); + expect(box.isMarked, isTrue, reason: 'still a sick day'); + expect(box.badge, isTrue, reason: 'kept lockers are badged'); + expect(find.text('Kept: LeetCode, Wake alarm'), findsOneWidget); + }); + + testWidgets('a sick day without kept lockers has no badge', (tester) async { + await sick.add(_day, isFree: _never, now: _today); + await open(tester); + expect(tester.widget(cell(_day)).badge, isFalse); + }); + + testWidgets('a sick day reopens showing its switches', (tester) async { + await sick.add(_day, isFree: _never, now: _today, keep: ['diet']); + await open(tester); + await tap(tester, cell(_day)); + + expect(find.text('Sick day: Tue 2026-10-20'), findsOneWidget); + expect(isOn(tester, SickLocker.diet), isTrue); + expect(isOn(tester, SickLocker.alarm), isFalse); + expect(find.text('Save'), findsOneWidget); + expect(find.text('Clear sick day'), findsOneWidget); + expect(find.text('Mark sick'), findsNothing); + }); + + testWidgets('Save edits keep only, never state', (tester) async { + await sick.add(_day, isFree: _never, now: _today); + final state = sick.snapshot()['2026-10-20']!.fields['state']!; + await open(tester); + await tap(tester, cell(_day)); + await tap(tester, toggle(SickLocker.alarm)); + await tap(tester, find.text('Save')); + + final fields = sick.snapshot()['2026-10-20']!.fields; + expect(fields['state'], state, reason: 'value and HLC untouched'); + expect(fields['keep']!.$1, ['alarm']); + expect(fields['keep']!.$2 > state.$2, isTrue); + expect(caches.last, isEmpty, reason: 'the alarm now rings'); + expect(syncs, 2); + }); + + testWidgets('Save with nothing changed writes nothing', (tester) async { + await sick.add(_day, isFree: _never, now: _today, keep: ['book']); + final before = sick.snapshot()['2026-10-20']; + await open(tester); + await tap(tester, cell(_day)); + await tap(tester, find.text('Save')); + expect(sick.snapshot()['2026-10-20'], before); + expect(syncs, 1); + }); + + testWidgets('Clear sick day returns the day to normal', (tester) async { + await sick.add(_day, isFree: _never, now: _today, keep: ['alarm']); + await open(tester); + await tap(tester, cell(_day)); + await tap(tester, find.text('Clear sick day')); + + expect(sick.isSick(_day), isFalse); + expect(sick.snapshot()['2026-10-20']!.fields['keep']!.$1, isEmpty); + expect(find.text('None ahead.'), findsOneWidget); + expect(syncs, 2); + }); + + testWidgets('dismissing the sheet writes nothing', (tester) async { + await open(tester); + await tap(tester, cell(_day)); + await tap(tester, toggle(SickLocker.workout)); + await tester.tapAt(const Offset(200, 40)); + await tester.pumpAndSettle(); + + expect(find.text('Mark sick'), findsNothing, reason: 'sheet closed'); + expect(sick.isSick(_day), isFalse); + expect(syncs, 1); + }); + + testWidgets('a past sick day is read-only: no sheet, no edit', ( + tester, + ) async { + final hlc = Hlc.newTick('pc'); + await sick.replaceAll({ + '2026-10-15': Record( + id: '2026-10-15', + fields: { + 'state': ('sick', hlc), + 'keep': (['alarm'], hlc), + }, + ), + }); + await open(tester); + await tap(tester, cell(DateTime(2026, 10, 15))); + + expect(find.byType(SwitchListTile), findsNothing); + expect(sick.keptLockers(DateTime(2026, 10, 15)), {'alarm'}); + final box = tester.widget(cell(DateTime(2026, 10, 15))); + expect(box.isPast && box.isMarked && box.badge, isTrue); + expect(syncs, 1); + }); +} diff --git a/phone_app/test/sick_days_screen_test.dart b/phone_app/test/sick_days_screen_test.dart index 6fb1aa4..7065765 100644 --- a/phone_app/test/sick_days_screen_test.dart +++ b/phone_app/test/sick_days_screen_test.dart @@ -91,6 +91,17 @@ void main() { await tester.pumpAndSettle(); } + /// Taps [day], then [button] in the per-locker sheet it opens. + Future viaSheet( + WidgetTester tester, + DateTime day, + String button, + ) async { + await tapDay(tester, day); + await tester.tap(find.text(button)); + await tester.pumpAndSettle(); + } + Future open(WidgetTester tester, {Widget? widget}) async { phoneSize(tester); await tester.pumpWidget(widget ?? app()); @@ -110,13 +121,13 @@ void main() { testWidgets('today can be marked sick and cleared again', (tester) async { await open(tester); - await tapDay(tester, _today); + await viaSheet(tester, _today, 'Mark sick'); expect(sick.isSick(_today), isTrue); expect(find.text('Fri 2026-10-16'), findsOneWidget); expect(caches.last, ['2026-10-16'], reason: 'native cache rewritten'); expect(syncs, 2); - await tapDay(tester, _today); + await viaSheet(tester, _today, 'Clear sick day'); expect(sick.isSick(_today), isFalse); expect(caches.last, isEmpty); expect(syncs, 3); @@ -125,14 +136,12 @@ void main() { testWidgets('the native cache is read back after a mark', (tester) async { await open(tester); expect(find.text('Alarm cache: none'), findsOneWidget); - await tapDay(tester, DateTime(2026, 10, 18)); - await tapDay(tester, DateTime(2026, 10, 17)); + await viaSheet(tester, DateTime(2026, 10, 18), 'Mark sick'); + await viaSheet(tester, DateTime(2026, 10, 17), 'Mark sick'); expect(find.text('Alarm cache: 2026-10-17, 2026-10-18'), findsOneWidget); }); - testWidgets('an unreadable native cache never reads as none', ( - tester, - ) async { + testWidgets('an unreadable native cache never reads as none', (tester) async { cacheUnreadable = true; await open(tester); expect(find.text('Alarm cache: unreadable'), findsOneWidget); @@ -140,7 +149,7 @@ void main() { testWidgets('a future day is marked in the sick colour', (tester) async { await open(tester); - await tapDay(tester, DateTime(2026, 10, 20)); + await viaSheet(tester, DateTime(2026, 10, 20), 'Mark sick'); expect(sick.isSick(DateTime(2026, 10, 20)), isTrue); final box = tester.widget(cell(DateTime(2026, 10, 20))); expect(box.markColor, DayMarkColors.dark.sick);