diff --git a/AGENTS.md b/AGENTS.md index 09c93db..a5be809 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -71,7 +71,8 @@ clock, and everything that is a hard crash or a silent no-ring: **Free days.** On a day in the shared pool (`~/src/utils/freedays`) the phone does not ring at all: `AlarmReceiver` checks a native cache Dart rewrites after every mark/release/sync. Picker, cache and its fail-closed contract: -**`DOCS-free-days.md`**. +**`DOCS-free-days.md`**. Sick days (separate pool, same silence, pre-alarm +sync of PC marks): **`DOCS-sick-days.md`**. ## Scheduling — a per-minute timer diff --git a/DOCS-free-days.md b/DOCS-free-days.md index 8a0ca54..5a64418 100644 --- a/DOCS-free-days.md +++ b/DOCS-free-days.md @@ -11,7 +11,7 @@ event — the phone stays silent — rather than an absent dialog. | | | |---|---| -| Picker | `phone_app/lib/screens/free_days_screen.dart` (+ `free_days_body.dart`, `free_day_calendar.dart`), the calendar icon on the home screen | +| Picker | `phone_app/lib/screens/free_days_screen.dart` (+ `free_days_body.dart`, the shared `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//free_days.json` | | Ring path | `android/.../FreeDays.kt` + the check at the top of `AlarmReceiver.onReceive` | @@ -47,11 +47,14 @@ date → service start; a garbage XML file → service start. - **Marked on the phone:** immediate. The cache is rewritten before the sync even runs, so an offline phone still stands down on a day it marked itself. -- **Marked on the PC (`freedays mark …`):** on the phone's next sync, which - happens at app launch and whenever the picker opens. The ring path takes - no background dependency by design (see `DOCS-phone-alarm.md`), so **a day - marked on the PC overnight can miss the morning ring.** Mark on the phone, - or open the app once after marking on the PC. +- **Marked on the PC (`freedays mark …`):** on the phone's next sync: app + launch, either day picker opening, or the **pre-alarm sync** (21:00 and + A − 30 min, app closed; `DOCS-sick-days.md`). That sync needs network at + its slot; if it fails the cache keeps its last sync and the phone rings. + +A date is never both free and sick: this picker syncs the sick-day pool on +open and refuses a sick date (`… is a sick day — clear it in Sick days +first.`), and refuses every booking if that pool cannot be read. ## Cross-language check diff --git a/DOCS-phone-alarm.md b/DOCS-phone-alarm.md index 9333b0b..b359122 100644 --- a/DOCS-phone-alarm.md +++ b/DOCS-phone-alarm.md @@ -14,6 +14,7 @@ the ring/dismiss path in `phone_app/lib/`. `AlarmService` (foreground service: wakelock, sound, vibration, full-screen intent) · `AlarmActivity` (dismiss screen over the lockscreen) · `BootReceiver` (re-arm after reboot) · `StepCounter` (item 3 fallback). +Free/sick days and the pre-alarm pool sync: `DOCS-sick-days.md`. **Never replace any of this with a pub plugin.** The stack is AGP 9.0.1 / Gradle 9.1 / Kotlin 2.3.20; a plugin that will not compile against AGP 9 breaks diff --git a/DOCS-sick-days.md b/DOCS-sick-days.md new file mode 100644 index 0000000..429435f --- /dev/null +++ b/DOCS-sick-days.md @@ -0,0 +1,105 @@ +# Sick days — the phone picker, the silent ring, the pre-alarm sync + +The pool itself is `~/src/utils/sickdays` (Python): every day is normal until +it is marked sick, today or later only, no cap, no reason, never also a free +day. Read its README for the rules and the RTDB wire format. This file covers +the phone half. + +On a sick day the phone does not ring, the stick does not judge the day, and +nothing ring-related is recorded. It is **not** a free day: separate pool, +separate RTDB node (`sickdays-sync/devices`), separate native cache, separate +screen, and every log line and event says `sick_day`. + +## Where things are + +| | | +|---|---| +| Picker | `phone_app/lib/screens/sick_days_screen.dart` (+ `sick_days_body.dart`, the shared `day_calendar.dart`); the sick-face icon on the home screen | +| Rules, Dart port | `phone_app/lib/services/sick_day_pool.dart`: records `{state: sick/cleared, actor}`, one HLC per write, never a tombstone | +| Sync | `phone_app/lib/services/sick_day_sync.dart` (shared tick: `day_pool_sync.dart`), Firebase only | +| Ring path | `android/.../SickDays.kt` (+ `DayCache.kt`), read in `AlarmReceiver` through `RingDecision.kt` | +| Stick | `StickCheck.kt`: verdict `sick_day`; the streak skips sick days like free days | +| Pre-alarm sync | `PoolSyncAlarm.kt`, `PoolSyncSchedule.kt`, `PoolSyncEngine.kt` → `poolSyncMain` → `day_pool_refresh.dart` | + +## The picker + +Same Monday-first grid as the free days, in `cat-2` blue (`DayMarkColors` +in `ui/theme.dart`) rather than the accent gold, under its own title and +icon. One tap marks a day sick, another clears it. **Today is tappable** +(unlike the free-day grid); the past is inert. No count, no confirmation. + +## A date is never in both pools + +Each picker syncs the *other* pool on open and refuses a date it holds, naming +the pool: `2026-10-20 is a free day — release it in Free days first.` / +`2026-10-20 is a sick day — clear it in Sick days first.` If the other pool +cannot be opened, every mark is refused ("could not check" never counts as +"not in it"), as `sickdays`' `ConflictCheckUnavailableError` does. +`free_day_pool.dart` itself is untouched: the check lives in the callers, +exactly as the Python README prescribes. + +## The ring + +`AlarmReceiver` asks `RingDecision.skip(free, sick)`. On a sick day it logs +`sick day: not ringing; re-armed for tomorrow`, appends `alarm_fired` with +`reason: "sick_day"`, re-arms and returns: no `AlarmService`, no +`RingWatch.markFired`, no headless `ring_started`. The stick's checks at +A + 15, boot and watchdog return `sick_day` for that date, so there is no +miss, no streak change and no lockdown; the stakes card says +`Sick day — no alarm, nothing at stake.` + +**Fail closed.** An unreadable or missing cache is "not a sick day" +(`DayCache`), so the phone rings, matching `sickdays.is_sick_day()`. + +## PC → phone before the ring, with the app closed + +`sickdays add` on the PC writes locally; its sync timer pushes to RTDB. The +phone pulls from RTDB. A **pre-alarm sync** does that pull with no screen +open, so a PC mark reaches the native cache before the ring: + +- `PoolSyncAlarm` arms an allow-while-idle exact alarm (request code 1006; + inexact fallback if exact alarms are refused, never `setAlarmClock`, so it + is never shown as the next alarm) at the earliest of **21:00** and + **A − 30 min**. It is armed from `AlarmScheduler.schedule()`, the funnel + every arm path uses (boot included), and re-arms itself. +- `PoolSyncReceiver` holds the broadcast open with `goAsync()` (it keeps the + process out of the cached/frozen state) while `PoolSyncEngine` runs a + headless Flutter engine on `poolSyncMain`. That pulls **both** pools and + rewrites both native caches through `AlarmChannel`. Dart says `done`; a + 25 s timeout stops it regardless (the broadcast limit is 60 s). +- The ring decision stays a cache read. A network call inside + `AlarmReceiver` would spend the alarm-clock exemption that lets + `AlarmService` start, and a missed ring cannot be failed closed. + +The guarantee is precisely this: **a day marked on the PC, and pushed by its +sync timer before 21:00 (or before A − 30), is honoured if the phone has +network at that slot.** The evening slot is the dependable one. The morning +slot usually runs in Doze, where an allow-while-idle alarm gets only a short +network window. If both fail, the cache keeps its last sync and the phone +rings. Launching the app or opening either picker also syncs both pools. The free-day pool rides along, which closes the "PC mark overnight can +miss the ring" gap `DOCS-free-days.md` used to admit. + +Known edge: a sick mark that lands *after* the morning was dismissed does not +stop that session's re-rings (`REASON_RERING` is not gated); no pre-alarm +sync runs between A and the session's end anyway. + +## Checks + +- Dart: `test/services/sick_day_pool_test.dart` (rules + a log written by the + real Python package), `sick_day_sync_test.dart`, `day_pool_refresh_test.dart` + (an RTDB log from the PC reaches `setSickDays` through `runHeadlessPoolSync`), + `test/sick_days_screen_test.dart`, `test/free_days_sick_conflict_test.dart`, + `test/services/today_stakes_sick_test.dart`. +- Kotlin (JVM, `android/app/src/test`): `DayCacheTest`, `RingDecisionTest`, + `PoolSyncScheduleTest`, `StickCheckSickTest`. Run with + `cd phone_app/android && ./gradlew :app:testDebugUnitTest` (needs + `local.properties` with `flutter.sdk=/opt/flutter`). +- Cross-language: `phone_app/scripts/sick_days_interop/check.sh` writes a + pool through Dart and reads it with `sickdays` (Python), failing on any + disagreement. Needs `SICKDAYS_PYTHON` or the sickdays venv. + +## Do NOT + +- Don't fold sick days into `FreeDays`/`isFree`, or log them as `free_day`. +- Don't sync inside `AlarmReceiver`. Don't tombstone a date to clear it. +- Don't add a count, a reason prompt or a warning. There is no budget. diff --git a/phone_app/android/app/build.gradle.kts b/phone_app/android/app/build.gradle.kts index b3917e8..2805188 100644 --- a/phone_app/android/app/build.gradle.kts +++ b/phone_app/android/app/build.gradle.kts @@ -77,3 +77,10 @@ kotlin { flutter { source = "../.." } + +dependencies { + // JVM unit tests (src/test) for the pure decision code -- StickCheck, + // DayCache, RingDecision, PoolSyncSchedule. No Robolectric: anything + // that needs a Context stays out of them. + testImplementation("junit:junit:4.13.2") +} diff --git a/phone_app/android/app/src/main/AndroidManifest.xml b/phone_app/android/app/src/main/AndroidManifest.xml index 92dd568..9392eab 100644 --- a/phone_app/android/app/src/main/AndroidManifest.xml +++ b/phone_app/android/app/src/main/AndroidManifest.xml @@ -89,6 +89,11 @@ + + 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 f10e6c2..dbd7295 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 @@ -73,6 +73,16 @@ object AlarmChannel { result.success(null) } } + // The sick-day twin of setFreeDays; see SickDays.kt. + "setSickDays" -> { + val dates = call.argument>("dates") + if (dates == null) { + result.error("bad_args", "dates is required", null) + } else { + SickDays.store(context, dates) + result.success(null) + } + } "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/AlarmReceiver.kt b/phone_app/android/app/src/main/kotlin/com/kuhy/wake_alarm_sync/AlarmReceiver.kt index c9258c8..a0bebb6 100644 --- a/phone_app/android/app/src/main/kotlin/com/kuhy/wake_alarm_sync/AlarmReceiver.kt +++ b/phone_app/android/app/src/main/kotlin/com/kuhy/wake_alarm_sync/AlarmReceiver.kt @@ -17,7 +17,7 @@ import android.util.Log class AlarmReceiver : BroadcastReceiver() { override fun onReceive(context: Context, intent: Intent) { // A session deadline: the user dismissed the alarm and then stalled. - // No free-day check (a free day never starts a session) and no daily + // No free/sick-day check (neither ever starts a session) and no daily // re-arm -- that is the wake alarm's business, not this one's. if (intent.getStringExtra(AlarmScheduler.EXTRA_REASON) == AlarmScheduler.REASON_RERING) { Log.i(TAG, "session deadline missed: re-ringing") @@ -25,12 +25,16 @@ class AlarmReceiver : BroadcastReceiver() { AlarmService.start(context) return } - // A shared free day means the alarm does not ring -- not later, not - // quieter, not at all. Re-arm for tomorrow (nextOccurrence rolls over - // because the armed instant has passed) so the day after is normal. - if (FreeDays.isToday(context)) { - Log.i(TAG, "free day: not ringing; re-armed for tomorrow") - EventLog.append(context, "alarm_fired", mapOf("reason" to "free_day")) + // A shared free or sick day means the alarm does not ring -- not + // later, not quieter, not at all -- and nothing ring-related is + // recorded: no markFired (the stick's own check skips the day too) + // and no ring_started for the PC. Re-arm for tomorrow (the armed + // instant has passed, so it rolls over) so the day after is normal. + // Both caches fail closed: unreadable means ring. + val skip = RingDecision.skip(FreeDays.isToday(context), SickDays.isToday(context)) + if (skip != null) { + Log.i(TAG, "${skip.label}: not ringing; re-armed for tomorrow") + EventLog.append(context, "alarm_fired", mapOf("reason" to skip.wire)) AlarmScheduler.rearmFromStorage(context) return } diff --git a/phone_app/android/app/src/main/kotlin/com/kuhy/wake_alarm_sync/AlarmScheduler.kt b/phone_app/android/app/src/main/kotlin/com/kuhy/wake_alarm_sync/AlarmScheduler.kt index 281c748..0881bcd 100644 --- a/phone_app/android/app/src/main/kotlin/com/kuhy/wake_alarm_sync/AlarmScheduler.kt +++ b/phone_app/android/app/src/main/kotlin/com/kuhy/wake_alarm_sync/AlarmScheduler.kt @@ -65,6 +65,8 @@ object AlarmScheduler { // Every arm path (picker, boot, dismiss, free-day skip, watchdog) // ends here, so the stick's own check can never be left unarmed. StickAlarm.schedule(context, LocalTime.of(hour, minute)) + // Same funnel for the pre-alarm day-pool sync (PoolSyncAlarm.kt). + PoolSyncAlarm.schedule(context, LocalTime.of(hour, minute)) } /** Cancels the armed alarm and forgets the stored time. */ @@ -72,6 +74,7 @@ object AlarmScheduler { val manager = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager manager.cancel(firePendingIntent(context)) StickAlarm.cancel(context) + PoolSyncAlarm.cancel(context) context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit() .remove(KEY_HOUR).remove(KEY_MINUTE).apply() } diff --git a/phone_app/android/app/src/main/kotlin/com/kuhy/wake_alarm_sync/DayCache.kt b/phone_app/android/app/src/main/kotlin/com/kuhy/wake_alarm_sync/DayCache.kt new file mode 100644 index 0000000..864e96d --- /dev/null +++ b/phone_app/android/app/src/main/kotlin/com/kuhy/wake_alarm_sync/DayCache.kt @@ -0,0 +1,27 @@ +package com.kuhy.wake_alarm_sync + +import java.time.LocalDate + +/** + * The one rule both native day caches ([FreeDays], [SickDays]) answer by, + * as a pure function so it is checkable without a device. + * + * Fail closed: a read that throws or finds nothing means "not in the set", + * so a corrupt or missing cache restores the normal rules (the alarm rings, + * the stick applies) rather than standing everything down. That matches + * `freedays.is_free_day()` and `sickdays.is_sick_day()` on the PC. + */ +object DayCache { + + /** Whether [day] (as `yyyy-MM-dd`) is in the set [read] returns. */ + fun contains( + day: LocalDate, + onError: (RuntimeException) -> Unit = {}, + read: () -> Set?, + ): Boolean = try { + read()?.contains(day.toString()) ?: false + } catch (e: RuntimeException) { + onError(e) + false + } +} diff --git a/phone_app/android/app/src/main/kotlin/com/kuhy/wake_alarm_sync/EnforcementChannel.kt b/phone_app/android/app/src/main/kotlin/com/kuhy/wake_alarm_sync/EnforcementChannel.kt index 914eb2e..22ba2a7 100644 --- a/phone_app/android/app/src/main/kotlin/com/kuhy/wake_alarm_sync/EnforcementChannel.kt +++ b/phone_app/android/app/src/main/kotlin/com/kuhy/wake_alarm_sync/EnforcementChannel.kt @@ -50,6 +50,8 @@ object EnforcementChannel { val stick = StickStore.load(context) val alarm = AlarmScheduler.storedAlarm(context) val isFree = { day: LocalDate -> FreeDays.isFree(context, day) } + val isSick = { day: LocalDate -> SickDays.isSick(context, day) } + val standsDown = { day: LocalDate -> isFree(day) || isSick(day) } return mapOf( "today" to today.toString(), "alarmHour" to alarm?.hour, @@ -58,14 +60,16 @@ object EnforcementChannel { "escapedToday" to StickStore.escapedOn(context, today), "freeToday" to isFree(today), "freeTomorrow" to isFree(tomorrow), + "sickToday" to isSick(today), + "sickTomorrow" to isSick(tomorrow), "streak" to stick.streak, "lockedThrough" to stick.lockedThrough?.toString(), "lastMiss" to stick.lastMiss?.toString(), "updatedAt" to StickStore.updatedAt(context), "lockIfMissToday" to - StickCheck.afterMiss(today, stick, isFree).lockedThrough?.toString(), + StickCheck.afterMiss(today, stick, standsDown).lockedThrough?.toString(), "lockIfMissTomorrow" to - StickCheck.afterMiss(tomorrow, stick, isFree).lockedThrough?.toString(), + StickCheck.afterMiss(tomorrow, stick, standsDown).lockedThrough?.toString(), ) } } diff --git a/phone_app/android/app/src/main/kotlin/com/kuhy/wake_alarm_sync/FreeDays.kt b/phone_app/android/app/src/main/kotlin/com/kuhy/wake_alarm_sync/FreeDays.kt index 955dfec..d17a64f 100644 --- a/phone_app/android/app/src/main/kotlin/com/kuhy/wake_alarm_sync/FreeDays.kt +++ b/phone_app/android/app/src/main/kotlin/com/kuhy/wake_alarm_sync/FreeDays.kt @@ -13,9 +13,9 @@ import java.time.LocalDate * after every local change and every sync; [AlarmReceiver] reads it before * making any noise. * - * Fail closed: anything unreadable means "not a free day", so a corrupt or - * missing cache restores the normal rules rather than silencing the alarm. - * That matches `freedays.is_free_day()` on the PC. + * Fail closed ([DayCache]): anything unreadable means "not a free day", so + * a corrupt or missing cache restores the normal rules rather than silencing + * the alarm. That matches `freedays.is_free_day()` on the PC. */ object FreeDays { @@ -38,12 +38,11 @@ object FreeDays { * hands over every free record), which is what lets the stick's streak * skip them. */ - fun isFree(context: Context, day: LocalDate): Boolean = try { - val dates = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) - .getStringSet(KEY_DATES, emptySet()) ?: emptySet() - dates.contains(day.toString()) - } catch (e: RuntimeException) { - Log.w(TAG, "free-day cache unreadable; ringing as normal", e) - false + fun isFree(context: Context, day: LocalDate): Boolean = DayCache.contains( + day, + onError = { Log.w(TAG, "free-day cache unreadable; ringing as normal", it) }, + ) { + context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) + .getStringSet(KEY_DATES, emptySet()) } } diff --git a/phone_app/android/app/src/main/kotlin/com/kuhy/wake_alarm_sync/PoolSyncAlarm.kt b/phone_app/android/app/src/main/kotlin/com/kuhy/wake_alarm_sync/PoolSyncAlarm.kt new file mode 100644 index 0000000..410db2b --- /dev/null +++ b/phone_app/android/app/src/main/kotlin/com/kuhy/wake_alarm_sync/PoolSyncAlarm.kt @@ -0,0 +1,88 @@ +package com.kuhy.wake_alarm_sync + +import android.app.AlarmManager +import android.app.PendingIntent +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.os.Build +import android.util.Log +import java.time.LocalDateTime +import java.time.LocalTime +import java.time.ZoneId + +/** + * The background sync of both day pools before the alarm ([PoolSyncSchedule]). + * + * The ring decision itself stays a cache read: [AlarmReceiver] spends its + * short alarm-clock exemption starting [AlarmService], and a network call + * there could cost the ring. So the network happens *earlier*, here, and + * the receiver keeps reading whatever the last sync left -- including a + * failed one's (fail closed: no cache entry means ring). + * + * [AlarmManager.setExactAndAllowWhileIdle], not setAlarmClock: a sync must + * never show as the "next alarm". Falls back to the inexact + * setAndAllowWhileIdle when exact alarms are refused -- a sync a few + * minutes late is fine. Own receiver and request code ([REQUEST_CODE]), + * never [AlarmReceiver], which would ring. + * + * Armed from [AlarmScheduler.schedule] (every arm path, boot included) and + * re-armed by its own receiver. + */ +object PoolSyncAlarm { + private const val REQUEST_CODE = 1006 + private const val TAG = "WakeAlarmPoolSync" + + fun schedule(context: Context, alarm: LocalTime?) { + val at = PoolSyncSchedule.next(LocalDateTime.now(), alarm) + val millis = at.atZone(ZoneId.systemDefault()).toInstant().toEpochMilli() + Log.i(TAG, "pool sync armed for $at via ${arm(context, millis)}") + } + + /** Arms [millis], exactly when permitted; returns which API did it. */ + private fun arm(context: Context, millis: Long): String { + val manager = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager + val exactAllowed = Build.VERSION.SDK_INT < Build.VERSION_CODES.S || + manager.canScheduleExactAlarms() + if (exactAllowed) { + try { + manager.setExactAndAllowWhileIdle( + AlarmManager.RTC_WAKEUP, + millis, + pendingIntent(context), + ) + return "setExactAndAllowWhileIdle" + } catch (e: SecurityException) { + Log.w(TAG, "exact alarm refused; arming inexact", e) + } + } + manager.setAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, millis, pendingIntent(context)) + return "setAndAllowWhileIdle" + } + + fun cancel(context: Context) { + val manager = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager + manager.cancel(pendingIntent(context)) + } + + private fun pendingIntent(context: Context): PendingIntent = PendingIntent.getBroadcast( + context, + REQUEST_CODE, + Intent(context, PoolSyncReceiver::class.java), + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, + ) +} + +/** + * Receives [PoolSyncAlarm]: re-arms the next slot first (so a crash below + * cannot end the chain), then holds the broadcast open with goAsync while + * [PoolSyncEngine] syncs. Holding it keeps the process out of the cached + * state, where Android would freeze the Dart isolate mid-request. + */ +class PoolSyncReceiver : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + PoolSyncAlarm.schedule(context, AlarmScheduler.storedAlarm(context)) + val pending = goAsync() + PoolSyncEngine.run(context) { pending.finish() } + } +} diff --git a/phone_app/android/app/src/main/kotlin/com/kuhy/wake_alarm_sync/PoolSyncEngine.kt b/phone_app/android/app/src/main/kotlin/com/kuhy/wake_alarm_sync/PoolSyncEngine.kt new file mode 100644 index 0000000..53280f1 --- /dev/null +++ b/phone_app/android/app/src/main/kotlin/com/kuhy/wake_alarm_sync/PoolSyncEngine.kt @@ -0,0 +1,74 @@ +package com.kuhy.wake_alarm_sync + +import android.content.Context +import android.os.Handler +import android.os.Looper +import android.util.Log +import io.flutter.FlutterInjector +import io.flutter.embedding.engine.FlutterEngine +import io.flutter.embedding.engine.dart.DartExecutor +import io.flutter.plugin.common.MethodChannel + +/** + * A headless Flutter engine running `poolSyncMain` (`lib/main.dart` -> + * `day_pool_refresh.dart`): pulls the free- and sick-day pools and rewrites + * [FreeDays] and [SickDays] through [AlarmChannel] -- the same Dart sync the + * screens use, so the RTDB wire format has one implementation on the phone. + * + * Same shape as [HeadlessRingStart]. Dart says `done` on [CHANNEL]; + * [TIMEOUT_MS] destroys the engine regardless. That bound sits well inside + * the 60 s a background broadcast may stay open, since [PoolSyncReceiver] + * holds one for exactly this long. Never throws. + */ +object PoolSyncEngine { + private const val TAG = "WakeAlarmPoolSync" + const val CHANNEL = "com.kuhy.wake_alarm_sync/poolsync" + private const val ENTRYPOINT = "poolSyncMain" + private const val TIMEOUT_MS = 25_000L + + private val handler = Handler(Looper.getMainLooper()) + private var engine: FlutterEngine? = null + private var onFinished: (() -> Unit)? = null + private val timeout = Runnable { stop("timeout") } + + /** Runs one sync, then calls [finished]. Main thread only. */ + fun run(context: Context, finished: () -> Unit) { + if (engine != null) { + // One sync at a time; the running one covers this slot too. + finished() + return + } + onFinished = finished + try { + val app = context.applicationContext + val created = FlutterEngine(app) + engine = created + AlarmChannel.register(app, created) + MethodChannel(created.dartExecutor.binaryMessenger, CHANNEL) + .setMethodCallHandler { call, result -> + result.success(null) + // Posted: destroying an engine inside its own callback + // would tear the messenger down under the reply. + if (call.method == "done") handler.post { stop("done") } + } + val bundle = FlutterInjector.instance().flutterLoader().findAppBundlePath() + created.dartExecutor.executeDartEntrypoint( + DartExecutor.DartEntrypoint(bundle, ENTRYPOINT), + ) + handler.postDelayed(timeout, TIMEOUT_MS) + Log.i(TAG, "pool sync engine started") + } catch (e: RuntimeException) { + Log.w(TAG, "pool sync engine failed; the caches keep their last sync", e) + stop("failed") + } + } + + private fun stop(reason: String) { + handler.removeCallbacks(timeout) + engine?.destroy() + engine = null + Log.i(TAG, "pool sync engine stopped: $reason") + onFinished?.invoke() + onFinished = null + } +} diff --git a/phone_app/android/app/src/main/kotlin/com/kuhy/wake_alarm_sync/PoolSyncSchedule.kt b/phone_app/android/app/src/main/kotlin/com/kuhy/wake_alarm_sync/PoolSyncSchedule.kt new file mode 100644 index 0000000..abd4454 --- /dev/null +++ b/phone_app/android/app/src/main/kotlin/com/kuhy/wake_alarm_sync/PoolSyncSchedule.kt @@ -0,0 +1,35 @@ +package com.kuhy.wake_alarm_sync + +import java.time.LocalDateTime +import java.time.LocalTime + +/** + * When [PoolSyncAlarm] fires, as a pure function of the clock and the + * stored alarm -- no Context, so every case is checkable in isolation. + * + * Two slots a day, so a free or sick day marked on the PC reaches the + * native caches before the morning ring without the app being opened: + * - [EVENING] (21:00): the phone is usually awake and online, which makes + * it the dependable slot for anything marked during the day. + * - A - [LEAD_MINUTES]: catches a mark made late at night. The phone is + * usually in Doze by then; an allow-while-idle alarm gets a short network + * window, enough for two small RTDB reads, but not a guarantee. + */ +object PoolSyncSchedule { + + /** The evening slot. */ + val EVENING: LocalTime = LocalTime.of(21, 0) + + /** How long before the alarm the morning slot runs. */ + const val LEAD_MINUTES = 30L + + /** The earliest slot strictly after [now]; only [EVENING] when no alarm is stored. */ + fun next(now: LocalDateTime, alarm: LocalTime?): LocalDateTime { + val times = listOfNotNull(EVENING, alarm?.minusMinutes(LEAD_MINUTES)) + val today = now.toLocalDate() + return listOf(today, today.plusDays(1)) + .flatMap { day -> times.map { day.atTime(it) } } + .filter { it.isAfter(now) } + .min() + } +} diff --git a/phone_app/android/app/src/main/kotlin/com/kuhy/wake_alarm_sync/RingDecision.kt b/phone_app/android/app/src/main/kotlin/com/kuhy/wake_alarm_sync/RingDecision.kt new file mode 100644 index 0000000..dd89f37 --- /dev/null +++ b/phone_app/android/app/src/main/kotlin/com/kuhy/wake_alarm_sync/RingDecision.kt @@ -0,0 +1,21 @@ +package com.kuhy.wake_alarm_sync + +/** Why the daily alarm stays silent; [wire] is the `alarm_fired` event's `reason`. */ +enum class RingSkip(val wire: String, val label: String) { + FREE_DAY("free_day", "free day"), + SICK_DAY("sick_day", "sick day"), +} + +/** + * Whether the daily alarm rings, as a pure function of the two native day + * caches, so the rule is checkable without a device. A date is never in + * both pools; if a sync race ever put it there, the free day is named. + */ +object RingDecision { + /** The reason to stay silent, or null to ring. */ + fun skip(isFree: Boolean, isSick: Boolean): RingSkip? = when { + isFree -> RingSkip.FREE_DAY + isSick -> RingSkip.SICK_DAY + else -> null + } +} 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 new file mode 100644 index 0000000..5801783 --- /dev/null +++ b/phone_app/android/app/src/main/kotlin/com/kuhy/wake_alarm_sync/SickDays.kt @@ -0,0 +1,44 @@ +package com.kuhy.wake_alarm_sync + +import android.content.Context +import android.util.Log +import java.time.LocalDate + +/** + * The native copy of the shared sick-day pool (`~/src/utils/sickdays`): the + * ISO dates on which the alarm must not ring and the stick stands down. + * + * A separate cache from [FreeDays], with its own prefs file, because the + * pools are separate things: a sick day is never called a free day, in a + * log line or anywhere else. Dart rewrites it after every mark, clear and + * sync (`sick_day_sync.dart`), including the pre-alarm sync + * ([PoolSyncReceiver]) that brings a PC mark in with the app closed. + * + * Fail closed ([DayCache]): unreadable means "not a sick day", so the + * alarm rings. + */ +object SickDays { + + private const val TAG = "WakeAlarmSickDays" + private const val PREFS = "wake_alarm_sickdays" + private const val KEY_DATES = "sick_dates" + + /** Replaces the cached set with [isoDates] (`yyyy-MM-dd` each). */ + fun store(context: Context, isoDates: List) { + context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit() + .putStringSet(KEY_DATES, isoDates.toSet()) + .apply() + } + + /** Whether today (local calendar day) is in the cached set. */ + fun isToday(context: Context): Boolean = isSick(context, LocalDate.now()) + + /** Whether [day] is in the cached set; past sick days stay in it. */ + fun isSick(context: Context, day: LocalDate): Boolean = DayCache.contains( + day, + onError = { Log.w(TAG, "sick-day cache unreadable; ringing as normal", it) }, + ) { + context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) + .getStringSet(KEY_DATES, emptySet()) + } +} diff --git a/phone_app/android/app/src/main/kotlin/com/kuhy/wake_alarm_sync/StickCheck.kt b/phone_app/android/app/src/main/kotlin/com/kuhy/wake_alarm_sync/StickCheck.kt index 50bfbf7..4c8fe9f 100644 --- a/phone_app/android/app/src/main/kotlin/com/kuhy/wake_alarm_sync/StickCheck.kt +++ b/phone_app/android/app/src/main/kotlin/com/kuhy/wake_alarm_sync/StickCheck.kt @@ -9,6 +9,7 @@ enum class StickVerdict(val wire: String, val final: Boolean = true) { NO_ALARM("no_alarm"), NOT_STICK_DAY("not_stick_day"), FREE_DAY("free_day"), + SICK_DAY("sick_day"), ESCAPED("escaped"), ALREADY_PENALISED("already_penalised"), DISMISSED("dismissed"), @@ -32,6 +33,8 @@ data class StickInputs( val escapedOn: (LocalDate) -> Boolean, /** The native free-day cache; fail-closed, so unreadable is `false`. */ val isFree: (LocalDate) -> Boolean, + /** The native sick-day cache; fail-closed, so unreadable is `false`. */ + val isSick: (LocalDate) -> Boolean, val state: StickState, /** The last day whose verdict was final, or null before the first run. */ val checkedThrough: LocalDate?, @@ -57,7 +60,7 @@ data class StickRun( * so every rule here is checkable in isolation. * * A **miss** is a stick day (Tue/Wed/Thu, [WorkdayStick.STICK_DAYS]) that is - * not a free day, whose alarm passed by at least [Stakes.STICK_GRACE_MINUTES] + * neither a free day nor a sick day, whose alarm passed by at least [Stakes.STICK_GRACE_MINUTES] * with no dismiss and no escape, and that has not already been penalised. * Nothing here needs the ring to have *fired*: a phone switched off before * the alarm misses exactly like one left ringing (hole 1 of the contract). @@ -115,6 +118,9 @@ object StickCheck { val alarm = inputs.alarm ?: return StickVerdict.NO_ALARM to state if (!isStickDay(day)) return StickVerdict.NOT_STICK_DAY to state if (inputs.isFree(day)) return StickVerdict.FREE_DAY to state + // A sick day stands the stick down like a free day: no miss, no + // streak change, no lockdown -- and nothing carried to tomorrow. + if (inputs.isSick(day)) return StickVerdict.SICK_DAY to state // A dismissed or escaped stick day breaks the chain: the streak // shown and synced drops to 0 (a lockdown already sent stays). if (inputs.escapedOn(day)) return StickVerdict.ESCAPED to state.copy(streak = 0) @@ -122,7 +128,7 @@ object StickCheck { if (inputs.dismissedOn(day)) return StickVerdict.DISMISSED to state.copy(streak = 0) val due = day.atTime(alarm).plusMinutes(Stakes.STICK_GRACE_MINUTES) if (now.isBefore(due)) return StickVerdict.TOO_EARLY to state - return StickVerdict.MISS to afterMiss(day, state, inputs.isFree) + return StickVerdict.MISS to afterMiss(day, state, inputs.standsDown) } /** @@ -132,8 +138,8 @@ object StickCheck { * lockdown already in force: a chain restarting at 1 must not report * an earlier end than the one focus-owner was already sent. */ - fun afterMiss(day: LocalDate, state: StickState, isFree: (LocalDate) -> Boolean): StickState { - val streak = streakAfterMiss(day, state, isFree) + fun afterMiss(day: LocalDate, state: StickState, standsDown: (LocalDate) -> Boolean): StickState { + val streak = streakAfterMiss(day, state, standsDown) val computed = lockedThrough(day, streak) val locked = state.lockedThrough?.let { maxOf(it, computed) } ?: computed return StickState(streak = streak, lockedThrough = locked, lastMiss = day) @@ -141,11 +147,11 @@ object StickCheck { /** * Consecutive stick-day misses including one on [day]: the chain - * continues only when the previous stick day (free days skipped) is - * exactly the last miss. + * continues only when the previous stick day (free and sick days + * skipped, per [standsDown]) is exactly the last miss. */ - fun streakAfterMiss(day: LocalDate, state: StickState, isFree: (LocalDate) -> Boolean): Int { - val previous = previousStickDay(day, isFree) + fun streakAfterMiss(day: LocalDate, state: StickState, standsDown: (LocalDate) -> Boolean): Int { + val previous = previousStickDay(day, standsDown) return if (previous != null && previous == state.lastMiss) state.streak + 1 else 1 } @@ -153,12 +159,12 @@ object StickCheck { fun lockedThrough(day: LocalDate, streak: Int): LocalDate = day.plusDays(minOf(streak, Stakes.STICK_LOCKDOWN_MAX_DAYS - 1).toLong()) - /** The nearest earlier stick day that is not a free day, or null. */ - fun previousStickDay(day: LocalDate, isFree: (LocalDate) -> Boolean): LocalDate? { + /** The nearest earlier stick day that does not [standsDown], or null. */ + fun previousStickDay(day: LocalDate, standsDown: (LocalDate) -> Boolean): LocalDate? { var step = day.minusDays(1) val floor = day.minusDays(LOOKBACK_DAYS) while (step.isAfter(floor)) { - if (isStickDay(step) && !isFree(step)) return step + if (isStickDay(step) && !standsDown(step)) return step step = step.minusDays(1) } return null @@ -166,3 +172,7 @@ object StickCheck { fun isStickDay(day: LocalDate): Boolean = day.dayOfWeek in WorkdayStick.STICK_DAYS } + +/** A day the stick skips entirely: free or sick. */ +val StickInputs.standsDown: (LocalDate) -> Boolean + get() = { isFree(it) || isSick(it) } diff --git a/phone_app/android/app/src/main/kotlin/com/kuhy/wake_alarm_sync/StickRunner.kt b/phone_app/android/app/src/main/kotlin/com/kuhy/wake_alarm_sync/StickRunner.kt index ff17175..f7c2d59 100644 --- a/phone_app/android/app/src/main/kotlin/com/kuhy/wake_alarm_sync/StickRunner.kt +++ b/phone_app/android/app/src/main/kotlin/com/kuhy/wake_alarm_sync/StickRunner.kt @@ -32,6 +32,7 @@ object StickRunner { dismissedOn = { RingWatch.dismissedOn(context, it) }, escapedOn = { StickStore.escapedOn(context, it) }, isFree = { FreeDays.isFree(context, it) }, + isSick = { SickDays.isSick(context, it) }, state = before, checkedThrough = StickStore.checkedThrough(context), ), @@ -67,7 +68,11 @@ object StickRunner { @Synchronized fun onDismissed(context: Context) { val today = LocalDate.now() - if (!StickCheck.isStickDay(today) || FreeDays.isFree(context, today)) return + if (!StickCheck.isStickDay(today) || FreeDays.isFree(context, today) || + SickDays.isSick(context, today) + ) { + return + } val state = StickStore.load(context) if (state.lastMiss == today || state.streak == 0) return StickStore.save(context, state.copy(streak = 0)) diff --git a/phone_app/android/app/src/test/kotlin/com/kuhy/wake_alarm_sync/DayCacheTest.kt b/phone_app/android/app/src/test/kotlin/com/kuhy/wake_alarm_sync/DayCacheTest.kt new file mode 100644 index 0000000..0d2c720 --- /dev/null +++ b/phone_app/android/app/src/test/kotlin/com/kuhy/wake_alarm_sync/DayCacheTest.kt @@ -0,0 +1,34 @@ +package com.kuhy.wake_alarm_sync + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import java.time.LocalDate + +class DayCacheTest { + private val day = LocalDate.of(2026, 10, 20) + + @Test + fun aCachedDateIsInTheSet() { + assertTrue(DayCache.contains(day) { setOf("2026-10-19", "2026-10-20") }) + } + + @Test + fun anotherDateIsNot() { + assertFalse(DayCache.contains(day) { setOf("2026-10-21") }) + } + + @Test + fun aMissingCacheMeansNotInTheSet() { + assertFalse(DayCache.contains(day) { null }) + } + + @Test + fun anUnreadableCacheFailsClosedAndReportsTheError() { + val seen = mutableListOf() + val boom = ClassCastException("not a string set") + assertFalse(DayCache.contains(day, onError = { seen += it }) { throw boom }) + assertEquals(listOf(boom), seen) + } +} diff --git a/phone_app/android/app/src/test/kotlin/com/kuhy/wake_alarm_sync/PoolSyncScheduleTest.kt b/phone_app/android/app/src/test/kotlin/com/kuhy/wake_alarm_sync/PoolSyncScheduleTest.kt new file mode 100644 index 0000000..a858bad --- /dev/null +++ b/phone_app/android/app/src/test/kotlin/com/kuhy/wake_alarm_sync/PoolSyncScheduleTest.kt @@ -0,0 +1,49 @@ +package com.kuhy.wake_alarm_sync + +import org.junit.Assert.assertEquals +import org.junit.Test +import java.time.LocalDateTime +import java.time.LocalTime + +class PoolSyncScheduleTest { + private val alarm = LocalTime.of(7, 30) + + private fun at(day: Int, hour: Int, minute: Int = 0) = + LocalDateTime.of(2026, 10, day, hour, minute) + + @Test + fun afternoonNextSlotIsTheEvening() { + assertEquals(at(16, 21), PoolSyncSchedule.next(at(16, 14), alarm)) + } + + @Test + fun afterTheEveningNextSlotIsHalfAnHourBeforeTomorrowsAlarm() { + assertEquals(at(17, 7), PoolSyncSchedule.next(at(16, 21, 0), alarm)) + assertEquals(at(17, 7), PoolSyncSchedule.next(at(16, 23, 59), alarm)) + } + + @Test + fun earlyMorningNextSlotIsTodaysPreAlarm() { + assertEquals(at(17, 7), PoolSyncSchedule.next(at(17, 3), alarm)) + } + + @Test + fun theSlotThatJustFiredIsNeverReturned() { + assertEquals(at(17, 21), PoolSyncSchedule.next(at(17, 7), alarm)) + } + + @Test + fun withNoAlarmStoredOnlyTheEveningRuns() { + assertEquals(at(16, 21), PoolSyncSchedule.next(at(16, 3), null)) + assertEquals(at(17, 21), PoolSyncSchedule.next(at(16, 22), null)) + } + + @Test + fun theLeadIsThirtyMinutes() { + assertEquals(30L, PoolSyncSchedule.LEAD_MINUTES) + assertEquals( + at(17, 7, 30).minusMinutes(30), + PoolSyncSchedule.next(at(16, 22), alarm), + ) + } +} diff --git a/phone_app/android/app/src/test/kotlin/com/kuhy/wake_alarm_sync/RingDecisionTest.kt b/phone_app/android/app/src/test/kotlin/com/kuhy/wake_alarm_sync/RingDecisionTest.kt new file mode 100644 index 0000000..d0c1ca0 --- /dev/null +++ b/phone_app/android/app/src/test/kotlin/com/kuhy/wake_alarm_sync/RingDecisionTest.kt @@ -0,0 +1,30 @@ +package com.kuhy.wake_alarm_sync + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class RingDecisionTest { + @Test + fun aNormalDayRings() { + assertNull(RingDecision.skip(isFree = false, isSick = false)) + } + + @Test + fun aSickDayIsSilentAndNamedSick() { + val skip = RingDecision.skip(isFree = false, isSick = true) + assertEquals(RingSkip.SICK_DAY, skip) + assertEquals("sick_day", skip?.wire) + assertEquals("sick day", skip?.label) + } + + @Test + fun aFreeDayIsSilentAndNamedFree() { + assertEquals("free_day", RingDecision.skip(isFree = true, isSick = false)?.wire) + } + + @Test + fun aDateInBothPoolsIsNamedFree() { + assertEquals(RingSkip.FREE_DAY, RingDecision.skip(isFree = true, isSick = true)) + } +} diff --git a/phone_app/android/app/src/test/kotlin/com/kuhy/wake_alarm_sync/StickCheckSickTest.kt b/phone_app/android/app/src/test/kotlin/com/kuhy/wake_alarm_sync/StickCheckSickTest.kt new file mode 100644 index 0000000..dfeef8b --- /dev/null +++ b/phone_app/android/app/src/test/kotlin/com/kuhy/wake_alarm_sync/StickCheckSickTest.kt @@ -0,0 +1,87 @@ +package com.kuhy.wake_alarm_sync + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test +import java.time.LocalDate +import java.time.LocalDateTime +import java.time.LocalTime + +/** The stick on sick days: no miss, no streak change, no lockdown. */ +class StickCheckSickTest { + // 2026-10-20 is a Tuesday; 21 Wed, 22 Thu, 27 the next Tuesday. + private val tue = LocalDate.of(2026, 10, 20) + private val wed = tue.plusDays(1) + private val thu = tue.plusDays(2) + + private fun inputs( + now: LocalDateTime, + sick: Set = emptySet(), + free: Set = emptySet(), + state: StickState = StickState(), + checkedThrough: LocalDate? = null, + ) = StickInputs( + now = now, + alarm = LocalTime.of(7, 30), + dismissedOn = { false }, + escapedOn = { false }, + isFree = { it in free }, + isSick = { it in sick }, + state = state, + checkedThrough = checkedThrough, + ) + + @Test + fun anUndismissedSickStickDayIsNotAMiss() { + val run = StickCheck.run(inputs(wed.atTime(9, 0), sick = setOf(wed))) + assertEquals(StickVerdict.SICK_DAY, run.verdicts.single().verdict) + assertEquals("sick_day", run.verdicts.single().verdict.wire) + assertEquals(StickState(), run.state) + assertNull(run.signalUntil) + } + + @Test + fun theSameDayNotSickIsAMiss() { + val run = StickCheck.run(inputs(wed.atTime(9, 0))) + assertEquals(StickVerdict.MISS, run.verdicts.single().verdict) + assertEquals(thu, run.signalUntil) + } + + @Test + fun aSickDayKeepsAnExistingStreakAsItWas() { + val before = StickState(streak = 2, lockedThrough = wed, lastMiss = tue) + val run = StickCheck.run(inputs(wed.atTime(9, 0), sick = setOf(wed), state = before)) + assertEquals(before, run.state) + } + + @Test + fun aSickDayIsSkippedWhenTheStreakLooksBack() { + // Missed Tuesday, sick Wednesday: Thursday's miss continues the chain. + val before = StickState(streak = 1, lockedThrough = wed, lastMiss = tue) + val after = StickCheck.afterMiss(thu, before) { it == wed } + assertEquals(2, after.streak) + } + + @Test + fun aCaughtUpSickDayIsJudgedSick() { + val run = StickCheck.run( + inputs(thu.atTime(6, 0), sick = setOf(wed), checkedThrough = tue), + ) + assertEquals( + listOf(StickVerdict.SICK_DAY, StickVerdict.TOO_EARLY), + run.verdicts.map { it.verdict }, + ) + } + + @Test + fun aFreeDayIsStillNamedFree() { + val run = StickCheck.run(inputs(wed.atTime(9, 0), free = setOf(wed))) + assertEquals(StickVerdict.FREE_DAY, run.verdicts.single().verdict) + } + + @Test + fun standsDownCoversBothPools() { + val i = inputs(wed.atTime(9, 0), sick = setOf(wed), free = setOf(thu)) + assertEquals(listOf(false, true, true), listOf(tue, wed, thu).map(i.standsDown)) + } +} diff --git a/phone_app/lib/main.dart b/phone_app/lib/main.dart index 2d9e216..80d3d3e 100644 --- a/phone_app/lib/main.dart +++ b/phone_app/lib/main.dart @@ -5,6 +5,7 @@ import 'package:wake_alarm_sync/screens/ring_screen.dart'; import 'package:wake_alarm_sync/screens/session_screen.dart'; import 'package:wake_alarm_sync/services/alarm_policy.dart'; import 'package:wake_alarm_sync/services/alarm_scheduler.dart'; +import 'package:wake_alarm_sync/services/day_pool_refresh.dart'; import 'package:wake_alarm_sync/services/headless_ring_start.dart'; import 'package:wake_alarm_sync/ui/theme.dart'; @@ -38,6 +39,12 @@ Future main() async { @pragma('vm:entry-point') Future ringStartedMain() => runHeadlessRingStart(); +/// Headless entry point: `PoolSyncReceiver.kt` starts it in the evening and +/// shortly before the alarm, so a free or sick day marked on the PC reaches +/// the native ring-path caches with the app closed. +@pragma('vm:entry-point') +Future poolSyncMain() => runHeadlessPoolSync(); + /// Root application widget. class WakeAlarmSyncApp extends StatelessWidget { /// Creates a [WakeAlarmSyncApp]; [newScheduler] is the probes' seam. diff --git a/phone_app/lib/screens/free_day_calendar.dart b/phone_app/lib/screens/day_calendar.dart similarity index 69% rename from phone_app/lib/screens/free_day_calendar.dart rename to phone_app/lib/screens/day_calendar.dart index a649b53..5d50cdb 100644 --- a/phone_app/lib/screens/free_day_calendar.dart +++ b/phone_app/lib/screens/day_calendar.dart @@ -2,35 +2,54 @@ 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. +/// 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); /// 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 { +/// date was tapped. Past days are always inert. Today is inert too unless +/// [todayTappable]: releasing a free day that has already started still +/// spends it, so the free-day grid does not offer it, while a sick day can +/// be marked or cleared for today. +class DayCalendar extends StatelessWidget { /// Creates a grid over [month]. - const FreeDayCalendar({ + const DayCalendar({ required this.month, required this.today, - required this.isFree, + required this.isMarked, required this.busy, required this.onTapDay, required this.onMonth, this.firstMonth, this.lastMonth, + this.todayTappable = false, + this.keyPrefix = 'free-day', + this.markColor, + this.onMarkColor, 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. + /// The reference date: everything before it is read-only, and it too + /// unless [todayTappable]. final DateTime today; - /// Whether a date is currently free in the pool. - final bool Function(DateTime day) isFree; + /// Whether a date is currently marked in the pool. + final bool Function(DateTime day) isMarked; + + /// Whether [today] itself can be tapped. + final bool todayTappable; + + /// Each cell's key is `-`. + final String keyPrefix; + + /// A marked day's fill; null means the theme's primary (free days). + final Color? markColor; + + /// Text on [markColor]; null means the theme's onPrimary. + final Color? onMarkColor; /// Whether a sync is in flight, which makes every day inert. final bool busy; @@ -117,35 +136,39 @@ class FreeDayCalendar extends StatelessWidget { crossAxisSpacing: 4, children: [ for (var i = 0; i < blanks; i++) const SizedBox.shrink(), - for (var day = 1; day <= total; day++) _cell(day), + for (var day = 1; day <= total; day++) _cell(theme, day), ], ), ], ); } - Widget _cell(int day) { + Widget _cell(ThemeData theme, int day) { final date = DateTime(month.year, month.month, day); - final future = date.isAfter(today); - return FreeDayCell( - key: ValueKey('free-day-${FreeDayPool.toIso(date)}'), + final open = date.isAfter(today) || (todayTappable && date == today); + return DayCell( + key: ValueKey('$keyPrefix-${FreeDayPool.toIso(date)}'), day: day, - isFree: isFree(date), + isMarked: isMarked(date), isWorkday: isWorkday(date), - isPast: !future, - onTap: future && !busy ? () => onTapDay(date) : null, + isPast: !open, + markColor: markColor ?? theme.colorScheme.primary, + onMarkColor: onMarkColor ?? theme.colorScheme.onPrimary, + onTap: open && !busy ? () => onTapDay(date) : null, ); } } /// One day box: renders the state it is handed and reports taps. -class FreeDayCell extends StatelessWidget { +class DayCell extends StatelessWidget { /// Creates a cell for [day] of the month. - const FreeDayCell({ + const DayCell({ required this.day, - required this.isFree, + required this.isMarked, required this.isWorkday, required this.isPast, + required this.markColor, + required this.onMarkColor, required this.onTap, super.key, }); @@ -153,13 +176,19 @@ class FreeDayCell extends StatelessWidget { /// 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 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 today or earlier, and so read-only. + /// 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. @@ -171,13 +200,13 @@ class FreeDayCell extends StatelessWidget { // 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) + final background = isMarked + ? markColor.withValues(alpha: fade) : isWorkday ? colors.surfaceContainerHigh : null; - final foreground = isFree - ? colors.onPrimary + final foreground = isMarked + ? onMarkColor : colors.onSurface.withValues(alpha: fade); return InkWell( onTap: onTap, @@ -192,7 +221,7 @@ class FreeDayCell extends StatelessWidget { '$day', style: TextStyle( color: foreground, - fontWeight: isFree ? FontWeight.w600 : FontWeight.normal, + fontWeight: isMarked ? FontWeight.w600 : FontWeight.normal, ), ), ), diff --git a/phone_app/lib/screens/free_days_body.dart b/phone_app/lib/screens/free_days_body.dart index 5120641..8af8e3c 100644 --- a/phone_app/lib/screens/free_days_body.dart +++ b/phone_app/lib/screens/free_days_body.dart @@ -1,5 +1,5 @@ import 'package:flutter/material.dart'; -import 'package:wake_alarm_sync/screens/free_day_calendar.dart'; +import 'package:wake_alarm_sync/screens/day_calendar.dart'; import 'package:wake_alarm_sync/services/free_day_pool.dart'; import 'package:wake_alarm_sync/ui/theme.dart'; @@ -25,7 +25,7 @@ class FreeDaysBody extends StatelessWidget { }); /// The month grid, built by the screen, which owns the displayed month. - final FreeDayCalendar calendar; + final DayCalendar calendar; /// The calendar year the budget line describes. final int year; diff --git a/phone_app/lib/screens/free_days_screen.dart b/phone_app/lib/screens/free_days_screen.dart index 4fa6d32..c13cefe 100644 --- a/phone_app/lib/screens/free_days_screen.dart +++ b/phone_app/lib/screens/free_days_screen.dart @@ -1,10 +1,12 @@ import 'dart:async'; import 'package:flutter/material.dart'; -import 'package:wake_alarm_sync/screens/free_day_calendar.dart'; +import 'package:wake_alarm_sync/screens/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'; +import 'package:wake_alarm_sync/services/sick_day_pool.dart'; +import 'package:wake_alarm_sync/services/sick_day_sync.dart'; /// The one picker for the shared free-day pool. /// @@ -15,12 +17,21 @@ import 'package:wake_alarm_sync/services/free_day_sync.dart'; /// this screen. /// /// Syncs on open and after every change. A day marked on the PC reaches the -/// phone the next time this app launches or this screen opens — the ring -/// path takes no background dependency, so an overnight PC mark can miss -/// the morning ring. Marking on the phone is immediate. +/// phone when this app launches, when this screen opens, or at the next +/// pre-alarm sync (`DOCS-sick-days.md`). Marking on the phone is immediate. +/// +/// A sick day is never also a free day: the sick-day pool is synced on open +/// too, and a sick date is refused here with the pool named. class FreeDaysScreen extends StatefulWidget { /// Creates the picker; the seams are injectable for tests. - const FreeDaysScreen({this.openPool, this.syncer, this.now, super.key}); + const FreeDaysScreen({ + this.openPool, + this.syncer, + this.openSickPool, + this.sickSyncer, + this.now, + super.key, + }); /// Opens the pool; defaults to the on-disk one. final Future Function()? openPool; @@ -28,6 +39,12 @@ class FreeDaysScreen extends StatefulWidget { /// Syncs the pool; defaults to Firebase. final FreeDaySync? syncer; + /// Opens the sick-day pool, for the conflict check. + final Future Function()? openSickPool; + + /// Syncs the sick-day pool on open. + final SickDaySync? sickSyncer; + /// The reference date, for tests. final DateTime Function()? now; @@ -37,6 +54,10 @@ class FreeDaysScreen extends StatefulWidget { class _FreeDaysScreenState extends State { FreeDayPool? _pool; + + /// Null when it could not be opened: then no day can be booked, because + /// "could not check" must never count as "not sick". + SickDayPool? _sick; bool _busy = true; String _status = ''; @@ -65,9 +86,23 @@ class _FreeDaysScreenState extends State { }); return; } + await _loadSick(); await _sync(); } + /// Opens and syncs the sick-day pool, so a sick day marked on the PC is + /// refused here too. Its sync status is not shown: this is the free-day + /// screen, and the cache it refreshes is the sick-day one. + Future _loadSick() async { + try { + final sick = await (widget.openSickPool ?? SickDayPool.open)(); + await (widget.sickSyncer ?? SickDaySync()).sync(sick); + _sick = sick; + } on Exception { + _sick = null; + } + } + Future _sync() async { final pool = _pool; if (pool == null) return; @@ -95,10 +130,27 @@ class _FreeDaysScreenState extends State { await _apply( () => pool.isFree(day) ? pool.release(day, now: today) - : pool.mark(day, now: today), + : _mark(pool, day, today), ); } + /// Books [day] unless the sick-day pool holds it (or cannot be read). + Future _mark(FreeDayPool pool, DateTime day, DateTime today) async { + final iso = FreeDayPool.toIso(day); + final sick = _sick; + if (sick == null) { + throw FreeDayPolicyError( + 'Sick-day pool unavailable — cannot rule out a sick day on $iso.', + ); + } + if (sick.isSick(day)) { + throw FreeDayPolicyError( + '$iso is a sick day — clear it in Sick days first.', + ); + } + await pool.mark(day, now: today); + } + Future _release(DateTime day) async { final pool = _pool; if (pool == null) return; @@ -143,10 +195,10 @@ class _FreeDaysScreenState extends State { status: _status, busy: _busy, onRelease: (day) => unawaited(_release(day)), - calendar: FreeDayCalendar( + calendar: DayCalendar( month: _month, today: _today, - isFree: pool.isFree, + isMarked: pool.isFree, busy: _busy, onTapDay: (day) => unawaited(_toggle(day)), onMonth: (month) => setState(() => _month = month), diff --git a/phone_app/lib/screens/home_screen.dart b/phone_app/lib/screens/home_screen.dart index 6735171..79e200e 100644 --- a/phone_app/lib/screens/home_screen.dart +++ b/phone_app/lib/screens/home_screen.dart @@ -6,13 +6,13 @@ import 'package:flutter/material.dart'; import 'package:wake_alarm_sync/screens/free_days_screen.dart'; import 'package:wake_alarm_sync/screens/home_body.dart'; import 'package:wake_alarm_sync/screens/settings_screen.dart'; +import 'package:wake_alarm_sync/screens/sick_days_screen.dart'; import 'package:wake_alarm_sync/services/alarm_policy.dart'; import 'package:wake_alarm_sync/services/alarm_publisher.dart'; import 'package:wake_alarm_sync/services/alarm_scheduler.dart'; import 'package:wake_alarm_sync/services/alarm_sync_service.dart'; +import 'package:wake_alarm_sync/services/day_pool_refresh.dart'; import 'package:wake_alarm_sync/services/firebase_backend.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/sync_settings.dart'; /// Main screen: displays the current alarm time and syncs it to the PC via @@ -62,27 +62,9 @@ class _HomeScreenState extends State { await _applyPolicy(); await _loadSyncState(); await _syncToPc(); - await _refreshFreeDays(); - } - - /// Pulls the shared free-day pool and hands it to the native ring path. - /// - /// Last, and silent: a day marked on the PC reaches the phone here, and - /// nothing on this screen reports it -- the pool is only ever consulted, - /// never announced. Failures are logged by [FreeDaySync] and swallowed; - /// an unreadable pool means the alarm rings as normal. - Future _refreshFreeDays() async { - try { - final pool = await FreeDayPool.open(); - await FreeDaySync().sync(pool); - } on Exception catch (e, stack) { - developer.log( - 'free-day pool unavailable: $e', - name: 'WakeAlarmSync', - error: e, - stackTrace: stack, - ); - } + // Last, and silent: free and sick days marked on the PC reach the + // native ring path here; an unreadable pool means the alarm rings. + await refreshDayPools(); } /// Opens the sync store and records whether a backend is connected. @@ -202,6 +184,17 @@ class _HomeScreenState extends State { MaterialPageRoute(builder: (_) => const FreeDaysScreen()), ), ), + IconButton( + icon: Icon( + Icons.sick_outlined, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + tooltip: 'Sick days', + onPressed: () => Navigator.push( + context, + MaterialPageRoute(builder: (_) => const SickDaysScreen()), + ), + ), IconButton( icon: Icon( Icons.settings, diff --git a/phone_app/lib/screens/sick_days_body.dart b/phone_app/lib/screens/sick_days_body.dart new file mode 100644 index 0000000..94d3dff --- /dev/null +++ b/phone_app/lib/screens/sick_days_body.dart @@ -0,0 +1,84 @@ +import 'package:flutter/material.dart'; +import 'package:wake_alarm_sync/screens/day_calendar.dart'; +import 'package:wake_alarm_sync/services/free_day_pool.dart'; +import 'package:wake_alarm_sync/ui/theme.dart'; + +/// The sick-day picker's body: what a sick day does, the status line, the +/// month calendar and the sick days still ahead. +/// +/// Stateless and fed plain values, like `FreeDaysBody`: the screen owns the +/// pool and the sync ordering, this only draws. No count and no budget -- +/// sick days have none. +class SickDaysBody extends StatelessWidget { + /// Creates the body from already-resolved state. + const SickDaysBody({ + required this.upcoming, + required this.status, + required this.refused, + required this.calendar, + super.key, + }); + + /// The month grid, built by the screen, which owns the displayed month. + final DayCalendar calendar; + + /// Sick days from today on, ascending. + final List upcoming; + + /// The status line; may be a raw `Error: …`. + final String status; + + /// Whether [status] is a refused tap, drawn like an error. + final bool refused; + + static const _weekdays = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']; + + String _label(DateTime day) => + '${_weekdays[day.weekday - 1]} ${FreeDayPool.toIso(day)}'; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final muted = theme.colorScheme.onSurfaceVariant; + final statusColors = theme.extension()!; + final failed = refused || status.contains('Error'); + return SafeArea( + child: ListView( + padding: const EdgeInsets.all(16), + children: [ + Text( + 'Tap a day to mark it sick; tap it again to clear it. Today or ' + 'later only. No limit and no reason needed.', + style: theme.textTheme.bodyMedium, + ), + const SizedBox(height: 8), + Text( + 'On a sick day the alarm stays silent, nothing locks, and work ' + 'left undone is dropped rather than carried forward.', + style: theme.textTheme.bodySmall?.copyWith(color: muted), + ), + if (status.isNotEmpty) ...[ + const SizedBox(height: 16), + Text( + status, + maxLines: 4, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: failed ? theme.colorScheme.error : statusColors.success, + ), + ), + ], + const SizedBox(height: 16), + calendar, + const SizedBox(height: 24), + Text('Marked sick', style: theme.textTheme.titleMedium), + const SizedBox(height: 8), + if (upcoming.isEmpty) + Text('None ahead.', style: TextStyle(color: muted)), + for (final day in upcoming) + ListTile(contentPadding: EdgeInsets.zero, title: Text(_label(day))), + ], + ), + ); + } +} diff --git a/phone_app/lib/screens/sick_days_screen.dart b/phone_app/lib/screens/sick_days_screen.dart new file mode 100644 index 0000000..d22a475 --- /dev/null +++ b/phone_app/lib/screens/sick_days_screen.dart @@ -0,0 +1,195 @@ +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/free_day_pool.dart'; +import 'package:wake_alarm_sync/services/free_day_sync.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'; + +/// 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 +/// name, icon and fill. Every day is normal until tapped; one tap marks a +/// day sick, another clears it. Today and later only -- past days are +/// inert. No cap, no reason, no confirmation. +/// +/// On a sick day every gate stands down and this phone does not ring. A +/// free day is refused here (with the pool named), so the free-day pool is +/// synced on open as well. +class SickDaysScreen extends StatefulWidget { + /// Creates the picker; the seams are injectable for tests. + const SickDaysScreen({ + this.openPool, + this.syncer, + this.openFreePool, + this.freeSyncer, + this.now, + super.key, + }); + + /// Opens the sick-day pool; defaults to the on-disk one. + final Future Function()? openPool; + + /// Syncs the sick-day pool; defaults to Firebase. + final SickDaySync? syncer; + + /// Opens the free-day pool, for the conflict check. + final Future Function()? openFreePool; + + /// Syncs the free-day pool on open. + final FreeDaySync? freeSyncer; + + /// The reference date, for tests. + final DateTime Function()? now; + + @override + State createState() => _SickDaysScreenState(); +} + +class _SickDaysScreenState extends State { + SickDayPool? _pool; + + /// Null when it could not be opened: then no day can be marked, because + /// "could not check" must never count as "not free". + FreeDayPool? _free; + bool _busy = true; + String _status = ''; + bool _refused = false; + + late DateTime _month = DateTime(_today.year, _today.month); + + late final SickDaySync _syncer = widget.syncer ?? SickDaySync(); + + DateTime get _today => + FreeDayPool.dateOnly(widget.now?.call() ?? DateTime.now()); + + @override + void initState() { + super.initState(); + unawaited(_load()); + } + + Future _load() async { + try { + _pool = await (widget.openPool ?? SickDayPool.open)(); + } on Exception catch (e) { + if (!mounted) return; + setState(() { + _busy = false; + _status = 'Error: pool unavailable ($e)'; + }); + return; + } + await _loadFree(); + await _sync(); + } + + /// 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 { + try { + final free = await (widget.openFreePool ?? FreeDayPool.open)(); + await (widget.freeSyncer ?? FreeDaySync()).sync(free); + _free = free; + } on Exception { + _free = null; + } + } + + Future _sync() async { + final pool = _pool; + if (pool == null) return; + setState(() { + _busy = true; + _refused = false; + _status = 'Syncing…'; + }); + final message = await _syncer.sync(pool); + if (!mounted) return; + setState(() { + _busy = false; + _status = message; + }); + } + + /// One tap per day: a sick day is cleared, any other is marked sick. + Future _toggle(DateTime day) async { + final pool = _pool; + if (pool == null) return; + final today = _today; + try { + if (pool.isSick(day)) { + 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) { + setState(() { + _refused = true; + _status = e.message; + }); + return; + } + await _sync(); + } + + @override + Widget build(BuildContext context) { + final pool = _pool; + final marks = Theme.of(context).extension()!; + return Scaffold( + appBar: AppBar( + title: const Row( + children: [ + Icon(Icons.sick_outlined), + SizedBox(width: 8), + Text('Sick days'), + ], + ), + actions: [ + IconButton( + icon: const Icon(Icons.sync), + tooltip: 'Sync now', + onPressed: _busy ? null : () => unawaited(_sync()), + ), + ], + ), + body: pool == null + ? Center(child: Text(_status.isEmpty ? 'Loading…' : _status)) + : SickDaysBody( + upcoming: [ + for (final day in pool.sickDays()) + if (!day.isBefore(_today)) day, + ], + status: _status, + refused: _refused, + calendar: DayCalendar( + month: _month, + today: _today, + isMarked: pool.isSick, + busy: _busy, + todayTappable: true, + keyPrefix: 'sick-day', + markColor: marks.sick, + onMarkColor: marks.onSick, + onTapDay: (day) => unawaited(_toggle(day)), + onMonth: (month) => setState(() => _month = month), + // Back to January for this year's history; forward to the + // end of next year, the same horizon as the free days. + firstMonth: DateTime(_today.year), + lastMonth: DateTime(_today.year + 1, 12), + ), + ), + ); + } +} diff --git a/phone_app/lib/services/alarm_scheduler.dart b/phone_app/lib/services/alarm_scheduler.dart index ff30112..6ff78f1 100644 --- a/phone_app/lib/services/alarm_scheduler.dart +++ b/phone_app/lib/services/alarm_scheduler.dart @@ -132,6 +132,11 @@ class AlarmScheduler { Future setFreeDays(List isoDates) => _channel .invokeMethod('setFreeDays', {'dates': isoDates}); + /// Hands the native ring path the current set of sick ISO dates: the + /// same kind of cache as [setFreeDays], kept separately (`SickDays.kt`). + Future setSickDays(List isoDates) => _channel + .invokeMethod('setSickDays', {'dates': isoDates}); + /// 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/lib/services/day_pool_refresh.dart b/phone_app/lib/services/day_pool_refresh.dart new file mode 100644 index 0000000..16d99a8 --- /dev/null +++ b/phone_app/lib/services/day_pool_refresh.dart @@ -0,0 +1,76 @@ +import 'dart:developer' as developer; +import 'dart:ui' show DartPluginRegistrant; + +import 'package:flutter/services.dart'; +import 'package:flutter/widgets.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'; +import 'package:wake_alarm_sync/services/sick_day_sync.dart'; + +/// Channel `PoolSyncEngine.kt` listens on for `done`. +const poolSyncChannelName = 'com.kuhy.wake_alarm_sync/poolsync'; + +/// Pulls both day pools and rewrites both native ring-path caches. +/// +/// The two run side by side: the pre-alarm sync may only have Doze's short +/// network window. Silent, and never throws -- each failure is logged and +/// swallowed, and an unreadable pool leaves the cache as it was (the alarm +/// rings on anything it cannot read). +Future refreshDayPools({ + Future Function()? openFree, + Future Function()? openSick, + FreeDaySync? freeSync, + SickDaySync? sickSync, +}) async { + Future guarded(String pool, Future Function() run) async { + try { + await run(); + } on Exception catch (e, stack) { + developer.log( + '$pool pool unavailable: $e', + name: 'WakeAlarmSync', + error: e, + stackTrace: stack, + ); + } + } + + await Future.wait([ + guarded('free-day', () async { + final pool = await (openFree ?? FreeDayPool.open)(); + await (freeSync ?? FreeDaySync()).sync(pool); + }), + guarded('sick-day', () async { + final pool = await (openSick ?? SickDayPool.open)(); + await (sickSync ?? SickDaySync()).sync(pool); + }), + ]); +} + +/// The pre-alarm headless engine's whole job: [refreshDayPools], then say +/// `done` -- whatever happened, or the native side keeps the engine (and +/// the broadcast it holds open) until its timeout. +/// +/// Started natively by `PoolSyncReceiver` (`poolSyncMain` in `main.dart`) +/// in the evening and shortly before the alarm, so a day marked on the PC +/// reaches the ring path without the app being opened. +Future runHeadlessPoolSync({ + Future Function()? refresh, + MethodChannel? channel, +}) async { + WidgetsFlutterBinding.ensureInitialized(); + // A non-`main` entrypoint does not register Dart-side plugin + // implementations (path_provider, secure storage) by itself. + DartPluginRegistrant.ensureInitialized(); + try { + await (refresh ?? refreshDayPools)(); + } finally { + try { + await (channel ?? const MethodChannel(poolSyncChannelName)) + .invokeMethod('done'); + } on Object catch (e) { + developer.log('pool-sync done not delivered: $e', name: 'WakeAlarmSync'); + } + } +} diff --git a/phone_app/lib/services/day_pool_sync.dart b/phone_app/lib/services/day_pool_sync.dart new file mode 100644 index 0000000..acffbe5 --- /dev/null +++ b/phone_app/lib/services/day_pool_sync.dart @@ -0,0 +1,76 @@ +import 'dart:developer' as developer; + +import 'package:crdt_sync/crdt_sync.dart'; + +/// One pull/merge/push tick for a day pool (free days, sick days), then a +/// refresh of that pool's native ring-path cache. +/// +/// Shared by `FreeDaySync` and `SickDaySync`, which differ only in where +/// the log lives and which cache it feeds. Firebase only: neither namespace +/// ever had a GitHub mirror. +/// +/// Returns a status line for the screen. Never throws: this also runs +/// unattended (app launch, the pre-alarm sync), where a dead network must +/// degrade to a message. [publish] runs whatever happened, so a day marked +/// offline still reaches the ring path. +Future syncDayPoolLog({ + required Future Function() openClient, + required String deviceId, + required String pathPrefix, + required String filename, + required String commitMessage, + required Log Function() snapshot, + required Future Function(Log merged) replaceAll, + required Future Function() publish, + required String logName, +}) async { + final RemoteStore? client; + try { + client = await openClient(); + } on Object catch (error, stackTrace) { + developer.log( + 'could not open Firebase', + name: logName, + error: error, + stackTrace: stackTrace, + ); + await publish(); + return 'Error: $error'; + } + if (client == null) { + await publish(); + return 'Not synced — connect Firebase in Settings.'; + } + try { + final merged = await syncLog( + client: client, + deviceId: deviceId, + pathPrefix: pathPrefix, + filename: filename, + localLog: snapshot(), + encode: logToJson, + decode: logFromJson, + commitMessage: commitMessage, + ); + await replaceAll(merged); + return 'Synced ✓'; + } on Exception catch (e) { + return 'Error: $e'; + } finally { + await publish(); + } +} + +/// Runs [write] (a native-cache write) and swallows a missing platform: +/// widget tests and probe entry points have no channel, the Dart pool is +/// still correct, and the cache is rewritten on the next sync anyway. +Future publishQuietly( + Future Function() write, { + required String logName, +}) async { + try { + await write(); + } on Object catch (error) { + developer.log('native cache not written: $error', name: logName); + } +} diff --git a/phone_app/lib/services/enforcement_state.dart b/phone_app/lib/services/enforcement_state.dart index 53431a5..6225687 100644 --- a/phone_app/lib/services/enforcement_state.dart +++ b/phone_app/lib/services/enforcement_state.dart @@ -14,6 +14,8 @@ class EnforcementState { required this.freeToday, required this.freeTomorrow, required this.streak, + this.sickToday = false, + this.sickTomorrow = false, this.alarmHour, this.alarmMinute, this.lockedThrough, @@ -36,6 +38,8 @@ class EnforcementState { escapedToday: map['escapedToday'] == true, freeToday: map['freeToday'] == true, freeTomorrow: map['freeTomorrow'] == true, + sickToday: map['sickToday'] == true, + sickTomorrow: map['sickTomorrow'] == true, streak: (map['streak'] as int?) ?? 0, lockedThrough: _date(map['lockedThrough']), lastMiss: _date(map['lastMiss']), @@ -66,6 +70,12 @@ class EnforcementState { /// Whether tomorrow is a free day in the native cache. final bool freeTomorrow; + /// Whether today is a sick day in the native cache. + final bool sickToday; + + /// Whether tomorrow is a sick day in the native cache. + final bool sickTomorrow; + /// Consecutive stick-day misses. final int streak; diff --git a/phone_app/lib/services/free_day_sync.dart b/phone_app/lib/services/free_day_sync.dart index 4c5eaa0..28fcae6 100644 --- a/phone_app/lib/services/free_day_sync.dart +++ b/phone_app/lib/services/free_day_sync.dart @@ -1,7 +1,6 @@ -import 'dart:developer' as developer; - import 'package:crdt_sync/crdt_sync.dart'; import 'package:wake_alarm_sync/services/alarm_scheduler.dart'; +import 'package:wake_alarm_sync/services/day_pool_sync.dart'; import 'package:wake_alarm_sync/services/firebase_backend.dart'; import 'package:wake_alarm_sync/services/free_day_pool.dart'; @@ -27,62 +26,24 @@ class FreeDaySync { /// Pulls, merges and pushes [pool], then refreshes the native cache. /// - /// Returns a status line for the screen. Never throws: this also runs - /// unattended at launch, where a dead network must degrade to a message. - /// The native cache is refreshed even when the sync could not run, so a - /// day marked offline still stands the phone down. - Future sync(FreeDayPool pool) async { - final RemoteStore? client; - try { - client = await _openClient(); - } on Object catch (error, stackTrace) { - developer.log( - 'free-day sync: could not open Firebase', - name: 'FreeDaySync', - error: error, - stackTrace: stackTrace, - ); - await publishToNative(pool); - return 'Error: $error'; - } - if (client == null) { - await publishToNative(pool); - return 'Not synced — connect Firebase in Settings.'; - } - try { - final merged = await syncLog( - client: client, - deviceId: pool.deviceId, - pathPrefix: FreeDayPool.pathPrefix, - filename: FreeDayPool.filename, - localLog: pool.snapshot(), - encode: logToJson, - decode: logFromJson, - commitMessage: 'freedays: update pool', - ); - await pool.replaceAll(merged); - return 'Synced ✓'; - } on Exception catch (e) { - return 'Error: $e'; - } finally { - await publishToNative(pool); - } - } + /// Never throws; see [syncDayPoolLog]. The native cache is refreshed even + /// when the sync could not run, so a day marked offline still stands the + /// phone down. + Future sync(FreeDayPool pool) => syncDayPoolLog( + openClient: _openClient, + deviceId: pool.deviceId, + pathPrefix: FreeDayPool.pathPrefix, + filename: FreeDayPool.filename, + commitMessage: 'freedays: update pool', + snapshot: pool.snapshot, + replaceAll: pool.replaceAll, + publish: () => publishToNative(pool), + logName: 'FreeDaySync', + ); /// Writes the pool's free dates where `AlarmReceiver` can read them. - /// - /// Swallows a missing platform (widget tests, a probe entry point): the - /// Dart pool is still correct, and the cache is rewritten on the next - /// launch anyway. - Future publishToNative(FreeDayPool pool) async { - try { - await _scheduler.setFreeDays(pool.freeIsoDates()); - } on Object catch (error) { - developer.log( - 'free-day cache not written: $error', - name: 'FreeDaySync', - error: error, - ); - } - } + Future publishToNative(FreeDayPool pool) => publishQuietly( + () => _scheduler.setFreeDays(pool.freeIsoDates()), + logName: 'FreeDaySync', + ); } diff --git a/phone_app/lib/services/sick_day_pool.dart b/phone_app/lib/services/sick_day_pool.dart new file mode 100644 index 0000000..77f08db --- /dev/null +++ b/phone_app/lib/services/sick_day_pool.dart @@ -0,0 +1,162 @@ +import 'dart:io'; + +import 'package:crdt_sync/crdt_sync.dart'; +import 'package:crdt_sync/crdt_sync_io.dart'; +import 'package:path/path.dart' as p; +import 'package:path_provider/path_provider.dart'; +import 'package:wake_alarm_sync/services/alarm_sync_service.dart'; +import 'package:wake_alarm_sync/services/free_day_pool.dart'; + +/// The shared sick-day pool, as the phone sees it. +/// +/// A Dart port of `~/src/utils/sickdays` (Python), which owns the rules: +/// every day is normal until marked sick; only today or a later day can be +/// marked or cleared; no cap, no reason, no countdown; and a free day is +/// never also a sick day. The record shape is the Python package's wire +/// format: the record id *is* the ISO date, fields `state` (`sick` / +/// `cleared`) and `actor`, both stamped with one HLC tick. Clearing flips +/// `state` rather than tombstoning, because crdt tombstones are monotonic +/// and a deleted date could never be marked sick again. +/// +/// This is not the free-day pool: its own file, its own remote node +/// (`sickdays-sync/devices`), its own native cache. Nothing here touches +/// the network; `sick_day_sync.dart` does that. +class SickDayPool { + /// Wraps an already-loaded store writing as [deviceId]. + SickDayPool(this._store, {required this.deviceId}); + + final LogStore _store; + + /// The device uuid stamped as `actor` on every write. + final String deviceId; + + /// Remote layout shared with the Python package: `//`. + static const pathPrefix = 'sickdays-sync/devices'; + + /// The per-device file name under [pathPrefix]. + static const filename = 'sick_days.json'; + + static const _stateField = 'state'; + static const _actorField = 'actor'; + static const _sick = 'sick'; + static const _cleared = 'cleared'; + + /// Opens the on-disk pool under the install's one device uuid (the same + /// one the alarm and free-day stores write as). + static Future open({ + LogPersistence? persistence, + String? deviceId, + }) async { + final backend = persistence ?? await _defaultPersistence(); + final id = deviceId ?? await AlarmSyncService.loadDeviceId(); + final store = LogStore(persistence: backend, nodeId: id); + await store.load(); + return SickDayPool(store, deviceId: id); + } + + static Future _defaultPersistence() async { + final dir = await getApplicationDocumentsDirectory(); + return FileLogPersistence(File(p.join(dir.path, 'sick_days_log.json'))); + } + + /// The whole log, for `syncLog`. + Log snapshot() => _store.snapshot(); + + /// Replaces the log with a merged result and persists it. + Future replaceAll(Log merged) => _store.replaceAll(merged); + + /// Anything not understood -- a tombstone, a missing or unknown state -- + /// reads as "not sick", exactly like `sickdays.is_sick_record`. + static bool _isSick(Record? record) { + if (record == null || record.deleted) return false; + return record.fields[_stateField]?.$1 == _sick; + } + + /// Whether [day] is a sick day. + bool isSick(DateTime day) => + _isSick(_store.get(FreeDayPool.toIso(FreeDayPool.dateOnly(day)))); + + /// Every currently-sick date, ascending. + List sickDays() { + final days = [ + for (final record in _store.values) + if (_isSick(record)) ?FreeDayPool.parseIso(record.id), + ]; + return days..sort(); + } + + /// Every ISO date currently sick, past ones included, for the native + /// cache: the stick skips past sick days when it walks back the streak. + List sickIsoDates() => sickDays().map(FreeDayPool.toIso).toList(); + + /// Marks [day] sick, everywhere, for every app. + /// + /// Throws [SickDayPolicyError] for a past day, a day already sick, or a + /// day [isFree] says is in the free-day pool. [now] is injectable. + Future add( + DateTime day, { + required bool Function(DateTime day) isFree, + DateTime? now, + }) async { + final id = _checkNotPast(day, now, 'marked'); + if (_isSick(_store.get(id))) { + throw SickDayPolicyError('$id is already a sick day.'); + } + if (isFree(FreeDayPool.dateOnly(day))) { + throw SickDayPolicyError( + '$id is a free day — release it in Free days first.', + ); + } + await _write(id, sick: true); + } + + /// Clears [day] back to a normal day. + /// + /// Throws [SickDayPolicyError] for a past day or a day that is not sick. + Future remove(DateTime day, {DateTime? now}) async { + final id = _checkNotPast(day, now, 'cleared'); + if (!_isSick(_store.get(id))) { + throw SickDayPolicyError('$id is not a sick day.'); + } + await _write(id, sick: false); + } + + /// The ISO id of [day], or a refusal when it is before [now]'s date. + String _checkNotPast(DateTime day, DateTime? now, String verb) { + final target = FreeDayPool.dateOnly(day); + final reference = FreeDayPool.dateOnly(now ?? DateTime.now()); + final id = FreeDayPool.toIso(target); + if (target.isBefore(reference)) { + throw SickDayPolicyError( + '$id has already passed — only today or later can be $verb.', + ); + } + return id; + } + + /// Both fields share one tick, as the Python writer does. + Future _write(String id, {required bool sick}) async { + final hlc = _store.nextHlc(); + await _store.upsert( + Record( + id: id, + fields: { + _stateField: (sick ? _sick : _cleared, hlc), + _actorField: (deviceId, hlc), + }, + ), + ); + } +} + +/// An add or remove the pool's rules refuse. The message is for the user. +class SickDayPolicyError implements Exception { + /// Creates the refusal with its user-facing [message]. + SickDayPolicyError(this.message); + + /// Why the pool said no. + final String message; + + @override + String toString() => message; +} diff --git a/phone_app/lib/services/sick_day_sync.dart b/phone_app/lib/services/sick_day_sync.dart new file mode 100644 index 0000000..c042850 --- /dev/null +++ b/phone_app/lib/services/sick_day_sync.dart @@ -0,0 +1,43 @@ +import 'package:crdt_sync/crdt_sync.dart'; +import 'package:wake_alarm_sync/services/alarm_scheduler.dart'; +import 'package:wake_alarm_sync/services/day_pool_sync.dart'; +import 'package:wake_alarm_sync/services/firebase_backend.dart'; +import 'package:wake_alarm_sync/services/sick_day_pool.dart'; + +/// Moves the sick-day pool between this phone and every other device, over +/// `sickdays-sync/devices` -- the node `sickdays sync` on the PC uses. +/// +/// Every sync ends by handing the merged pool to the native ring path +/// ([AlarmScheduler.setSickDays]): that cache, not the Dart store, is what +/// `AlarmReceiver` reads when it decides whether to ring. +class SickDaySync { + /// Creates a syncer; both seams are injectable for tests. + SickDaySync({ + Future Function()? openClient, + AlarmScheduler? scheduler, + }) : _openClient = openClient ?? openFirebase, + _scheduler = scheduler ?? AlarmScheduler(); + + final Future Function() _openClient; + final AlarmScheduler _scheduler; + + /// Pulls, merges and pushes [pool], then refreshes the native cache. + /// Never throws; see [syncDayPoolLog]. + Future sync(SickDayPool pool) => syncDayPoolLog( + openClient: _openClient, + deviceId: pool.deviceId, + pathPrefix: SickDayPool.pathPrefix, + filename: SickDayPool.filename, + commitMessage: 'sickdays: update pool', + snapshot: pool.snapshot, + replaceAll: pool.replaceAll, + publish: () => publishToNative(pool), + logName: 'SickDaySync', + ); + + /// Writes the pool's sick dates where `AlarmReceiver` can read them. + Future publishToNative(SickDayPool pool) => publishQuietly( + () => _scheduler.setSickDays(pool.sickIsoDates()), + logName: 'SickDaySync', + ); +} diff --git a/phone_app/lib/services/today_stakes.dart b/phone_app/lib/services/today_stakes.dart index 71a091e..5c8ed32 100644 --- a/phone_app/lib/services/today_stakes.dart +++ b/phone_app/lib/services/today_stakes.dart @@ -50,14 +50,21 @@ StakesView describeStakes({ final day = todayOpen ? today : _plusDays(today, 1); final alarm = todayOpen ? stored : (nextAlarm ?? stored); final free = todayOpen ? state.freeToday : state.freeTomorrow; + final sick = todayOpen ? state.sickToday : state.sickTomorrow; final when = todayOpen ? 'Today' : 'Tomorrow'; final stick = isWorkday(day); - final kind = free ? 'free day' : (stick ? 'stick day' : 'carrot day'); + final kind = free + ? 'free day' + : sick + ? 'sick day' + : (stick ? 'stick day' : 'carrot day'); final lines = []; if (alarm == null) { lines.add('No alarm armed yet — nothing at stake.'); } else if (free) { lines.add('Free day — no alarm, nothing at stake.'); + } else if (sick) { + lines.add('Sick day — no alarm, nothing at stake.'); } else { final ringAt = _at(day, alarm); if (stick) { @@ -93,11 +100,14 @@ StakesView describeStakes({ } /// The ring screen's one line: the stick-day consequence of not dismissing, -/// or null when this ring has none (not a stick day, free, already +/// or null when this ring has none (not a stick day, free, sick, already /// dismissed, excused or missed). String? ringStakesLine(EnforcementState state) { final stored = _stored(state); - if (stored == null || !isWorkday(state.today) || state.freeToday) { + if (stored == null || + !isWorkday(state.today) || + state.freeToday || + state.sickToday) { return null; } if (state.dismissedToday || state.escapedToday) return null; diff --git a/phone_app/lib/ui/theme.dart b/phone_app/lib/ui/theme.dart index b2f8d69..08b50e0 100644 --- a/phone_app/lib/ui/theme.dart +++ b/phone_app/lib/ui/theme.dart @@ -39,7 +39,7 @@ ThemeData buildAppTheme() { useMaterial3: true, colorScheme: colorScheme, scaffoldBackgroundColor: colorScheme.surface, - extensions: const [AppStatusColors.dark], + extensions: const [AppStatusColors.dark, DayMarkColors.dark], // No custom shadow/elevation exists elsewhere in this app — the one // Card (_StatusCard) is the sole place M3's default elevation would // otherwise introduce a shadow in an all-dark UI (rule 26). @@ -69,11 +69,10 @@ class AppStatusColors extends ThemeExtension { final Color warning; @override - AppStatusColors copyWith({Color? success, Color? warning}) => - AppStatusColors( - success: success ?? this.success, - warning: warning ?? this.warning, - ); + AppStatusColors copyWith({Color? success, Color? warning}) => AppStatusColors( + success: success ?? this.success, + warning: warning ?? this.warning, + ); @override AppStatusColors lerp(AppStatusColors? other, double t) { @@ -84,3 +83,41 @@ class AppStatusColors extends ThemeExtension { ); } } + +/// The fill of a marked day on the two day-pool calendars. +/// +/// Free days keep `accent` (the theme's primary). Sick days take `cat-2` +/// from the shared categorical ramp: hue-separated from the warm accent / +/// success / warning / danger wedge by design, so a sick day can never be +/// read as a free day (accent), a warning or an error. Text on it is +/// `on-fill` (`ink`, 4.7:1), never `text-on-dark` (3.0:1). +@immutable +class DayMarkColors extends ThemeExtension { + /// Creates a day-mark color set. + const DayMarkColors({required this.sick, required this.onSick}); + + /// The shared dark-theme instance. + static const dark = DayMarkColors( + sick: Color(0xFF398FC0), // cat-2 + onSick: Color(0xFF211D1B), // on-fill + ); + + /// A sick day's fill. + final Color sick; + + /// Text on [sick]. + final Color onSick; + + @override + DayMarkColors copyWith({Color? sick, Color? onSick}) => + DayMarkColors(sick: sick ?? this.sick, onSick: onSick ?? this.onSick); + + @override + DayMarkColors lerp(DayMarkColors? other, double t) { + if (other is! DayMarkColors) return this; + return DayMarkColors( + sick: Color.lerp(sick, other.sick, t) ?? sick, + onSick: Color.lerp(onSick, other.onSick, t) ?? onSick, + ); + } +} diff --git a/phone_app/scripts/sick_days_interop/__init__.py b/phone_app/scripts/sick_days_interop/__init__.py new file mode 100644 index 0000000..f2fd34f --- /dev/null +++ b/phone_app/scripts/sick_days_interop/__init__.py @@ -0,0 +1 @@ +"""Dart/Python cross-check for the sick-day pool (see check.sh).""" diff --git a/phone_app/scripts/sick_days_interop/check.sh b/phone_app/scripts/sick_days_interop/check.sh new file mode 100755 index 0000000..d81cd45 --- /dev/null +++ b/phone_app/scripts/sick_days_interop/check.sh @@ -0,0 +1,57 @@ +#!/bin/bash + +# ============================================================================ +# Cross-language check for the sick-day pool. +# +# phone_app/lib/services/sick_day_pool.dart writes the wire format that +# ~/src/utils/sickdays (Python) owns. This writes a pool through the Dart +# rules and reads it back through the Python ones, and fails on any +# disagreement about which days are sick. +# +# Requires a Python with sickdays importable: SICKDAYS_PYTHON, else the venv +# sickdays/install.sh creates. +# +# Usage: phone_app/scripts/sick_days_interop/check.sh +# ============================================================================ + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +APP_DIR="$(cd "$SCRIPT_DIR/../.." && pwd)" +readonly SCRIPT_DIR APP_DIR +readonly SICKDAYS_PYTHON="${SICKDAYS_PYTHON:-$HOME/.local/share/sickdays/venv/bin/python}" + +TEMP_DIR="" +cleanup() { + if [[ -n "$TEMP_DIR" && -d "$TEMP_DIR" ]]; then + rm -rf "$TEMP_DIR" + fi +} +trap cleanup EXIT + +validate_requirements() { + if [[ ! -x "$SICKDAYS_PYTHON" ]]; then + echo "Error: no sickdays python at $SICKDAYS_PYTHON (set SICKDAYS_PYTHON)" >&2 + exit 1 + fi + command -v flutter >/dev/null || { echo "Error: flutter not on PATH" >&2; exit 1; } +} + +main() { + validate_requirements + TEMP_DIR="$(mktemp -d)" + local pool="$TEMP_DIR/sick_days.json" + + echo "== sick-day interop: Dart writes ==" + ( + cd "$APP_DIR" + SICK_DAYS_INTEROP_OUT="$pool" flutter test test/sick_day_interop_writer_test.dart \ + | tail -1 + ) + [[ -s "$pool" ]] || { echo "Error: Dart wrote no fixture" >&2; exit 1; } + + echo "== sick-day interop: Python reads ==" + "$SICKDAYS_PYTHON" -I "$SCRIPT_DIR/verify.py" "$pool" +} + +main "$@" diff --git a/phone_app/scripts/sick_days_interop/verify.py b/phone_app/scripts/sick_days_interop/verify.py new file mode 100644 index 0000000..8040916 --- /dev/null +++ b/phone_app/scripts/sick_days_interop/verify.py @@ -0,0 +1,84 @@ +"""Read a Dart-written sick-day pool with the Python package and compare. + +``phone_app/lib/services/sick_day_pool.dart`` writes the wire format that +``~/src/utils/sickdays`` owns. The Python package is the oracle: it replays +the writer test's edits into a scratch pool, and every reading a gate relies +on (``is_sick_day`` per date, ``sick_days``) has to agree between the two +files. Reads only; writes nothing outside a temporary directory. +""" + +from __future__ import annotations + +from datetime import date +import json +from pathlib import Path +import sys +import tempfile + +import sickdays +from sickdays._paths import Paths + +EXPECTED_ARGC = 2 +TODAY = date(2026, 10, 16) +DAYS = [TODAY, date(2026, 10, 20), date(2026, 10, 21), date(2026, 10, 23)] + + +def _never_free(_day: date) -> bool: + return False + + +def python_pool(root: Path) -> Path: + """Write the oracle pool with the edits the Dart writer made.""" + paths = Paths.under(root) + sickdays.add_sick_day(TODAY, is_free=_never_free, paths=paths, now=TODAY) + sickdays.add_sick_day( + date(2026, 10, 20), is_free=_never_free, paths=paths, now=TODAY + ) + sickdays.add_sick_day( + date(2026, 10, 21), is_free=_never_free, paths=paths, now=TODAY + ) + sickdays.remove_sick_day(date(2026, 10, 21), paths=paths, now=TODAY) + return paths.log + + +def reading(pool: Path) -> dict[str, object]: + """Every view of a pool a gate relies on.""" + return { + "is_sick_day": { + d.isoformat(): sickdays.is_sick_day(d, log_path=pool) for d in DAYS + }, + "sick_days": sorted(d.isoformat() for d in sickdays.sick_days(log_path=pool)), + } + + +def one_tick_per_write(pool: Path) -> bool: + """Whether every record's fields share one HLC, as the format requires.""" + log = json.loads(pool.read_text(encoding="utf-8")) + return all( + len({hlc for _value, hlc in record["fields"].values()}) == 1 + and not record["deleted"] + for record in log.values() + ) + + +def main(argv: list[str]) -> int: + """Compare the Dart fixture at ``argv[1]`` with the Python oracle.""" + if len(argv) != EXPECTED_ARGC: + sys.stderr.write("usage: verify.py \n") + return 2 + dart = reading(Path(argv[1])) + with tempfile.TemporaryDirectory() as tmp: + python = reading(python_pool(Path(tmp))) + sys.stdout.write(f"dart: {json.dumps(dart)}\npython: {json.dumps(python)}\n") + if dart != python: + sys.stderr.write("MISMATCH: Dart and Python read different pools\n") + return 1 + if not one_tick_per_write(Path(argv[1])): + sys.stderr.write("MISMATCH: a Dart record has mixed HLCs or a tombstone\n") + return 1 + sys.stdout.write("OK: Dart and Python agree\n") + return 0 + + +if __name__ == "__main__": + sys.exit(main(sys.argv)) diff --git a/phone_app/test/fake_day_pool_store.dart b/phone_app/test/fake_day_pool_store.dart new file mode 100644 index 0000000..5c0b19b --- /dev/null +++ b/phone_app/test/fake_day_pool_store.dart @@ -0,0 +1,94 @@ +import 'package:crdt_sync/crdt_sync.dart'; + +/// An in-memory `LogPersistence`. +class MemPersistence implements LogPersistence { + /// The persisted text, or null before the first write. + String? text; + + @override + Future read() async => text; + + @override + Future write(String value) async => text = value; +} + +/// An in-memory `RemoteStore`: a flat path -> text map. +class MemRemote implements RemoteStore { + /// Every file, by full path. + final Map files = {}; + + @override + Future> listDirectory(String path) async { + final prefix = '$path/'; + return files.keys + .where((k) => k.startsWith(prefix)) + .map((k) => k.substring(prefix.length).split('/').first) + .toSet() + .toList(); + } + + @override + Future getFileText(String path) async => files[path]; + + @override + Future putFileText( + String path, + String text, { + required String message, + }) async => files[path] = text; + + @override + Future deleteFile(String path, {String? message}) async => + files.remove(path); + + @override + Future canAccessRemote() async => true; + + @override + void close() {} +} + +/// A remote that is offline for every read and write. +class BrokenRemote implements RemoteStore { + @override + Future> listDirectory(String path) async => + throw RemoteSyncError('offline'); + + @override + Future getFileText(String path) async => + throw RemoteSyncError('offline'); + + @override + Future putFileText( + String path, + String text, { + required String message, + }) async => throw RemoteSyncError('offline'); + + @override + Future deleteFile(String path, {String? message}) async {} + + @override + Future canAccessRemote() async => false; + + @override + void close() {} +} + +/// A log exactly as `sickdays` 0.1.0 (Python) writes it: 2026-10-20 sick, +/// 2026-10-21 marked and then cleared. Generated by `add_sick_day` / +/// `remove_sick_day` on 2026-10-16, not hand-written. +const pythonSickLog = + '{"2026-10-20": {"id": "2026-10-20", "fields": {"state": ["sick", ' + '"2026-10-16T11:13:53.005Z-0000-ad78e209-49cd-4989-9235-da5ec179f183"], ' + '"actor": ["ad78e209-49cd-4989-9235-da5ec179f183", ' + '"2026-10-16T11:13:53.005Z-0000-ad78e209-49cd-4989-9235-da5ec179f183"]}, ' + '"deleted": false, "deleted_hlc": null}, "2026-10-21": {"id": ' + '"2026-10-21", "fields": {"state": ["cleared", ' + '"2026-10-16T11:13:53.007Z-0000-ad78e209-49cd-4989-9235-da5ec179f183"], ' + '"actor": ["ad78e209-49cd-4989-9235-da5ec179f183", ' + '"2026-10-16T11:13:53.007Z-0000-ad78e209-49cd-4989-9235-da5ec179f183"]}, ' + '"deleted": false, "deleted_hlc": null}}'; + +/// The PC's device directory in [pythonSickLog]. +const pythonDeviceId = 'ad78e209-49cd-4989-9235-da5ec179f183'; diff --git a/phone_app/test/free_days_screen_test.dart b/phone_app/test/free_days_screen_test.dart index 9206813..c2a4e1c 100644 --- a/phone_app/test/free_days_screen_test.dart +++ b/phone_app/test/free_days_screen_test.dart @@ -8,6 +8,8 @@ import 'package:wake_alarm_sync/screens/free_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_pool.dart'; +import 'package:wake_alarm_sync/services/sick_day_sync.dart'; import 'package:wake_alarm_sync/ui/theme.dart'; class _MemPersistence implements LogPersistence { @@ -60,6 +62,12 @@ void main() { }, scheduler: scheduler, ), + openSickPool: () => + SickDayPool.open(persistence: _MemPersistence(), deviceId: 'phone'), + sickSyncer: SickDaySync( + openClient: () async => null, + scheduler: scheduler, + ), now: () => _today, ), ); diff --git a/phone_app/test/free_days_sick_conflict_test.dart b/phone_app/test/free_days_sick_conflict_test.dart new file mode 100644 index 0000000..646363a --- /dev/null +++ b/phone_app/test/free_days_sick_conflict_test.dart @@ -0,0 +1,109 @@ +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:wake_alarm_sync/screens/free_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_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'; + +final _today = DateTime(2026, 10, 16); + +/// The free-day picker's half of "a date is never in both pools". +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + late AlarmScheduler scheduler; + late FreeDayPool free; + + setUp(() async { + const channel = MethodChannel(AlarmScheduler.channelName); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async => null); + scheduler = AlarmScheduler(channel: channel); + free = await FreeDayPool.open( + persistence: MemPersistence(), + deviceId: 'phone', + ); + }); + + Future pumpScreen( + WidgetTester tester, + Future Function() openSick, + ) async { + tester.view + ..physicalSize = const Size(1080, 2400) + ..devicePixelRatio = 2.625; + addTearDown(tester.view.reset); + await tester.pumpWidget( + MaterialApp( + theme: buildAppTheme(), + home: FreeDaysScreen( + openPool: () async => free, + syncer: FreeDaySync( + openClient: () async => null, + scheduler: scheduler, + ), + openSickPool: openSick, + sickSyncer: SickDaySync( + openClient: () async => null, + scheduler: scheduler, + ), + now: () => _today, + ), + ), + ); + await tester.pumpAndSettle(); + } + + Future tapDay(WidgetTester tester, DateTime day) async { + final cell = find.byKey( + ValueKey('free-day-${FreeDayPool.toIso(day)}'), + ); + await tester.ensureVisible(cell); + await tester.tap(cell); + await tester.pumpAndSettle(); + await tester.drag(find.byType(ListView), const Offset(0, 3000)); + await tester.pumpAndSettle(); + } + + testWidgets('a sick day is refused, naming the sick-day pool', ( + tester, + ) async { + final sick = await SickDayPool.open( + persistence: MemPersistence(), + deviceId: 'phone', + ); + await sick.add(DateTime(2026, 10, 20), isFree: (_) => false, now: _today); + await pumpScreen(tester, () async => sick); + + await tapDay(tester, DateTime(2026, 10, 20)); + + expect(free.isFree(DateTime(2026, 10, 20)), isFalse); + expect( + find.text('2026-10-20 is a sick day — clear it in Sick days first.'), + findsOneWidget, + ); + // A normal day still books. + await tapDay(tester, DateTime(2026, 10, 21)); + expect(free.isFree(DateTime(2026, 10, 21)), isTrue); + }); + + testWidgets('an unreadable sick-day pool refuses every booking', ( + tester, + ) async { + await pumpScreen( + tester, + () async => throw const FileSystemException('corrupt'), + ); + await tapDay(tester, DateTime(2026, 10, 21)); + expect(free.isFree(DateTime(2026, 10, 21)), isFalse); + expect(find.textContaining('Sick-day pool unavailable'), findsOneWidget); + }); +} diff --git a/phone_app/test/services/day_pool_refresh_test.dart b/phone_app/test/services/day_pool_refresh_test.dart new file mode 100644 index 0000000..1b614cb --- /dev/null +++ b/phone_app/test/services/day_pool_refresh_test.dart @@ -0,0 +1,105 @@ +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:wake_alarm_sync/services/alarm_scheduler.dart'; +import 'package:wake_alarm_sync/services/day_pool_refresh.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'; +import 'package:wake_alarm_sync/services/sick_day_sync.dart'; + +import '../fake_day_pool_store.dart'; + +/// The PC→phone path with the app closed: what `poolSyncMain` runs. +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + late Map> caches; + late AlarmScheduler scheduler; + late List done; + const poolChannel = MethodChannel(poolSyncChannelName); + + setUp(() { + caches = {}; + done = []; + const channel = MethodChannel(AlarmScheduler.channelName); + final messenger = + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + ..setMockMethodCallHandler(channel, (call) async { + caches[call.method] = ((call.arguments as Map)['dates'] as List) + .cast(); + return null; + }) + ..setMockMethodCallHandler(poolChannel, (call) async { + done.add(call.method); + return null; + }); + addTearDown(() => messenger.setMockMethodCallHandler(poolChannel, null)); + scheduler = AlarmScheduler(channel: channel); + }); + + test('a sick day the PC pushed to RTDB reaches the native cache', () async { + // What `sickdays add 2026-10-20` + the PC's sync timer leave in RTDB. + final remote = MemRemote(); + remote.files['sickdays-sync/devices/$pythonDeviceId/sick_days.json'] = + pythonSickLog; + final sick = await SickDayPool.open( + persistence: MemPersistence(), + deviceId: 'phone', + ); + final free = await FreeDayPool.open( + persistence: MemPersistence(), + deviceId: 'phone', + ); + + await runHeadlessPoolSync( + refresh: () => refreshDayPools( + openFree: () async => free, + openSick: () async => sick, + freeSync: FreeDaySync( + openClient: () async => remote, + scheduler: scheduler, + ), + sickSync: SickDaySync( + openClient: () async => remote, + scheduler: scheduler, + ), + ), + ); + + expect(caches['setSickDays'], ['2026-10-20']); + expect(caches['setFreeDays'], isEmpty); + expect(done, ['done']); + }); + + test('a pool that cannot open leaves the other one syncing', () async { + final remote = MemRemote(); + remote.files['sickdays-sync/devices/pc/sick_days.json'] = pythonSickLog; + await refreshDayPools( + openFree: () async => throw const FormatException('corrupt'), + openSick: () => + SickDayPool.open(persistence: MemPersistence(), deviceId: 'phone'), + sickSync: SickDaySync( + openClient: () async => remote, + scheduler: scheduler, + ), + ); + expect(caches['setSickDays'], ['2026-10-20']); + expect(caches.containsKey('setFreeDays'), isFalse); + }); + + test('done is sent even when the refresh fails', () async { + await expectLater( + runHeadlessPoolSync(refresh: () async => throw StateError('boom')), + throwsStateError, + ); + expect(done, ['done']); + }); + + test('a missing done channel is swallowed', () async { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(poolChannel, (call) async { + throw PlatformException(code: 'gone'); + }); + await runHeadlessPoolSync(refresh: () async {}); + }); +} diff --git a/phone_app/test/services/sick_day_pool_test.dart b/phone_app/test/services/sick_day_pool_test.dart new file mode 100644 index 0000000..fcdcd2d --- /dev/null +++ b/phone_app/test/services/sick_day_pool_test.dart @@ -0,0 +1,146 @@ +import 'dart:convert'; + +import 'package:crdt_sync/crdt_sync.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:wake_alarm_sync/services/sick_day_pool.dart'; + +import '../fake_day_pool_store.dart'; + +final _today = DateTime(2026, 10, 16); +bool _never(DateTime _) => false; + +void main() { + late MemPersistence disk; + late SickDayPool pool; + + setUp(() async { + disk = MemPersistence(); + pool = await SickDayPool.open(persistence: disk, deviceId: 'phone-uuid'); + }); + + test('every day is normal until marked', () { + expect(pool.isSick(_today), isFalse); + expect(pool.sickDays(), isEmpty); + }); + + test('marks today and a future day, ascending, in the wire format', () async { + await pool.add(DateTime(2026, 10, 20), isFree: _never, now: _today); + await pool.add(_today, isFree: _never, now: _today); + + expect(pool.isSick(DateTime(2026, 10, 20, 14)), isTrue); + expect(pool.sickIsoDates(), ['2026-10-16', '2026-10-20']); + final json = jsonDecode(disk.text!) as Map; + final record = json['2026-10-20']! as Map; + final fields = record['fields']! as Map; + final state = fields['state']! as List; + final actor = fields['actor']! as List; + expect(state.first, 'sick'); + expect(actor.first, 'phone-uuid'); + expect(state.last, actor.last, reason: 'one HLC tick per write'); + expect(record['deleted'], isFalse); + }); + + test('clearing flips state to cleared and never tombstones', () async { + await pool.add(DateTime(2026, 10, 20), isFree: _never, now: _today); + await pool.remove(DateTime(2026, 10, 20), now: _today); + + expect(pool.isSick(DateTime(2026, 10, 20)), isFalse); + final record = pool.snapshot()['2026-10-20']!; + expect(record.deleted, isFalse); + expect(record.fields['state']!.$1, 'cleared'); + + // ...so it can be marked again. + await pool.add(DateTime(2026, 10, 20), isFree: _never, now: _today); + expect(pool.isSick(DateTime(2026, 10, 20)), isTrue); + }); + + test('refuses a past day for both add and remove', () async { + final yesterday = DateTime(2026, 10, 15); + await expectLater( + pool.add(yesterday, isFree: _never, now: _today), + throwsA( + isA().having( + (e) => e.message, + 'message', + contains('2026-10-15 has already passed'), + ), + ), + ); + await expectLater( + pool.remove(yesterday, now: _today), + throwsA(isA()), + ); + }); + + test('refuses an already-sick day and clearing a normal day', () async { + await pool.add(DateTime(2026, 10, 20), isFree: _never, now: _today); + await expectLater( + pool.add(DateTime(2026, 10, 20), isFree: _never, now: _today), + throwsA( + isA().having( + (e) => e.toString(), + 'toString', + '2026-10-20 is already a sick day.', + ), + ), + ); + await expectLater( + pool.remove(DateTime(2026, 10, 22), now: _today), + throwsA( + isA().having( + (e) => e.message, + 'message', + '2026-10-22 is not a sick day.', + ), + ), + ); + }); + + test('refuses a free day and names the free-day pool', () async { + await expectLater( + pool.add( + DateTime(2026, 10, 20), + isFree: (day) => day == DateTime(2026, 10, 20), + now: _today, + ), + throwsA( + isA().having( + (e) => e.message, + 'message', + '2026-10-20 is a free day — release it in Free days first.', + ), + ), + ); + expect(pool.isSick(DateTime(2026, 10, 20)), isFalse); + }); + + test('reads a log the Python package wrote', () async { + await pool.replaceAll(logFromJson(pythonSickLog)); + expect(pool.isSick(DateTime(2026, 10, 20)), isTrue); + expect(pool.isSick(DateTime(2026, 10, 21)), isFalse, reason: 'cleared'); + expect(pool.sickIsoDates(), ['2026-10-20']); + }); + + test('a tombstone, an unknown state or a junk id is not sick', () async { + final hlc = Hlc.newTick('pc'); + await pool.replaceAll({ + '2026-10-20': Record( + id: '2026-10-20', + fields: {'state': ('sick', hlc)}, + deleted: true, + deletedHlc: hlc, + ), + '2026-10-22': Record(id: '2026-10-22', fields: {'state': ('ill', hlc)}), + 'nonsense': Record(id: 'nonsense', fields: {'state': ('sick', hlc)}), + }); + expect(pool.isSick(DateTime(2026, 10, 20)), isFalse); + expect(pool.isSick(DateTime(2026, 10, 22)), isFalse); + expect(pool.sickIsoDates(), isEmpty); + }); + + test('persists across a reopen', () async { + await pool.add(DateTime(2026, 10, 20), isFree: _never, now: _today); + final again = await SickDayPool.open(persistence: disk, deviceId: 'x'); + expect(again.isSick(DateTime(2026, 10, 20)), isTrue); + }); +} diff --git a/phone_app/test/services/sick_day_sync_test.dart b/phone_app/test/services/sick_day_sync_test.dart new file mode 100644 index 0000000..ce2e0b0 --- /dev/null +++ b/phone_app/test/services/sick_day_sync_test.dart @@ -0,0 +1,118 @@ +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:wake_alarm_sync/services/alarm_scheduler.dart'; +import 'package:wake_alarm_sync/services/sick_day_pool.dart'; +import 'package:wake_alarm_sync/services/sick_day_sync.dart'; + +import '../fake_day_pool_store.dart'; + +final _today = DateTime(2026, 10, 16); +bool _never(DateTime _) => false; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + late List calls; + late AlarmScheduler scheduler; + var channelThrows = false; + + setUp(() { + calls = []; + channelThrows = false; + const channel = MethodChannel(AlarmScheduler.channelName); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + calls.add(call); + if (channelThrows) throw PlatformException(code: 'no_platform'); + return null; + }); + scheduler = AlarmScheduler(channel: channel); + }); + + Future openPool() => + SickDayPool.open(persistence: MemPersistence(), deviceId: 'phone'); + + List lastCache() { + expect(calls.last.method, 'setSickDays'); + return ((calls.last.arguments as Map)['dates'] as List).cast(); + } + + test("pulls the PC's sick day and pushes the phone's own", () async { + final remote = MemRemote(); + remote.files['${SickDayPool.pathPrefix}/$pythonDeviceId/' + '${SickDayPool.filename}'] = + pythonSickLog; + final pool = await openPool(); + await pool.add(DateTime(2026, 10, 23), isFree: _never, now: _today); + + final sync = SickDaySync( + openClient: () async => remote, + scheduler: scheduler, + ); + expect(await sync.sync(pool), 'Synced ✓'); + + expect(pool.isSick(DateTime(2026, 10, 20)), isTrue, reason: 'pulled'); + expect(pool.isSick(DateTime(2026, 10, 21)), isFalse, reason: 'cleared'); + final pushed = remote.files['sickdays-sync/devices/phone/sick_days.json']; + expect(pushed, contains('"2026-10-23"')); + expect(lastCache(), ['2026-10-20', '2026-10-23']); + }); + + test('a later clear on the phone beats an earlier PC mark', () async { + final remote = MemRemote(); + remote.files['${SickDayPool.pathPrefix}/$pythonDeviceId/' + '${SickDayPool.filename}'] = + pythonSickLog; + final pool = await openPool(); + final sync = SickDaySync( + openClient: () async => remote, + scheduler: scheduler, + ); + await sync.sync(pool); + await pool.remove(DateTime(2026, 10, 20), now: _today); + await sync.sync(pool); + expect(pool.isSick(DateTime(2026, 10, 20)), isFalse); + expect(lastCache(), isEmpty); + }); + + test('not connected to Firebase still refreshes the cache', () async { + final pool = await openPool(); + await pool.add(_today, isFree: _never, now: _today); + final sync = SickDaySync( + openClient: () async => null, + scheduler: scheduler, + ); + expect(await sync.sync(pool), contains('connect Firebase')); + expect(lastCache(), ['2026-10-16']); + }); + + test('an unreachable remote reports an error and keeps the cache', () async { + final pool = await openPool(); + await pool.add(_today, isFree: _never, now: _today); + final sync = SickDaySync( + openClient: () async => BrokenRemote(), + scheduler: scheduler, + ); + expect(await sync.sync(pool), startsWith('Error:')); + expect(lastCache(), ['2026-10-16']); + }); + + test('a client factory that throws is reported, not propagated', () async { + final pool = await openPool(); + final sync = SickDaySync( + openClient: () async => throw StateError('keystore locked'), + scheduler: scheduler, + ); + expect(await sync.sync(pool), contains('keystore locked')); + expect(lastCache(), isEmpty); + }); + + test('a missing platform channel does not fail the sync', () async { + channelThrows = true; + final sync = SickDaySync( + openClient: () async => MemRemote(), + scheduler: scheduler, + ); + expect(await sync.sync(await openPool()), 'Synced ✓'); + }); +} diff --git a/phone_app/test/services/today_stakes_sick_test.dart b/phone_app/test/services/today_stakes_sick_test.dart new file mode 100644 index 0000000..662cf93 --- /dev/null +++ b/phone_app/test/services/today_stakes_sick_test.dart @@ -0,0 +1,48 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:wake_alarm_sync/services/enforcement_state.dart'; +import 'package:wake_alarm_sync/services/today_stakes.dart'; + +import '../fake_enforcement_channel.dart'; + +/// 2026-10-01 is a Thursday: a stick day unless it is sick. +EnforcementState _state({bool sickToday = false, bool sickTomorrow = false}) => + EnforcementState.fromMap({ + ...nativeState(), + 'sickToday': sickToday, + 'sickTomorrow': sickTomorrow, + })!; + +void main() { + final thuMorning = DateTime(2026, 10, 1, 6, 50); + + test('the native sick flags are parsed, defaulting to false', () { + final state = _state(sickToday: true); + expect(state.sickToday, isTrue); + expect(state.sickTomorrow, isFalse); + expect(EnforcementState.fromMap(nativeState())!.sickToday, isFalse); + }); + + test('a sick stick day promises no lockdown and no PC ring', () { + final view = describeStakes( + now: thuMorning, + state: _state(sickToday: true), + ); + expect(view.title, 'Today · Thu 1 Oct — sick day'); + expect(view.stakes, ['Sick day — no alarm, nothing at stake.']); + }); + + test("tomorrow's sick day is announced after today's alarm", () { + final evening = DateTime(2026, 10, 1, 20); + final view = describeStakes( + now: evening, + state: _state(sickTomorrow: true), + ); + expect(view.title, 'Tomorrow · Fri 2 Oct — sick day'); + expect(view.stakes, ['Sick day — no alarm, nothing at stake.']); + }); + + test('the ring line is silent on a sick day', () { + expect(ringStakesLine(_state()), isNotNull); + expect(ringStakesLine(_state(sickToday: true)), isNull); + }); +} diff --git a/phone_app/test/sick_day_interop_writer_test.dart b/phone_app/test/sick_day_interop_writer_test.dart new file mode 100644 index 0000000..00a3ab8 --- /dev/null +++ b/phone_app/test/sick_day_interop_writer_test.dart @@ -0,0 +1,43 @@ +import 'dart:io'; + +import 'package:crdt_sync/crdt_sync.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:wake_alarm_sync/services/sick_day_pool.dart'; + +/// Half of the cross-language check (`scripts/sick_days_interop/check.sh`). +/// +/// Writes a pool through the Dart rules to `SICK_DAYS_INTEROP_OUT`; the +/// script reads it back with `sickdays` (Python) and replays the same edits +/// there. Under a plain `flutter test` the variable is unset and this does +/// nothing. +class _FilePersistence implements LogPersistence { + _FilePersistence(this.file); + + final File file; + + @override + Future read() async => + file.existsSync() ? file.readAsStringSync() : null; + + @override + Future write(String text) async => file.writeAsStringSync(text); +} + +void main() { + test('writes the interop fixture when asked', () async { + final out = Platform.environment['SICK_DAYS_INTEROP_OUT']; + if (out == null) return; + final pool = await SickDayPool.open( + persistence: _FilePersistence(File(out)), + deviceId: 'dart-interop', + ); + final now = DateTime(2026, 10, 16); + bool never(DateTime _) => false; + // Today sick; a future day sick; a future day marked then cleared; + // 2026-10-23 untouched. + await pool.add(now, isFree: never, now: now); + await pool.add(DateTime(2026, 10, 20), isFree: never, now: now); + await pool.add(DateTime(2026, 10, 21), isFree: never, now: now); + await pool.remove(DateTime(2026, 10, 21), now: now); + }); +} diff --git a/phone_app/test/sick_days_screen_test.dart b/phone_app/test/sick_days_screen_test.dart new file mode 100644 index 0000000..d5c2bb7 --- /dev/null +++ b/phone_app/test/sick_days_screen_test.dart @@ -0,0 +1,197 @@ +import 'dart:io'; + +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_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'; + +final _today = DateTime(2026, 10, 16); + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + late AlarmScheduler scheduler; + late SickDayPool sick; + late FreeDayPool free; + late int syncs; + late List> caches; + + setUp(() async { + caches = []; + 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(), + ); + } + return null; + }); + scheduler = AlarmScheduler(channel: channel); + sick = await SickDayPool.open( + persistence: MemPersistence(), + deviceId: 'phone', + ); + free = await FreeDayPool.open( + persistence: MemPersistence(), + deviceId: 'phone', + ); + syncs = 0; + }); + + void phoneSize(WidgetTester tester) { + tester.view + ..physicalSize = const Size(1080, 2400) + ..devicePixelRatio = 2.625; + addTearDown(tester.view.reset); + } + + Widget app({Future Function()? openFree}) => MaterialApp( + theme: buildAppTheme(), + home: SickDaysScreen( + openPool: () async => sick, + syncer: SickDaySync( + openClient: () async { + syncs++; + return null; + }, + scheduler: scheduler, + ), + openFreePool: openFree ?? () async => free, + freeSyncer: FreeDaySync( + openClient: () async => null, + scheduler: scheduler, + ), + now: () => _today, + ), + ); + + Finder cell(DateTime day) => + find.byKey(ValueKey('sick-day-${FreeDayPool.toIso(day)}')); + + Future tapDay(WidgetTester tester, DateTime day) async { + await tester.ensureVisible(cell(day)); + await tester.tap(cell(day)); + await tester.pumpAndSettle(); + } + + Future open(WidgetTester tester, {Widget? widget}) async { + phoneSize(tester); + await tester.pumpWidget(widget ?? app()); + await tester.pumpAndSettle(); + } + + testWidgets('every day starts normal; syncs on open', (tester) async { + await open(tester); + expect(find.text('Sick days'), findsOneWidget); + expect(find.byIcon(Icons.sick_outlined), findsOneWidget); + expect(find.text('October 2026'), findsOneWidget); + expect(find.text('None ahead.'), findsOneWidget); + expect(syncs, 1); + expect(caches.last, isEmpty); + }); + + testWidgets('today can be marked sick and cleared again', (tester) async { + await open(tester); + + await tapDay(tester, _today); + expect(sick.isSick(_today), isTrue); + expect(find.text('Fri 2026-10-16'), findsOneWidget); + expect(caches.last, ['2026-10-16'], reason: 'native cache rewritten'); + expect(syncs, 2); + + await tapDay(tester, _today); + expect(sick.isSick(_today), isFalse); + expect(caches.last, isEmpty); + expect(syncs, 3); + }); + + testWidgets('a future day is marked in the sick colour', (tester) async { + await open(tester); + await tapDay(tester, DateTime(2026, 10, 20)); + expect(sick.isSick(DateTime(2026, 10, 20)), isTrue); + final box = tester.widget(cell(DateTime(2026, 10, 20))); + expect(box.markColor, DayMarkColors.dark.sick); + expect(box.markColor, isNot(buildAppTheme().colorScheme.primary)); + }); + + testWidgets('past days are inert', (tester) async { + await open(tester); + await tapDay(tester, DateTime(2026, 10, 15)); + expect(sick.isSick(DateTime(2026, 10, 15)), isFalse); + expect(syncs, 1); + }); + + testWidgets('a free day is refused, naming the free-day pool', ( + tester, + ) async { + await free.mark(DateTime(2026, 10, 20), now: _today); + await open(tester); + + await tapDay(tester, DateTime(2026, 10, 20)); + + expect(sick.isSick(DateTime(2026, 10, 20)), isFalse); + await tester.drag(find.byType(ListView), const Offset(0, 3000)); + await tester.pumpAndSettle(); + expect( + find.text('2026-10-20 is a free day — release it in Free days first.'), + findsOneWidget, + ); + expect(syncs, 1); + }); + + testWidgets('an unreadable free-day pool refuses every mark', (tester) async { + await open( + tester, + widget: app(openFree: () async => throw const FileSystemException('x')), + ); + await tapDay(tester, DateTime(2026, 10, 20)); + expect(sick.isSick(DateTime(2026, 10, 20)), isFalse); + await tester.drag(find.byType(ListView), const Offset(0, 3000)); + await tester.pumpAndSettle(); + expect(find.textContaining('Free-day pool unavailable'), findsOneWidget); + }); + + testWidgets('month arrows reach January and the end of next year', ( + tester, + ) async { + await open(tester); + for (var i = 0; i < 9; i++) { + await tester.tap(find.byTooltip('Previous month')); + await tester.pumpAndSettle(); + } + expect(find.text('January 2026'), findsOneWidget); + for (var i = 0; i < 23; i++) { + await tester.tap(find.byTooltip('Next month')); + await tester.pumpAndSettle(); + } + expect(find.text('December 2027'), findsOneWidget); + await tester.tap(find.byTooltip('Sync now')); + await tester.pumpAndSettle(); + expect(syncs, 2); + }); + + testWidgets('a pool that cannot open reports it', (tester) async { + await tester.pumpWidget( + MaterialApp( + theme: buildAppTheme(), + home: SickDaysScreen( + openPool: () async => throw const FileSystemException('ro'), + now: () => _today, + ), + ), + ); + await tester.pumpAndSettle(); + expect(find.textContaining('Error: pool unavailable'), findsOneWidget); + }); +}