phone: per-locker sheet on the sick-day calendar

Tapping today or a later normal day opens a sheet with one switch per
locker (Workout lock, LeetCode, Book, Diet, Wake alarm), all off, and
"Mark sick"; a sick day reopens with its switches, "Save" (keep-only
edit, nothing written when unchanged) and "Clear sick day". Past days
stay inert. A sick day keeping any locker carries a dot in the calendar
and a "Kept: ..." line in the list. DayCell moves to day_cell.dart for
the 250-line cap.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PAhbNszs5JKdtnD4uBi3xp
This commit is contained in:
Krzysztof Rudnicki 2026-10-16 15:25:03 +02:00
parent 7fe6108df3
commit ee7e380278
9 changed files with 648 additions and 111 deletions

View File

@ -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<String>('$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,
),
),
),
);
}
}

View File

@ -0,0 +1,88 @@
import 'package:flutter/material.dart';
Widget _dot(Color color) => Container(
key: const ValueKey<String>('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)),
],
),
),
);
}
}

View File

@ -67,7 +67,8 @@ class _RoomProofScreenState extends State<RoomProofScreen> {
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;

View File

@ -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 = <String, String>{
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<String> 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<String> 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<bool> 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<SickSheetResult?> showSickDaySheet(
BuildContext context, {
required DateTime day,
required bool isSick,
required Set<String> kept,
}) => showModalBottomSheet<SickSheetResult>(
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<String> kept;
@override
State<SickDaySheet> createState() => _SickDaySheetState();
}
class _SickDaySheetState extends State<SickDaySheet> {
late final Set<String> _keep = {...widget.kept};
static const _weekdays = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
void _close(SickSheetAction action) => Navigator.of(
context,
).pop<SickSheetResult>((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<String>('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'),
),
],
),
),
);
}
}

View File

@ -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<String> 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'),
},
),
],
),
);

View File

@ -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,7 +112,8 @@ class _SickDaysScreenState extends State<SickDaysScreen> {
}
if (!mounted) return;
setState(() {
_cache = 'Alarm cache: ${switch (dates) {
_cache =
'Alarm cache: ${switch (dates) {
null => 'unreadable',
[] => 'none',
final d => d.join(', '),
@ -146,24 +150,29 @@ class _SickDaysScreenState extends State<SickDaysScreen> {
});
}
/// One tap per day: a sick day is cleared, any other is marked sick.
Future<void> _toggle(DateTime day) async {
/// Opens the per-locker sheet for [day] and applies what it returns.
Future<void> _onTapDay(DateTime day) async {
final pool = _pool;
if (pool == null) return;
final today = _today;
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)}.',
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),
);
}
await pool.add(day, isFree: free.isFree, now: today);
}
if (result == null || !mounted) return;
final bool wrote;
try {
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<SickDaysScreen> {
});
return;
}
await _sync();
if (wrote) await _sync();
}
@override
@ -202,6 +211,7 @@ class _SickDaysScreenState extends State<SickDaysScreen> {
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<SickDaysScreen> {
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.

View File

@ -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<SickDayPolicyError>().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<SickDayPolicyError>()),
);
});
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');
});
}

View File

@ -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<List<String>> 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<String>(),
);
}
if (call.method == 'getSickDays') {
return caches.isEmpty ? <String>[] : caches.last;
}
return null;
});
sick = await SickDayPool.open(
persistence: MemPersistence(),
deviceId: 'phone',
);
});
Future<void> 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<String>('sick-day-${FreeDayPool.toIso(day)}'));
Finder toggle(String locker) => find.byKey(ValueKey<String>('keep-$locker'));
bool isOn(WidgetTester tester, String locker) =>
tester.widget<SwitchListTile>(toggle(locker)).value;
Future<void> 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<DayCell>(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<DayCell>(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': (<Object?>['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<DayCell>(cell(DateTime(2026, 10, 15)));
expect(box.isPast && box.isMarked && box.badge, isTrue);
expect(syncs, 1);
});
}

View File

@ -91,6 +91,17 @@ void main() {
await tester.pumpAndSettle();
}
/// Taps [day], then [button] in the per-locker sheet it opens.
Future<void> viaSheet(
WidgetTester tester,
DateTime day,
String button,
) async {
await tapDay(tester, day);
await tester.tap(find.text(button));
await tester.pumpAndSettle();
}
Future<void> 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<DayCell>(cell(DateTime(2026, 10, 20)));
expect(box.markColor, DayMarkColors.dark.sick);