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: <dates>" / "none" / "unreadable".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Xfj1sxKH9VBPqpcAni8Jry
This commit is contained in:
Krzysztof Rudnicki 2026-10-16 14:38:25 +02:00
parent 29e64fb7fa
commit 4f149e67a4
6 changed files with 87 additions and 0 deletions

View File

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

View File

@ -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<String>? {
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. */

View File

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

View File

@ -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<SickDaysScreen> createState() => _SickDaysScreenState();
}
@ -59,6 +64,9 @@ class _SickDaysScreenState extends State<SickDaysScreen> {
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<SickDaysScreen> {
});
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<void> _readCache() async {
List<String>? 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<void> _loadFree() async {
@ -108,6 +138,7 @@ class _SickDaysScreenState extends State<SickDaysScreen> {
_status = 'Syncing…';
});
final message = await _syncer.sync(pool);
await _readCache();
if (!mounted) return;
setState(() {
_busy = false;
@ -172,6 +203,7 @@ class _SickDaysScreenState extends State<SickDaysScreen> {
if (!day.isBefore(_today)) day,
],
status: _status,
cache: _cache,
refused: _refused,
calendar: DayCalendar(
month: _month,

View File

@ -137,6 +137,11 @@ class AlarmScheduler {
Future<void> setSickDays(List<String> isoDates) => _channel
.invokeMethod<void>('setSickDays', <String, Object>{'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<List<String>?> getSickDays() =>
_channel.invokeListMethod<String>('getSickDays');
/// Starts the ring immediately, without waiting for the alarm instant.
///
/// Exists so the foreground-service and full-screen-intent path can be

View File

@ -24,9 +24,11 @@ void main() {
late FreeDayPool free;
late int syncs;
late List<List<String>> 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<String>(),
);
}
if (call.method == 'getSickDays' && !cacheUnreadable) {
return caches.isEmpty ? <String>[] : 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));