|
1 | | -# Schedule Timer System |
| 1 | +# Schedule Timer & Preparation Runtime Flow (v2) |
2 | 2 |
|
3 | | -The OnTime app includes an automatic timer system within the `ScheduleBloc` that manages precise timing for schedule start notifications. This system ensures that schedule start events are triggered at the exact scheduled time. |
| 3 | +This document is the canonical runtime-flow reference for preparation timing in OnTime. |
| 4 | +It covers official schedule-start timing, early start, cache coherence, resume behavior, finish semantics, and verification scenarios. |
4 | 5 |
|
5 | | -## 🎯 Overview |
| 6 | +## Purpose and Scope |
6 | 7 |
|
7 | | -The timer system automatically: |
| 8 | +The runtime flow decides: |
8 | 9 |
|
9 | | -- Starts when an upcoming schedule is received |
10 | | -- Triggers a `ScheduleStarted` event at exactly x minute 00 seconds |
11 | | -- Handles proper cleanup and timer management |
12 | | -- Ensures thread-safety and prevents memory leaks |
| 10 | +- Which schedule state should be active (`initial`, `notExists`, `upcoming`, `ongoing`, `started`) |
| 11 | +- When to open `/scheduleStart` and `/alarmScreen` |
| 12 | +- How early-start sessions survive app restarts |
| 13 | +- When cached timed-preparation is valid, restored, or invalidated |
| 14 | +- How lateness is computed at finish time |
13 | 15 |
|
14 | | -## 🔄 Timer Flow |
| 16 | +## Core Invariants |
| 17 | + |
| 18 | +1. `step completion != lateness` |
| 19 | +- Completing all steps early does not mean late. |
| 20 | +- Lateness is decided only when finishing preparation (`ScheduleFinished`) relative to leave threshold. |
| 21 | + |
| 22 | +2. No route bounce after early start |
| 23 | +- If the user already early-started a schedule, official start callback must not reopen `/scheduleStart`. |
| 24 | + |
| 25 | +3. No stale cache resurrection after schedule mutation |
| 26 | +- Timed-preparation snapshots are valid only when `scheduleFingerprint` matches the current schedule payload. |
| 27 | +- Fingerprint mismatch clears persisted session/snapshot and resets to canonical preparation. |
| 28 | + |
| 29 | +## Runtime State Model |
| 30 | + |
| 31 | +### State meanings |
| 32 | + |
| 33 | +- `initial`: bloc boot state before schedule resolution. |
| 34 | +- `notExists`: no active schedule to prepare, or schedule already ended. |
| 35 | +- `upcoming`: current time is before `preparationStartTime`. |
| 36 | +- `ongoing`: current time is between `preparationStartTime` and `scheduleTime`. |
| 37 | +- `started`: preparation start flow is active (including early start path). |
| 38 | +- `isEarlyStarted`: flag on `ScheduleState` to mark early-started session context while in `started`. |
| 39 | + |
| 40 | +### State transitions |
15 | 41 |
|
16 | 42 | ```mermaid |
17 | | -sequenceDiagram |
18 | | - participant UC as UseCase |
19 | | - participant SB as ScheduleBloc |
20 | | - participant Timer as Timer |
21 | | - participant State as State |
22 | | -
|
23 | | - UC->>SB: ScheduleUpcomingReceived(schedule) |
24 | | - SB->>SB: Cancel existing timer |
25 | | - SB->>SB: _startScheduleTimer(schedule) |
26 | | - SB->>Timer: Create Timer(targetTime) |
27 | | - Note over Timer: Timer set for schedule.scheduleTime<br/>at x minute 00 seconds |
28 | | - SB->>State: emit(ScheduleState.upcoming/ongoing) |
29 | | -
|
30 | | - Timer-->>SB: Timer fires at exact minute |
31 | | - SB->>SB: add(ScheduleStarted()) |
32 | | - SB->>SB: _onScheduleStarted() |
33 | | - SB->>State: emit(ScheduleState.started) |
34 | | -
|
35 | | - Note over SB: Timer cleanup on close()<br/>or new schedule received |
| 43 | +stateDiagram-v2 |
| 44 | + [*] --> initial |
| 45 | + initial --> notExists: no schedule or ended schedule |
| 46 | + initial --> upcoming: now < preparationStartTime |
| 47 | + initial --> ongoing: preparationStartTime < now < scheduleTime |
| 48 | + initial --> started: early-start session restored |
| 49 | +
|
| 50 | + upcoming --> started: official start timer -> ScheduleStarted |
| 51 | + upcoming --> started: early start action -> SchedulePreparationStarted |
| 52 | + upcoming --> notExists: schedule ended/null |
| 53 | +
|
| 54 | + ongoing --> started: ScheduleStarted |
| 55 | + ongoing --> notExists: schedule ended/null |
| 56 | +
|
| 57 | + started --> started: official start callback while isEarlyStarted == true (no-op) |
| 58 | + started --> notExists: ScheduleFinished / schedule ended / schedule switch cleanup |
| 59 | +
|
| 60 | + notExists --> upcoming: new future schedule |
| 61 | + notExists --> ongoing: late entry into prep window |
36 | 62 | ``` |
37 | 63 |
|
38 | | -## 📋 Implementation Details |
| 64 | +## Entry Paths, Router Behavior, and Finish Semantics |
39 | 65 |
|
40 | | -### Key Components |
| 66 | +### Entry paths |
41 | 67 |
|
42 | | -1. **Timer Management** |
| 68 | +1. Official timer start |
| 69 | +- In `upcoming`, bloc starts a timer targeting `preparationStartTime`. |
| 70 | +- Timer callback dispatches `ScheduleStarted`. |
| 71 | +- If not early-started, `/scheduleStart` is pushed. |
43 | 72 |
|
44 | | - - `_scheduleStartTimer`: Dart Timer instance that handles the countdown |
45 | | - - `_currentScheduleId`: Tracks the active schedule to prevent stale events |
| 73 | +2. 5-minute variant start-now |
| 74 | +- On `/scheduleStart` (`isFiveMinutesBefore=true`), primary action dispatches `SchedulePreparationStarted` and navigates to `/alarmScreen`. |
| 75 | +- Secondary action navigates to `/home`. |
46 | 76 |
|
47 | | -2. **Event Handling** |
| 77 | +3. Delayed entry |
| 78 | +- If user enters during `ongoing`, bloc applies catch-up elapsed time and begins ticking from current clock. |
48 | 79 |
|
49 | | - - `ScheduleUpcomingReceived`: Triggers timer setup |
50 | | - - `ScheduleStarted`: Fired when timer completes |
| 80 | +4. Stale/ended schedule path |
| 81 | +- If schedule is already ended or stream emits `null`, state goes `notExists`. |
| 82 | +- Alarm listener redirects safely to `/home` instead of remaining in a loading state. |
51 | 83 |
|
52 | | -3. **State Transitions** |
53 | | - - `upcoming` → `started`: When timer fires for upcoming schedules |
54 | | - - `ongoing` → `started`: When timer fires for preparation-in-progress schedules |
| 84 | +### Router and UX rules |
55 | 85 |
|
56 | | -### Timer Calculation |
| 86 | +- `/scheduleStart` builder checks `ScheduleState.isEarlyStarted`. |
| 87 | +- If early-start is active, `/scheduleStart` resolves directly to `AlarmScreen` (no bounce). |
| 88 | +- `/alarmScreen` in `upcoming` shows early-start-ready UI (countdown + start + home), not indefinite spinner. |
57 | 89 |
|
58 | | -The timer calculates the exact target time as: |
| 90 | +### Finish semantics |
59 | 91 |
|
60 | | -```dart |
61 | | -final targetTime = DateTime( |
62 | | - scheduleTime.year, |
63 | | - scheduleTime.month, |
64 | | - scheduleTime.day, |
65 | | - scheduleTime.hour, |
66 | | - scheduleTime.minute, |
67 | | - 0, // Always trigger at 00 seconds |
68 | | - 0, // 0 milliseconds |
69 | | -); |
| 92 | +- Finish before leave threshold -> `lateness=0`. |
| 93 | +- Finish after leave threshold -> positive lateness minutes. |
| 94 | +- Completion dialog copy is late-aware: |
| 95 | +late case uses running-late wording, non-late case uses completion wording. |
| 96 | + |
| 97 | +### Sequence flow |
| 98 | + |
| 99 | +```mermaid |
| 100 | +sequenceDiagram |
| 101 | + participant User |
| 102 | + participant Start as "ScheduleStartScreen (5-min variant)" |
| 103 | + participant Bloc as ScheduleBloc |
| 104 | + participant Router |
| 105 | + participant Alarm as AlarmScreen |
| 106 | + participant Timer as OfficialStartTimer |
| 107 | +
|
| 108 | + User->>Start: Tap "Start Preparing" |
| 109 | + Start->>Bloc: add(SchedulePreparationStarted) |
| 110 | + Start->>Router: go("/alarmScreen") |
| 111 | + Bloc->>Bloc: mark early-start session + cancel start timer |
| 112 | + Bloc->>Alarm: emit started(isEarlyStarted=true) |
| 113 | +
|
| 114 | + alt User taps "Start in 5 minutes" |
| 115 | + User->>Start: Tap secondary button |
| 116 | + Start->>Router: go("/home") |
| 117 | + end |
| 118 | +
|
| 119 | + Timer-->>Bloc: Official start callback |
| 120 | + alt already early-started for same schedule |
| 121 | + Bloc->>Bloc: no-op (no duplicate push) |
| 122 | + else not early-started |
| 123 | + Bloc->>Router: push("/scheduleStart") |
| 124 | + end |
70 | 125 | ``` |
71 | 126 |
|
72 | | -### Safety Features |
| 127 | +## Cache Coherence and Resume Model |
73 | 128 |
|
74 | | -- **Timer Cancellation**: Previous timers are automatically cancelled when new schedules arrive |
75 | | -- **Bloc State Validation**: Timer callbacks verify the bloc is still active before firing events |
76 | | -- **Schedule ID Matching**: Events only fire for the currently tracked schedule |
77 | | -- **Proper Cleanup**: All timers are cancelled when the bloc is disposed |
| 129 | +### Persisted artifacts |
78 | 130 |
|
79 | | -## 🛡️ Error Handling |
| 131 | +1. Early start session |
| 132 | +- Stored by schedule ID with `startedAt`. |
80 | 133 |
|
81 | | -The system includes several safety mechanisms: |
| 134 | +2. Timed preparation snapshot |
| 135 | +- Stored by schedule ID with: |
| 136 | +`preparation`, `savedAt`, `scheduleFingerprint`. |
82 | 137 |
|
83 | | -1. **Past Schedule Protection**: Timers are not set for schedules in the past |
84 | | -2. **Bloc Lifecycle Management**: Timer callbacks check `isClosed` before adding events |
85 | | -3. **Memory Leak Prevention**: All timers are properly cancelled in `close()` |
86 | | -4. **Race Condition Prevention**: Schedule ID tracking prevents stale timer events |
| 138 | +### Resume and invalidation policy |
87 | 139 |
|
88 | | -## 📱 Usage Example |
| 140 | +- On schedule emission, runtime checks for early-start session and snapshot. |
| 141 | +- Snapshot is used only when fingerprint matches current schedule payload. |
| 142 | +- On restore, preparation is fast-forwarded by `(now - savedAt)`. |
| 143 | +- On mismatch, clear persisted session/snapshot and use canonical preparation. |
| 144 | +- Persisted state is also cleared on: |
| 145 | +finish success, schedule end/null, and schedule-id switch. |
89 | 146 |
|
90 | | -The timer system works automatically within the `ScheduleBloc`: |
| 147 | +```mermaid |
| 148 | +flowchart TD |
| 149 | + A["ScheduleUpcomingReceived(incoming)"] --> B{"Incoming schedule exists and not ended?"} |
| 150 | + B -- No --> C["Clear persisted state for stale/current id"] --> D["Emit notExists"] |
| 151 | + B -- Yes --> E{"Schedule id switched?"} |
| 152 | + E -- Yes --> F["Clear persisted state for previous schedule id"] |
| 153 | + E -- No --> G["Continue"] |
| 154 | + F --> G |
| 155 | +
|
| 156 | + G --> H{"Early-start session exists for incoming id?"} |
| 157 | + H -- No --> I{"incoming.preparationStartTime > now?"} |
| 158 | + I -- Yes --> J["Clear timed snapshot for incoming id (prevent stale pre-start revive)"] --> K["Use canonical incoming preparation"] |
| 159 | + I -- No --> L["Load timed snapshot"] |
| 160 | + H -- Yes --> L |
| 161 | +
|
| 162 | + L --> M{"Snapshot exists?"} |
| 163 | + M -- No --> K |
| 164 | + M -- Yes --> N{"snapshot.fingerprint == incoming.fingerprint?"} |
| 165 | + N -- No --> O["Clear session + timed snapshot"] --> K |
| 166 | + N -- Yes --> P["Restore snapshot and fast-forward by now - savedAt"] |
| 167 | +
|
| 168 | + K --> Q{"Early-start session exists?"} |
| 169 | + P --> Q |
| 170 | + Q -- Yes --> R["Emit started(isEarlyStarted=true) and start ticking"] |
| 171 | + Q -- No --> S{"now within preparation window?"} |
| 172 | + S -- Yes --> T["Emit ongoing and start ticking"] |
| 173 | + S -- No --> U["Emit upcoming and arm official start timer"] |
| 174 | +
|
| 175 | + R --> V["On finish/end/switch: clear session + snapshot"] |
| 176 | + T --> V |
| 177 | + U --> V |
| 178 | +``` |
| 179 | + |
| 180 | +## Public Interfaces / Types |
| 181 | + |
| 182 | +### `EarlyStartSessionRepository` |
| 183 | + |
| 184 | +Contract: |
91 | 185 |
|
92 | 186 | ```dart |
93 | | -// When a new schedule is received |
94 | | -bloc.add(ScheduleSubscriptionRequested()); |
95 | | -
|
96 | | -// The bloc will: |
97 | | -// 1. Listen for upcoming schedules |
98 | | -// 2. Automatically start timers for each schedule |
99 | | -// 3. Emit ScheduleStarted events at the exact scheduled time |
100 | | -// 4. Transition to 'started' state |
101 | | -
|
102 | | -// Listen for state changes |
103 | | -bloc.stream.listen((state) { |
104 | | - if (state.status == ScheduleStatus.started) { |
105 | | - // Handle schedule start (e.g., show notification, start tracking) |
106 | | - } |
107 | | -}); |
| 187 | +abstract interface class EarlyStartSessionRepository { |
| 188 | + Future<void> markStarted({required String scheduleId, required DateTime startedAt}); |
| 189 | + Future<EarlyStartSessionEntity?> getSession(String scheduleId); |
| 190 | + Future<void> clear(String scheduleId); |
| 191 | +} |
108 | 192 | ``` |
109 | 193 |
|
110 | | -## 🔧 Configuration |
| 194 | +### Timed snapshot schema |
| 195 | + |
| 196 | +`TimedPreparationSnapshotEntity` fields: |
| 197 | + |
| 198 | +- `PreparationWithTimeEntity preparation` |
| 199 | +- `DateTime savedAt` |
| 200 | +- `String scheduleFingerprint` |
| 201 | + |
| 202 | +### `ScheduleBloc` additions relevant to this flow |
| 203 | + |
| 204 | +- Dependencies for early-start/session and snapshot read/clear. |
| 205 | +- `nowProvider` + notification hook in test constructor for deterministic tests. |
| 206 | +- Guarded timer/event handling to prevent closed-bloc event races. |
| 207 | + |
| 208 | +### `ScheduleState` addition |
| 209 | + |
| 210 | +- `bool isEarlyStarted` differentiates early-started started-state from official start flow. |
| 211 | + |
| 212 | +### Router rule |
| 213 | + |
| 214 | +- If `/scheduleStart` resolves while current `ScheduleState.isEarlyStarted == true`, route resolves to `/alarmScreen` UI (`AlarmScreen`) instead of start screen. |
| 215 | + |
| 216 | +## Verification Matrix |
| 217 | + |
| 218 | +### Bloc runtime scenarios |
| 219 | + |
| 220 | +- Early start from `upcoming` transitions to active started flow and begins ticking. |
| 221 | +- Official start trigger does not push `/scheduleStart` again when early-start already active. |
| 222 | +- Re-emission of same schedule preserves active started state. |
| 223 | +- Finish clears timers, early-start session, and timed snapshot; state returns to `notExists`. |
| 224 | +- Fingerprint mismatch invalidates persisted state and resets to canonical preparation. |
| 225 | + |
| 226 | +### Widget flow scenarios |
111 | 227 |
|
112 | | -The timer system requires no additional configuration and works automatically with: |
| 228 | +- 5-minute variant: `Start Preparing` enters alarm flow immediately. |
| 229 | +- 5-minute variant: `Start in 5 minutes` returns to home. |
| 230 | +- Entering alarm before official start shows early-start-ready UI (countdown and actions). |
| 231 | +- Completion dialog: |
| 232 | +`Finish Preparation` uses finish path, `Continue Preparing` keeps user in alarm. |
| 233 | +- Late vs non-late finish payload behavior is validated. |
113 | 234 |
|
114 | | -- Any `ScheduleWithPreparationEntity` that has a valid `scheduleTime` |
115 | | -- Both upcoming and ongoing schedule states |
116 | | -- All timezone-aware DateTime calculations |
| 235 | +### Persistence/resume scenarios |
117 | 236 |
|
118 | | -## 📊 Performance Considerations |
| 237 | +- Early start -> app restart -> restore and fast-forward timed preparation from snapshot metadata. |
| 238 | +- Schedule mutation (fingerprint change) prevents stale progress restoration and resets correctly. |
119 | 239 |
|
120 | | -- **Single Timer**: Only one timer runs at a time per bloc instance |
121 | | -- **Minimal Memory Footprint**: Timers are created/destroyed as needed |
122 | | -- **Precise Timing**: Uses Dart's native Timer for accurate scheduling |
123 | | -- **Efficient Cleanup**: No lingering resources after bloc disposal |
| 240 | +### Boundary scenarios |
124 | 241 |
|
125 | | -## 🚀 Future Enhancements |
| 242 | +- Exact preparation start boundary transition. |
| 243 | +- Late entry catch-up into current step. |
| 244 | +- Very-late entry before `scheduleTime` with all steps effectively done. |
| 245 | +- Stale notification / ended schedule path redirects safely without stuck loading. |
126 | 246 |
|
127 | | -Potential improvements to consider: |
| 247 | +## Operational Notes |
128 | 248 |
|
129 | | -- Multiple concurrent schedule timers |
130 | | -- Configurable timer precision (seconds vs milliseconds) |
131 | | -- Timer persistence across app restarts |
132 | | -- Integration with system-level scheduling APIs |
| 249 | +- Wiki source of truth is this repository `docs/` folder. |
| 250 | +- Diagram format is Mermaid for GitHub wiki compatibility. |
| 251 | +- Recommended publish flow: |
| 252 | +commit docs changes in this repo, then sync with subtree: |
| 253 | +`git subtree push --prefix=docs wiki master` |
0 commit comments