mirror of
https://github.com/kuhyx/wake-alarm.git
synced 2026-11-10 16:38:38 +01:00
Merge branch 'sickdays-phone' into sickdays-pc
# Conflicts: # AGENTS.md
This commit is contained in:
commit
29e64fb7fa
@ -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
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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
105
DOCS-sick-days.md
Normal 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.
|
||||
@ -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
|
||||
|
||||
@ -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")
|
||||
}
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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()
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
@ -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(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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())
|
||||
}
|
||||
}
|
||||
|
||||
@ -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() }
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
@ -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()
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
@ -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())
|
||||
}
|
||||
}
|
||||
@ -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) }
|
||||
|
||||
@ -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))
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -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))
|
||||
}
|
||||
}
|
||||
@ -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))
|
||||
}
|
||||
}
|
||||
@ -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.
|
||||
|
||||
@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
@ -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;
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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,
|
||||
|
||||
84
phone_app/lib/screens/sick_days_body.dart
Normal file
84
phone_app/lib/screens/sick_days_body.dart
Normal 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))),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
195
phone_app/lib/screens/sick_days_screen.dart
Normal file
195
phone_app/lib/screens/sick_days_screen.dart
Normal 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),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
|
||||
76
phone_app/lib/services/day_pool_refresh.dart
Normal file
76
phone_app/lib/services/day_pool_refresh.dart
Normal 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');
|
||||
}
|
||||
}
|
||||
}
|
||||
76
phone_app/lib/services/day_pool_sync.dart
Normal file
76
phone_app/lib/services/day_pool_sync.dart
Normal 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);
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
|
||||
|
||||
@ -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',
|
||||
);
|
||||
}
|
||||
|
||||
162
phone_app/lib/services/sick_day_pool.dart
Normal file
162
phone_app/lib/services/sick_day_pool.dart
Normal 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;
|
||||
}
|
||||
43
phone_app/lib/services/sick_day_sync.dart
Normal file
43
phone_app/lib/services/sick_day_sync.dart
Normal 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',
|
||||
);
|
||||
}
|
||||
@ -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;
|
||||
|
||||
@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
1
phone_app/scripts/sick_days_interop/__init__.py
Normal file
1
phone_app/scripts/sick_days_interop/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
"""Dart/Python cross-check for the sick-day pool (see check.sh)."""
|
||||
57
phone_app/scripts/sick_days_interop/check.sh
Executable file
57
phone_app/scripts/sick_days_interop/check.sh
Executable 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 "$@"
|
||||
84
phone_app/scripts/sick_days_interop/verify.py
Normal file
84
phone_app/scripts/sick_days_interop/verify.py
Normal 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))
|
||||
94
phone_app/test/fake_day_pool_store.dart
Normal file
94
phone_app/test/fake_day_pool_store.dart
Normal 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';
|
||||
@ -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,
|
||||
),
|
||||
);
|
||||
|
||||
109
phone_app/test/free_days_sick_conflict_test.dart
Normal file
109
phone_app/test/free_days_sick_conflict_test.dart
Normal 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);
|
||||
});
|
||||
}
|
||||
105
phone_app/test/services/day_pool_refresh_test.dart
Normal file
105
phone_app/test/services/day_pool_refresh_test.dart
Normal 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 {});
|
||||
});
|
||||
}
|
||||
146
phone_app/test/services/sick_day_pool_test.dart
Normal file
146
phone_app/test/services/sick_day_pool_test.dart
Normal 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);
|
||||
});
|
||||
}
|
||||
118
phone_app/test/services/sick_day_sync_test.dart
Normal file
118
phone_app/test/services/sick_day_sync_test.dart
Normal 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 ✓');
|
||||
});
|
||||
}
|
||||
48
phone_app/test/services/today_stakes_sick_test.dart
Normal file
48
phone_app/test/services/today_stakes_sick_test.dart
Normal 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);
|
||||
});
|
||||
}
|
||||
43
phone_app/test/sick_day_interop_writer_test.dart
Normal file
43
phone_app/test/sick_day_interop_writer_test.dart
Normal 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);
|
||||
});
|
||||
}
|
||||
197
phone_app/test/sick_days_screen_test.dart
Normal file
197
phone_app/test/sick_days_screen_test.dart
Normal 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);
|
||||
});
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user