From 4f149e67a48c3e864cf1efd69e7690e6af6ded62 Mon Sep 17 00:00:00 2001 From: Krzysztof kuhy Rudnicki Date: Fri, 16 Oct 2026 14:38:25 +0200 Subject: [PATCH] phone: read the native sick-day cache back on the Sick days screen A release build gives no way to see the SharedPreferences cache that AlarmReceiver reads at ring time (no run-as, no log of its contents), so "the Dart UI shows it" could not prove "the alarm will stay silent". - SickDays.store/dates log the sorted set under WakeAlarmSickDays. - AlarmChannel getSickDays returns the cached set (null if unreadable). - The Sick days screen reads it on open (before the sync rewrites it) and after every sync: "Alarm cache: " / "none" / "unreadable". Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Xfj1sxKH9VBPqpcAni8Jry --- .../com/kuhy/wake_alarm_sync/AlarmChannel.kt | 2 ++ .../com/kuhy/wake_alarm_sync/SickDays.kt | 18 +++++++++++ phone_app/lib/screens/sick_days_body.dart | 8 +++++ phone_app/lib/screens/sick_days_screen.dart | 32 +++++++++++++++++++ phone_app/lib/services/alarm_scheduler.dart | 5 +++ phone_app/test/sick_days_screen_test.dart | 22 +++++++++++++ 6 files changed, 87 insertions(+) diff --git a/phone_app/android/app/src/main/kotlin/com/kuhy/wake_alarm_sync/AlarmChannel.kt b/phone_app/android/app/src/main/kotlin/com/kuhy/wake_alarm_sync/AlarmChannel.kt index dbd7295..3aa04e6 100644 --- a/phone_app/android/app/src/main/kotlin/com/kuhy/wake_alarm_sync/AlarmChannel.kt +++ b/phone_app/android/app/src/main/kotlin/com/kuhy/wake_alarm_sync/AlarmChannel.kt @@ -83,6 +83,8 @@ object AlarmChannel { result.success(null) } } + // Read-back for the picker: what AlarmReceiver will see. + "getSickDays" -> result.success(SickDays.dates(context)) "hasNfc" -> result.success(NfcReader.isAvailable(context)) // Raw observations only; home_presence.dart decides what they mean. // Lives here rather than in MainActivity's override (as NfcReader diff --git a/phone_app/android/app/src/main/kotlin/com/kuhy/wake_alarm_sync/SickDays.kt b/phone_app/android/app/src/main/kotlin/com/kuhy/wake_alarm_sync/SickDays.kt index 5801783..3fca392 100644 --- a/phone_app/android/app/src/main/kotlin/com/kuhy/wake_alarm_sync/SickDays.kt +++ b/phone_app/android/app/src/main/kotlin/com/kuhy/wake_alarm_sync/SickDays.kt @@ -28,6 +28,24 @@ object SickDays { context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit() .putStringSet(KEY_DATES, isoDates.toSet()) .apply() + Log.i(TAG, "native cache stored: ${isoDates.sorted()}") + } + + /** + * The cached dates, sorted, for the picker's read-back line (the only + * way to see this cache on a release build); null when unreadable, + * which the ring path treats as "not a sick day". + */ + fun dates(context: Context): List? { + val dates = try { + context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) + .getStringSet(KEY_DATES, emptySet())?.sorted() + } catch (e: RuntimeException) { + Log.w(TAG, "sick-day cache unreadable", e) + null + } + Log.i(TAG, "native cache read: $dates") + return dates } /** Whether today (local calendar day) is in the cached set. */ diff --git a/phone_app/lib/screens/sick_days_body.dart b/phone_app/lib/screens/sick_days_body.dart index 94d3dff..c76d6e6 100644 --- a/phone_app/lib/screens/sick_days_body.dart +++ b/phone_app/lib/screens/sick_days_body.dart @@ -14,6 +14,7 @@ class SickDaysBody extends StatelessWidget { const SickDaysBody({ required this.upcoming, required this.status, + required this.cache, required this.refused, required this.calendar, super.key, @@ -28,6 +29,9 @@ class SickDaysBody extends StatelessWidget { /// The status line; may be a raw `Error: …`. final String status; + /// The native ring-path cache, read back (`Alarm cache: …`). + final String cache; + /// Whether [status] is a refused tap, drawn like an error. final bool refused; @@ -68,6 +72,10 @@ class SickDaysBody extends StatelessWidget { ), ), ], + if (cache.isNotEmpty) ...[ + const SizedBox(height: 8), + Text(cache, style: TextStyle(color: muted)), + ], const SizedBox(height: 16), calendar, const SizedBox(height: 24), diff --git a/phone_app/lib/screens/sick_days_screen.dart b/phone_app/lib/screens/sick_days_screen.dart index d22a475..d3c6592 100644 --- a/phone_app/lib/screens/sick_days_screen.dart +++ b/phone_app/lib/screens/sick_days_screen.dart @@ -3,6 +3,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_days_body.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_pool.dart'; @@ -27,6 +28,7 @@ class SickDaysScreen extends StatefulWidget { this.openFreePool, this.freeSyncer, this.now, + this.scheduler, super.key, }); @@ -45,6 +47,9 @@ class SickDaysScreen extends StatefulWidget { /// The reference date, for tests. final DateTime Function()? now; + /// Reads the native cache back; defaults to the real channel. + final AlarmScheduler? scheduler; + @override State createState() => _SickDaysScreenState(); } @@ -59,6 +64,9 @@ class _SickDaysScreenState extends State { String _status = ''; bool _refused = false; + /// What the native ring-path cache holds, read back over the channel. + String _cache = ''; + late DateTime _month = DateTime(_today.year, _today.month); late final SickDaySync _syncer = widget.syncer ?? SickDaySync(); @@ -83,10 +91,32 @@ class _SickDaysScreenState extends State { }); return; } + // Read before syncing too: the sync rewrites the cache, so only this + // read shows what earlier marks left there. + await _readCache(); await _loadFree(); await _sync(); } + /// Shows the native cache `AlarmReceiver` reads, not the Dart pool. + /// Unreadable is never shown as "none": the ring treats it as no sick day. + Future _readCache() async { + List? dates; + try { + dates = await (widget.scheduler ?? AlarmScheduler()).getSickDays(); + } on Object { + dates = null; + } + if (!mounted) return; + setState(() { + _cache = 'Alarm cache: ${switch (dates) { + null => 'unreadable', + [] => 'none', + final d => d.join(', '), + }}'; + }); + } + /// Opens and syncs the free-day pool, so a free day booked on the PC is /// refused here too. Its status is not shown on this screen. Future _loadFree() async { @@ -108,6 +138,7 @@ class _SickDaysScreenState extends State { _status = 'Syncing…'; }); final message = await _syncer.sync(pool); + await _readCache(); if (!mounted) return; setState(() { _busy = false; @@ -172,6 +203,7 @@ class _SickDaysScreenState extends State { if (!day.isBefore(_today)) day, ], status: _status, + cache: _cache, refused: _refused, calendar: DayCalendar( month: _month, diff --git a/phone_app/lib/services/alarm_scheduler.dart b/phone_app/lib/services/alarm_scheduler.dart index 6ff78f1..42efb06 100644 --- a/phone_app/lib/services/alarm_scheduler.dart +++ b/phone_app/lib/services/alarm_scheduler.dart @@ -137,6 +137,11 @@ class AlarmScheduler { Future setSickDays(List isoDates) => _channel .invokeMethod('setSickDays', {'dates': isoDates}); + /// Reads the native sick-day cache back, sorted: what `AlarmReceiver` + /// will read at ring time. Null when the native side cannot read it. + Future?> getSickDays() => + _channel.invokeListMethod('getSickDays'); + /// Starts the ring immediately, without waiting for the alarm instant. /// /// Exists so the foreground-service and full-screen-intent path can be diff --git a/phone_app/test/sick_days_screen_test.dart b/phone_app/test/sick_days_screen_test.dart index d5c2bb7..6fb1aa4 100644 --- a/phone_app/test/sick_days_screen_test.dart +++ b/phone_app/test/sick_days_screen_test.dart @@ -24,9 +24,11 @@ void main() { late FreeDayPool free; late int syncs; late List> caches; + late bool cacheUnreadable; setUp(() async { caches = []; + cacheUnreadable = false; const channel = MethodChannel(AlarmScheduler.channelName); TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger .setMockMethodCallHandler(channel, (call) async { @@ -35,6 +37,9 @@ void main() { ((call.arguments as Map)['dates'] as List).cast(), ); } + if (call.method == 'getSickDays' && !cacheUnreadable) { + return caches.isEmpty ? [] : caches.last; + } return null; }); scheduler = AlarmScheduler(channel: channel); @@ -73,6 +78,7 @@ void main() { scheduler: scheduler, ), now: () => _today, + scheduler: scheduler, ), ); @@ -116,6 +122,22 @@ void main() { expect(syncs, 3); }); + 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)); + expect(find.text('Alarm cache: 2026-10-17, 2026-10-18'), findsOneWidget); + }); + + testWidgets('an unreadable native cache never reads as none', ( + tester, + ) async { + cacheUnreadable = true; + await open(tester); + expect(find.text('Alarm cache: unreadable'), findsOneWidget); + }); + testWidgets('a future day is marked in the sick colour', (tester) async { await open(tester); await tapDay(tester, DateTime(2026, 10, 20));