mirror of
https://github.com/kuhyx/wake-alarm.git
synced 2026-11-10 18:07:05 +01:00
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:
parent
7fe6108df3
commit
ee7e380278
@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
88
phone_app/lib/screens/day_cell.dart
Normal file
88
phone_app/lib/screens/day_cell.dart
Normal 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)),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
|
||||
183
phone_app/lib/screens/sick_day_sheet.dart
Normal file
183
phone_app/lib/screens/sick_day_sheet.dart
Normal 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'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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'),
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
@ -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<SickDaysScreen> {
|
||||
}
|
||||
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<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;
|
||||
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<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.
|
||||
|
||||
54
phone_app/test/sick_day_apply_test.dart
Normal file
54
phone_app/test/sick_day_apply_test.dart
Normal 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');
|
||||
});
|
||||
}
|
||||
237
phone_app/test/sick_day_sheet_test.dart
Normal file
237
phone_app/test/sick_day_sheet_test.dart
Normal 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);
|
||||
});
|
||||
}
|
||||
@ -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);
|
||||
|
||||
Loading…
Reference in New Issue
Block a user