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