mirror of
https://github.com/kuhyx/wake-alarm.git
synced 2026-11-10 19:27:02 +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: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/alarm_ramp.dart';
|
||||||
import 'package:wake_alarm_sync/services/free_day_pool.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.
|
/// 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);
|
/// Adapted from punchme's `FreeDayCalendar` (Monday-first grid, dumb cells);
|
||||||
@ -26,9 +29,14 @@ class DayCalendar extends StatelessWidget {
|
|||||||
this.keyPrefix = 'free-day',
|
this.keyPrefix = 'free-day',
|
||||||
this.markColor,
|
this.markColor,
|
||||||
this.onMarkColor,
|
this.onMarkColor,
|
||||||
|
this.hasBadge,
|
||||||
super.key,
|
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.
|
/// The month on display; only its year and month are read.
|
||||||
final DateTime month;
|
final DateTime month;
|
||||||
|
|
||||||
@ -150,6 +158,7 @@ class DayCalendar extends StatelessWidget {
|
|||||||
key: ValueKey<String>('$keyPrefix-${FreeDayPool.toIso(date)}'),
|
key: ValueKey<String>('$keyPrefix-${FreeDayPool.toIso(date)}'),
|
||||||
day: day,
|
day: day,
|
||||||
isMarked: isMarked(date),
|
isMarked: isMarked(date),
|
||||||
|
badge: hasBadge?.call(date) ?? false,
|
||||||
isWorkday: isWorkday(date),
|
isWorkday: isWorkday(date),
|
||||||
isPast: !open,
|
isPast: !open,
|
||||||
markColor: markColor ?? theme.colorScheme.primary,
|
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;
|
if (!mounted) return;
|
||||||
setState(() {
|
setState(() {
|
||||||
_scanningSlot = null;
|
_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.';
|
'tag, in a different room.';
|
||||||
});
|
});
|
||||||
return;
|
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:flutter/material.dart';
|
||||||
import 'package:wake_alarm_sync/screens/day_calendar.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/services/free_day_pool.dart';
|
||||||
import 'package:wake_alarm_sync/ui/theme.dart';
|
import 'package:wake_alarm_sync/ui/theme.dart';
|
||||||
|
|
||||||
@ -17,9 +18,13 @@ class SickDaysBody extends StatelessWidget {
|
|||||||
required this.cache,
|
required this.cache,
|
||||||
required this.refused,
|
required this.refused,
|
||||||
required this.calendar,
|
required this.calendar,
|
||||||
|
required this.keptOf,
|
||||||
super.key,
|
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.
|
/// The month grid, built by the screen, which owns the displayed month.
|
||||||
final DayCalendar calendar;
|
final DayCalendar calendar;
|
||||||
|
|
||||||
@ -51,14 +56,17 @@ class SickDaysBody extends StatelessWidget {
|
|||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
children: [
|
children: [
|
||||||
Text(
|
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.',
|
'later only. No limit and no reason needed.',
|
||||||
style: theme.textTheme.bodyMedium,
|
style: theme.textTheme.bodyMedium,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
Text(
|
Text(
|
||||||
'On a sick day the alarm stays silent, nothing locks, and work '
|
'On a sick day every locker left off stands down: no alarm, no '
|
||||||
'left undone is dropped rather than carried forward.',
|
'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),
|
style: theme.textTheme.bodySmall?.copyWith(color: muted),
|
||||||
),
|
),
|
||||||
if (status.isNotEmpty) ...[
|
if (status.isNotEmpty) ...[
|
||||||
@ -84,7 +92,14 @@ class SickDaysBody extends StatelessWidget {
|
|||||||
if (upcoming.isEmpty)
|
if (upcoming.isEmpty)
|
||||||
Text('None ahead.', style: TextStyle(color: muted)),
|
Text('None ahead.', style: TextStyle(color: muted)),
|
||||||
for (final day in upcoming)
|
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:flutter/material.dart';
|
||||||
import 'package:wake_alarm_sync/screens/day_calendar.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/screens/sick_days_body.dart';
|
||||||
import 'package:wake_alarm_sync/services/alarm_scheduler.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_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`).
|
/// 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
|
/// 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
|
/// name, icon and fill. Every day is normal until marked. Tapping today or
|
||||||
/// day sick, another clears it. Today and later only -- past days are
|
/// a later day opens [SickDaySheet]: one switch per locker, all off, and
|
||||||
/// inert. No cap, no reason, no confirmation.
|
/// "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
|
/// On a sick day every locker left off stands down; a kept one behaves as
|
||||||
/// free day is refused here (with the pool named), so the free-day pool is
|
/// on a normal day (the cell carries a dot). This phone does not ring only
|
||||||
/// synced on open as well.
|
/// 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 {
|
class SickDaysScreen extends StatefulWidget {
|
||||||
/// Creates the picker; the seams are injectable for tests.
|
/// Creates the picker; the seams are injectable for tests.
|
||||||
const SickDaysScreen({
|
const SickDaysScreen({
|
||||||
@ -109,11 +112,12 @@ class _SickDaysScreenState extends State<SickDaysScreen> {
|
|||||||
}
|
}
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
setState(() {
|
setState(() {
|
||||||
_cache = 'Alarm cache: ${switch (dates) {
|
_cache =
|
||||||
null => 'unreadable',
|
'Alarm cache: ${switch (dates) {
|
||||||
[] => 'none',
|
null => 'unreadable',
|
||||||
final d => d.join(', '),
|
[] => '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.
|
/// Opens the per-locker sheet for [day] and applies what it returns.
|
||||||
Future<void> _toggle(DateTime day) async {
|
Future<void> _onTapDay(DateTime day) async {
|
||||||
final pool = _pool;
|
final pool = _pool;
|
||||||
if (pool == null) return;
|
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 {
|
try {
|
||||||
if (pool.isSick(day)) {
|
wrote = await applySickSheet(pool, day, result, free: _free, now: _today);
|
||||||
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);
|
|
||||||
}
|
|
||||||
} on SickDayPolicyError catch (e) {
|
} on SickDayPolicyError catch (e) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_refused = true;
|
_refused = true;
|
||||||
@ -171,7 +180,7 @@ class _SickDaysScreenState extends State<SickDaysScreen> {
|
|||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await _sync();
|
if (wrote) await _sync();
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@ -202,6 +211,7 @@ class _SickDaysScreenState extends State<SickDaysScreen> {
|
|||||||
for (final day in pool.sickDays())
|
for (final day in pool.sickDays())
|
||||||
if (!day.isBefore(_today)) day,
|
if (!day.isBefore(_today)) day,
|
||||||
],
|
],
|
||||||
|
keptOf: pool.keptLockers,
|
||||||
status: _status,
|
status: _status,
|
||||||
cache: _cache,
|
cache: _cache,
|
||||||
refused: _refused,
|
refused: _refused,
|
||||||
@ -209,12 +219,13 @@ class _SickDaysScreenState extends State<SickDaysScreen> {
|
|||||||
month: _month,
|
month: _month,
|
||||||
today: _today,
|
today: _today,
|
||||||
isMarked: pool.isSick,
|
isMarked: pool.isSick,
|
||||||
|
hasBadge: (day) => pool.keptLockers(day).isNotEmpty,
|
||||||
busy: _busy,
|
busy: _busy,
|
||||||
todayTappable: true,
|
todayTappable: true,
|
||||||
keyPrefix: 'sick-day',
|
keyPrefix: 'sick-day',
|
||||||
markColor: marks.sick,
|
markColor: marks.sick,
|
||||||
onMarkColor: marks.onSick,
|
onMarkColor: marks.onSick,
|
||||||
onTapDay: (day) => unawaited(_toggle(day)),
|
onTapDay: (day) => unawaited(_onTapDay(day)),
|
||||||
onMonth: (month) => setState(() => _month = month),
|
onMonth: (month) => setState(() => _month = month),
|
||||||
// Back to January for this year's history; forward to the
|
// Back to January for this year's history; forward to the
|
||||||
// end of next year, the same horizon as the free days.
|
// 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();
|
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 {
|
Future<void> open(WidgetTester tester, {Widget? widget}) async {
|
||||||
phoneSize(tester);
|
phoneSize(tester);
|
||||||
await tester.pumpWidget(widget ?? app());
|
await tester.pumpWidget(widget ?? app());
|
||||||
@ -110,13 +121,13 @@ void main() {
|
|||||||
testWidgets('today can be marked sick and cleared again', (tester) async {
|
testWidgets('today can be marked sick and cleared again', (tester) async {
|
||||||
await open(tester);
|
await open(tester);
|
||||||
|
|
||||||
await tapDay(tester, _today);
|
await viaSheet(tester, _today, 'Mark sick');
|
||||||
expect(sick.isSick(_today), isTrue);
|
expect(sick.isSick(_today), isTrue);
|
||||||
expect(find.text('Fri 2026-10-16'), findsOneWidget);
|
expect(find.text('Fri 2026-10-16'), findsOneWidget);
|
||||||
expect(caches.last, ['2026-10-16'], reason: 'native cache rewritten');
|
expect(caches.last, ['2026-10-16'], reason: 'native cache rewritten');
|
||||||
expect(syncs, 2);
|
expect(syncs, 2);
|
||||||
|
|
||||||
await tapDay(tester, _today);
|
await viaSheet(tester, _today, 'Clear sick day');
|
||||||
expect(sick.isSick(_today), isFalse);
|
expect(sick.isSick(_today), isFalse);
|
||||||
expect(caches.last, isEmpty);
|
expect(caches.last, isEmpty);
|
||||||
expect(syncs, 3);
|
expect(syncs, 3);
|
||||||
@ -125,14 +136,12 @@ void main() {
|
|||||||
testWidgets('the native cache is read back after a mark', (tester) async {
|
testWidgets('the native cache is read back after a mark', (tester) async {
|
||||||
await open(tester);
|
await open(tester);
|
||||||
expect(find.text('Alarm cache: none'), findsOneWidget);
|
expect(find.text('Alarm cache: none'), findsOneWidget);
|
||||||
await tapDay(tester, DateTime(2026, 10, 18));
|
await viaSheet(tester, DateTime(2026, 10, 18), 'Mark sick');
|
||||||
await tapDay(tester, DateTime(2026, 10, 17));
|
await viaSheet(tester, DateTime(2026, 10, 17), 'Mark sick');
|
||||||
expect(find.text('Alarm cache: 2026-10-17, 2026-10-18'), findsOneWidget);
|
expect(find.text('Alarm cache: 2026-10-17, 2026-10-18'), findsOneWidget);
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('an unreadable native cache never reads as none', (
|
testWidgets('an unreadable native cache never reads as none', (tester) async {
|
||||||
tester,
|
|
||||||
) async {
|
|
||||||
cacheUnreadable = true;
|
cacheUnreadable = true;
|
||||||
await open(tester);
|
await open(tester);
|
||||||
expect(find.text('Alarm cache: unreadable'), findsOneWidget);
|
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 {
|
testWidgets('a future day is marked in the sick colour', (tester) async {
|
||||||
await open(tester);
|
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);
|
expect(sick.isSick(DateTime(2026, 10, 20)), isTrue);
|
||||||
final box = tester.widget<DayCell>(cell(DateTime(2026, 10, 20)));
|
final box = tester.widget<DayCell>(cell(DateTime(2026, 10, 20)));
|
||||||
expect(box.markColor, DayMarkColors.dark.sick);
|
expect(box.markColor, DayMarkColors.dark.sick);
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user