Merge branch 'sickdays-phone' into sickdays-pc

# Conflicts:
#	AGENTS.md
This commit is contained in:
Krzysztof Rudnicki 2026-10-16 13:26:09 +02:00
commit 29e64fb7fa
52 changed files with 2563 additions and 168 deletions

View File

@ -71,8 +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`**. **Sick days** (`~/src/utils/sickdays`, never a free
day) stand the PC down too, via `_sick.py`: **`DOCS-wake-enforcement.md`**.
**`DOCS-free-days.md`**. Sick days (`~/src/utils/sickdays`, never a free day):
phone + pre-alarm sync **`DOCS-sick-days.md`**, PC **`DOCS-wake-enforcement.md`**.
## Scheduling — a per-minute timer

View File

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

View File

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

105
DOCS-sick-days.md Normal file
View File

@ -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`, after one
`flutter build apk` has written `gradlew` and `local.properties`.
- 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.

View File

@ -167,7 +167,7 @@ Phone (`phone_events.kind`): `alarm_fired` (data: `reason` =
`daily`|`rering`|`free_day`|`sick_day` — only `daily` is the morning's ring),
`ring_screen_opened`, `ring_started_published` (data: `date`, `source` =
`alarm`|`ring_screen`), `dismissed`, `stick_check` (data: `trigger` =
`exact`|`boot`|`watchdog`, `date` judged, `verdict`), `lockdown_sent` (data:
`exact`|`boot`|`watchdog`, `date` judged, `verdict`, incl. `free_day`|`sick_day`), `lockdown_sent` (data:
`until`, `streak`), `lift_sent`, `escape_used`, `boot`.
`ring_started` is published twice over: a headless Dart engine started by

View File

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

View File

@ -89,6 +89,11 @@
<receiver
android:name=".StickCheckReceiver"
android:exported="false"/>
<!-- The pre-alarm sync of the free/sick day pools (PoolSyncAlarm.kt);
not exported, only this app's own PendingIntent reaches it. -->
<receiver
android:name=".PoolSyncReceiver"
android:exported="false"/>
<receiver
android:name=".BootReceiver"
android:exported="true">

View File

@ -73,6 +73,16 @@ object AlarmChannel {
result.success(null)
}
}
// The sick-day twin of setFreeDays; see SickDays.kt.
"setSickDays" -> {
val dates = call.argument<List<String>>("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

View File

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

View File

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

View File

@ -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<String>?,
): Boolean = try {
read()?.contains(day.toString()) ?: false
} catch (e: RuntimeException) {
onError(e)
false
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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<String>) {
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())
}
}

View File

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

View File

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

View File

@ -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<RuntimeException>()
val boom = ClassCastException("not a string set")
assertFalse(DayCache.contains(day, onError = { seen += it }) { throw boom })
assertEquals(listOf<RuntimeException>(boom), seen)
}
}

View File

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

View File

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

View File

@ -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<LocalDate> = emptySet(),
free: Set<LocalDate> = 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))
}
}

View File

@ -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<void> main() async {
@pragma('vm:entry-point')
Future<void> 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<void> poolSyncMain() => runHeadlessPoolSync();
/// Root application widget.
class WakeAlarmSyncApp extends StatelessWidget {
/// Creates a [WakeAlarmSyncApp]; [newScheduler] is the probes' seam.

View File

@ -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 `<keyPrefix>-<yyyy-MM-dd>`.
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<String>('free-day-${FreeDayPool.toIso(date)}'),
final open = date.isAfter(today) || (todayTappable && date == today);
return DayCell(
key: ValueKey<String>('$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,
),
),
),

View File

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

View File

@ -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<FreeDayPool> 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<SickDayPool> 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<FreeDaysScreen> {
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<FreeDaysScreen> {
});
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<void> _loadSick() async {
try {
final sick = await (widget.openSickPool ?? SickDayPool.open)();
await (widget.sickSyncer ?? SickDaySync()).sync(sick);
_sick = sick;
} on Exception {
_sick = null;
}
}
Future<void> _sync() async {
final pool = _pool;
if (pool == null) return;
@ -95,10 +130,27 @@ class _FreeDaysScreenState extends State<FreeDaysScreen> {
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<void> _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<void> _release(DateTime day) async {
final pool = _pool;
if (pool == null) return;
@ -143,10 +195,10 @@ class _FreeDaysScreenState extends State<FreeDaysScreen> {
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),

View File

@ -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<HomeScreen> {
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<void> _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<HomeScreen> {
MaterialPageRoute<void>(builder: (_) => const FreeDaysScreen()),
),
),
IconButton(
icon: Icon(
Icons.sick_outlined,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
tooltip: 'Sick days',
onPressed: () => Navigator.push<void>(
context,
MaterialPageRoute<void>(builder: (_) => const SickDaysScreen()),
),
),
IconButton(
icon: Icon(
Icons.settings,

View File

@ -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<DateTime> 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<AppStatusColors>()!;
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))),
],
),
);
}
}

View File

@ -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<SickDayPool> Function()? openPool;
/// Syncs the sick-day pool; defaults to Firebase.
final SickDaySync? syncer;
/// Opens the free-day pool, for the conflict check.
final Future<FreeDayPool> Function()? openFreePool;
/// Syncs the free-day pool on open.
final FreeDaySync? freeSyncer;
/// The reference date, for tests.
final DateTime Function()? now;
@override
State<SickDaysScreen> createState() => _SickDaysScreenState();
}
class _SickDaysScreenState extends State<SickDaysScreen> {
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<void> _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<void> _loadFree() async {
try {
final free = await (widget.openFreePool ?? FreeDayPool.open)();
await (widget.freeSyncer ?? FreeDaySync()).sync(free);
_free = free;
} on Exception {
_free = null;
}
}
Future<void> _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<void> _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<DayMarkColors>()!;
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),
),
),
);
}
}

View File

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

View File

@ -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<void> refreshDayPools({
Future<FreeDayPool> Function()? openFree,
Future<SickDayPool> Function()? openSick,
FreeDaySync? freeSync,
SickDaySync? sickSync,
}) async {
Future<void> guarded(String pool, Future<void> 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<void> runHeadlessPoolSync({
Future<void> 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<void>('done');
} on Object catch (e) {
developer.log('pool-sync done not delivered: $e', name: 'WakeAlarmSync');
}
}
}

View File

@ -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<String> syncDayPoolLog({
required Future<RemoteStore?> Function() openClient,
required String deviceId,
required String pathPrefix,
required String filename,
required String commitMessage,
required Log Function() snapshot,
required Future<void> Function(Log merged) replaceAll,
required Future<void> 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<void> publishQuietly(
Future<void> Function() write, {
required String logName,
}) async {
try {
await write();
} on Object catch (error) {
developer.log('native cache not written: $error', name: logName);
}
}

View File

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

View File

@ -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<String> 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<String> 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<void> 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<void> publishToNative(FreeDayPool pool) => publishQuietly(
() => _scheduler.setFreeDays(pool.freeIsoDates()),
logName: 'FreeDaySync',
);
}

View File

@ -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: `<prefix>/<device>/<file>`.
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<SickDayPool> 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<LogPersistence> _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<void> 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<DateTime> sickDays() {
final days = <DateTime>[
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<String> 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<void> 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<void> 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<void> _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;
}

View File

@ -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<RemoteStore?> Function()? openClient,
AlarmScheduler? scheduler,
}) : _openClient = openClient ?? openFirebase,
_scheduler = scheduler ?? AlarmScheduler();
final Future<RemoteStore?> Function() _openClient;
final AlarmScheduler _scheduler;
/// Pulls, merges and pushes [pool], then refreshes the native cache.
/// Never throws; see [syncDayPoolLog].
Future<String> 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<void> publishToNative(SickDayPool pool) => publishQuietly(
() => _scheduler.setSickDays(pool.sickIsoDates()),
logName: 'SickDaySync',
);
}

View File

@ -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 = <String>[];
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;

View File

@ -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<AppStatusColors> {
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<AppStatusColors> {
);
}
}
/// 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<DayMarkColors> {
/// 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,
);
}
}

View File

@ -0,0 +1 @@
"""Dart/Python cross-check for the sick-day pool (see check.sh)."""

View File

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

View File

@ -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 <dart sick_days.json>\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))

View File

@ -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<String?> read() async => text;
@override
Future<void> 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<String, String> files = {};
@override
Future<List<String>> 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<String?> getFileText(String path) async => files[path];
@override
Future<void> putFileText(
String path,
String text, {
required String message,
}) async => files[path] = text;
@override
Future<void> deleteFile(String path, {String? message}) async =>
files.remove(path);
@override
Future<bool> canAccessRemote() async => true;
@override
void close() {}
}
/// A remote that is offline for every read and write.
class BrokenRemote implements RemoteStore {
@override
Future<List<String>> listDirectory(String path) async =>
throw RemoteSyncError('offline');
@override
Future<String?> getFileText(String path) async =>
throw RemoteSyncError('offline');
@override
Future<void> putFileText(
String path,
String text, {
required String message,
}) async => throw RemoteSyncError('offline');
@override
Future<void> deleteFile(String path, {String? message}) async {}
@override
Future<bool> 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';

View File

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

View File

@ -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<void> pumpScreen(
WidgetTester tester,
Future<SickDayPool> 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<void> tapDay(WidgetTester tester, DateTime day) async {
final cell = find.byKey(
ValueKey<String>('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);
});
}

View File

@ -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<String, List<String>> caches;
late AlarmScheduler scheduler;
late List<String> 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<String>();
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 {});
});
}

View File

@ -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<String, Object?>;
final record = json['2026-10-20']! as Map<String, Object?>;
final fields = record['fields']! as Map<String, Object?>;
final state = fields['state']! as List<Object?>;
final actor = fields['actor']! as List<Object?>;
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<SickDayPolicyError>().having(
(e) => e.message,
'message',
contains('2026-10-15 has already passed'),
),
),
);
await expectLater(
pool.remove(yesterday, now: _today),
throwsA(isA<SickDayPolicyError>()),
);
});
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<SickDayPolicyError>().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<SickDayPolicyError>().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<SickDayPolicyError>().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);
});
}

View File

@ -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<MethodCall> calls;
late AlarmScheduler scheduler;
var channelThrows = false;
setUp(() {
calls = <MethodCall>[];
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<SickDayPool> openPool() =>
SickDayPool.open(persistence: MemPersistence(), deviceId: 'phone');
List<String> lastCache() {
expect(calls.last.method, 'setSickDays');
return ((calls.last.arguments as Map)['dates'] as List).cast<String>();
}
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 ✓');
});
}

View File

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

View File

@ -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<String?> read() async =>
file.existsSync() ? file.readAsStringSync() : null;
@override
Future<void> 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);
});
}

View File

@ -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<List<String>> 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<String>(),
);
}
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<FreeDayPool> 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<String>('sick-day-${FreeDayPool.toIso(day)}'));
Future<void> tapDay(WidgetTester tester, DateTime day) async {
await tester.ensureVisible(cell(day));
await tester.tap(cell(day));
await tester.pumpAndSettle();
}
Future<void> 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<DayCell>(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);
});
}