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:
Krzysztof Rudnicki 2026-09-27 20:53:46 +02:00
parent 7f7fd916f7
commit 19f23f35a6
5 changed files with 355 additions and 120 deletions

View File

@ -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

View 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,
),
),
),
);
}
}

View File

@ -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)

View File

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

View File

@ -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(