Files
pluriwave/openspec/changes/app-quality-and-native-alarms/apply-progress.md
T
FreeTLab f3e9487215 feat(alarms): native reliability fixes and end-to-end snooze
- Use mediaPlayback|systemExempted FGS type with FOREGROUND_SERVICE_SYSTEM_EXEMPTED so alarms fire on Android 14+ (FOREGROUND_SERVICE_ALARM does not exist in the SDK)
- Deduplicate fire notifications: the foreground service FSI notification is the single owner; receiver path removed
- Notification channel v2 with alarm sound URI and USAGE_ALARM attributes, one-time guarded migration from legacy channels
- Pass fallback station through the MethodChannel (NativeAlarmSpec schemaVersion 3) with a three-stage audio chain: primary -> fallback station -> bundled WAV
- Native fade-in volume ramp honoring fadeInSegundos when the app is killed
- Request battery-optimization exemption once, tracked with a persisted asked-once flag
- Fix snooze end-to-end: native ACTION_SNOOZE now reports back to Flutter (snoozed event + cold-start sync), snooze anchor unified to occurrence+minutes on both sides, periodic recalc no longer erases an active snooze
- Add snooze buttons (3/5/10/custom) to the ringing screen with shared audio teardown
- Redesign ringing screen on PluriWaveScaffold with reduced-motion-aware entry animation (new PluriAnimate helper)
- Alarm editor: live next-trigger preview, searchable station pickers (primary and fallback), configurable snooze duration, volume floor down to 0
- New alarm strings localized across all 13 locales
- New unit/widget tests for the snooze flow, alarm bridge payloads, ringing screen and editor (77 tests green)
- SDD artifacts for the app-quality-and-native-alarms change (explore, proposal, spec, design, tasks, apply progress)
2026-06-11 15:33:30 +02:00

19 KiB
Raw Blame History

Apply Progress: app-quality-and-native-alarms

Mode: Strict TDD (test runner: flutter test) Artifact store: openspec (Engram unavailable this session) Delivery: auto-chain, local apply — no commits, no PRs (user commits at own cadence) Last updated: 2026-06-11 (Batch 2)

Batch log

Batch Slice Status Date
1 S1 — Alarm native reliability COMPLETE (Dart verified; Kotlin/manifest on-device verification deferred to user) 2026-06-11
2 S2a + S2b — Snooze correctness end-to-end + Alarm UX parity COMPLETE (Dart verified; Kotlin on-device verification deferred to user) 2026-06-11

Task status (cumulative)

Slice S1 — Alarm native reliability — 17/17 complete

Task Status Notes
T-S1-01 [x] RED test: programar() payload carries fallbackStationName/Url, fadeInSegundos, fallbackSound
T-S1-02 [x] RED test: solicitarExencionBateria() invokes requestIgnoreBatteryOptimizations
T-S1-03 [x] Manifest FGS type + permission. DEVIATION (see below)
T-S1-04 [x] API ≥ 34 3-arg startForeground with type bitmask. DEVIATION (see below)
T-S1-05 [x] Receiver showFireNotification + ensureFireChannel removed; service notification (id 92841) is sole FSI owner; fireNotificationIdForAlarm kept for cancel-migration safety
T-S1-06 [x] setFullScreenIntent was ALREADY present in service buildNotification; stopAlarm already cancels legacy fire id + stopForeground(STOP_FOREGROUND_REMOVE); FSI-before-audio ordering documented with comment
T-S1-07 [x] Channel pluriwave_alarm_fire_v2 (IMPORTANCE_HIGH, DEFAULT_ALARM_ALERT_URI + USAGE_ALARM attrs, vibration); one-time deletion of pluriwave_alarm_native + pluriwave_alarm_fire guarded by channels_migrated_v2 flag in device-protected prefs pluriwave_alarm_channels; channel count consolidated to 2 (fire_v2 + pre-notice)
T-S1-08 [x] NativeAlarmSpec + fallbackStationName/Url, fadeInSegundos; schemaVersion 2→3; fromJson backward-compatible (null/0 defaults); wired through scheduleAlarm, MainActivity handler, EXTRA_* consts, fireIntent extras
T-S1-09 [x] Three-stage chain primary(15s) → fallback station(15s) → bundled WAV via continuation lambdas (onStageFailed); scheduleStationFallback per stage with independent timeout windows
T-S1-10 [x] startFadeIn ramp: start 0.05×target, 250 ms steps over fadeInSegundos; applied to station and bundled-WAV players; cancelFadeIn() in stopAlarm (snooze path goes through stopAlarm)
T-S1-11 [x] requestIgnoreBatteryOptimizations MethodChannel handler + private method in MainActivity.kt mirroring requestExactAlarmPermission
T-S1-12 [x] GREEN: payload fields added to programar() args map
T-S1-13 [x] GREEN: solicitarExencionBateria() on PuertoAlarmasAndroid + impl
T-S1-14 [x] GREEN: asked-once guard in _solicitarPermisosNecesariosParaAlarma — calls only when !diag.ignoraOptimizacionBateria AND bateria_exencion_solicitada unset; optional SharedPreferences? prefs ctor param added to EstadoAlarmas (forward-compatible with S3 injection)
T-S1-15 [x] flutter test — full suite 54 tests, all passing (5 new)
T-S1-16 [x] flutter analyzeNo issues found! (baseline before S1 was also clean)
T-S1-17 [x] dart format applied to the 4 touched Dart files

Slice S2a — Snooze correctness — 20/20 complete

Task Status Notes
T-S2a-01 [x] RED: test/estado/estado_alarmas_snooze_test.dart — anchor, snoozed-event, recalc-preserve + extras (cold-start import, stop-cancels-snooze, finalizar clears)
T-S2a-02 [x] RED: test/servicios/servicio_alarmas_snooze_test.dart — anchor future/clamped/custom(7), payload snoozeUntilMillis+snoozeOriginMillis, getNativeSnoozeState parse/empty. Test C (finalizar) lives in the estado file
T-S2a-03 [x] RED: synchronous list update after posponerAlarma (no poll)
T-S2a-04 [x] Kotlin: ACTION_SNOOZE reports back via MainActivity.notifyAlarmEvent with alarmAction="snoozed" (PluriWaveAlarmService.kt:56-80). On-device verify
T-S2a-05 [x] Kotlin: MainActivity.notifyAlarmEvent companion (lines ~610-635), @Volatile activeInstance set in configureFlutterEngine, cleared in onDestroy; main-thread post; no-op when engine dead. On-device verify
T-S2a-06 [x] Kotlin: AlarmScheduler.snooze() (lines 266-292) unified to occurrenceAt + minutes clamped to now + minutes; persists snoozeMinutes; returns NativeSnoozeResult(until, origin, title). On-device verify
T-S2a-07 [x] Kotlin: AlarmScheduler.nativeSnoozeStates() (lines 366-385) + getNativeSnoozeState handler (MainActivity.kt:192). On-device verify
T-S2a-08 [x] GREEN: EventoAlarmaAndroid.snoozeUntilMillis + accionSnoozed; app.dart ignores snoozed events in _abrirAlarmaSonando
T-S2a-09 [x] snoozeUntilMillis was ALREADY in the programar() payload — locked by new test, no code change
T-S2a-10 [x] GREEN: _alRecibirEventoNativo (estado_alarmas.dart:266) — posponerEjecucionHasta + _aplicar + notifyListeners, NO second programar. Subscribed in the CONSTRUCTOR (deviation, see below); cancelled in dispose
T-S2a-11 [x] GREEN: _importarSnoozesNativosActivos (estado_alarmas.dart:312) called at the end of _sincronizarEjecucionesGestionadasPorAndroid; imports active future snoozes for active alarms when they differ
T-S2a-12 [x] GREEN: obtenerEstadoSnoozeNativo() on PuertoAlarmasAndroid + impl + EstadoSnoozeNativo model
T-S2a-13 [x] GREEN: _recalcular snoozeActivo now requires alarma.activa (servicio_alarmas.dart:395) — disabling clears the snooze; finalizar path already cleared + re-programs without snooze (bridge cancels natively when inactive)
T-S2a-14 [x] RED: test/pantallas/pantalla_alarma_sonando_test.dart — buttons 3/5/10(+7), no-dup, tap-5 behavior
T-S2a-15 [x] GREEN: _liberarAudioLocal() + _posponer(int) + _detener refactor (pantalla_alarma_sonando.dart:138,161). _estadoSub.cancel() is fire-and-forget (deviation, see below)
T-S2a-16 [x] GREEN: snooze button row via _opcionesSnooze() (sorted {3,5,10,custom}); NEW l10n keys in ALL 13 .arb files: alarmSnoozeOptionLabel, snoozeAction, alarmSnoozeDurationTitle, alarmFallbackStationLabel, alarmStationPickerSearchHint (+ flutter gen-l10n regenerated)
T-S2a-17 [x] Targeted snooze tests green
T-S2a-18 [x] Full suite 77/77
T-S2a-19 [x] flutter analyze — No issues found
T-S2a-20 [x] dart format on touched files

Slice S2b — Editor + visual redesign — 12/12 complete

Task Status Notes
T-S2b-01 [x] RED: scaffold test — PluriWaveScaffold present, no Color(0xFF061722) Scaffold, Animate present / absent under disableAnimations
T-S2b-02 [x] RED: 5 editor tests (preview + live update, primary picker + filtering, fallback picker, snooze duration persists, volume floor 0.0)
T-S2b-03 [x] GREEN: ringing screen on PluriWaveScaffold; 0xFFFFB86Btokens.warmCoral; blurSigma: 10 + cold-GPU comment (Design 2.4)
T-S2b-04 [x] GREEN: lib/tema/pluri_animate.dartpluriFadeIn/pluriScaleIn honoring MediaQuery.maybeDisableAnimationsOf
T-S2b-05 [x] GREEN: glass surface wrapped in .pluriFadeIn(context)
T-S2b-06 [x] GREEN: _vistaProximaEjecucion (draft → calcularProxima, respects vacations/exceptions; recomputed on every setState)
T-S2b-07 [x] GREEN: _CampoSelectorEmisora + _SelectorEmisoraSheet (SearchBar picker) for primary AND fallback station; copyWith clear-flags added (see deviations)
T-S2b-08 [x] GREEN: snooze duration SegmentedButton wired to snoozeMinutos (editor used to hardcode 5); volume slider min 0.25 → 0.0 (divisions 20)
T-S2b-09 [x] S2b targeted tests 7/7 green
T-S2b-10 [x] Full suite 77/77
T-S2b-11 [x] flutter analyze — No issues found
T-S2b-12 [x] dart format applied

Remaining slices (not started)

S3a, S3b, S7, S4a, S4b, S5, S6, cross-cutting (T-CC-01, T-CC-02) — all pending.

Snooze defect fixes (design audit D1D5 / S1S5)

Defect Fix Where
D1 (audit S1) — native snooze never notifies Flutter ACTION_SNOOZEMainActivity.notifyAlarmEvent("snoozed", origin, until); Flutter records via posponerEjecucionHasta WITHOUT re-programming; engine-dead case covered by getNativeSnoozeState cold-start import PluriWaveAlarmService.kt:56-80, MainActivity.kt:627, estado_alarmas.dart:266,312
D2 (audit S2) — two snooze anchors Unified everywhere to occurrence + minutes clamped to now + minutes: native snooze() adopts postponeNext logic; Dart posponerEjecucion re-anchored from now+min to ejecucion+min AlarmScheduler.kt:266-292, servicio_alarmas.dart:256-274
D3 (audit S3) — no snooze on ringing screen 3/5/10 + custom buttons → _posponer → shared _liberarAudioLocal teardown → canonical EstadoAlarmas.posponerAlarma (Flutter-first; hides native notification = same stop path) pantalla_alarma_sonando.dart:138-176,242-256
D4 (audit S4) — recalc erases native-only snooze Resolved transitively by D1: Flutter now RECORDS every native snooze, so _recalcular sees snoozeActivo and preserves it; regression-guard test added; additionally snooze cleared when alarm disabled (S2-R5) servicio_alarmas.dart:392-401, test estado_alarmas_snooze_test.dart
D5 (audit S5) — preserveNativeSnooze origin mismatch Resolved transitively by D1/D2: Flutter always sends snoozeUntilMillis when snoozed, so the preservation net is no longer load-bearing; kept as belt-and-suspenders per design AlarmScheduler.kt:preserveNativeSnooze (unchanged)

TDD Cycle Evidence (Strict TDD hard gate)

Task RED (test written first, failing) GREEN (implementation passes) REFACTOR
T-S1-01/T-S1-12 servicio_alarmas_android_test.dart written first; load failure + payload keys absent Payload fields added; 3 tests pass dart format
T-S1-02/T-S1-13 Same RED run: solicitarExencionBateria undefined → compile failure Interface + impl added; test passes None needed
T-S1-14 "solicita exencion una sola vez" FAILED (Expected 1, Actual 0) Asked-once guard; both tests pass Fake made configurable
T-S1-03..11 (Kotlin) N/A — on-device items N/A Surgical diffs
T-S2a-01..03 / T-S2a-08..13 All 3 new test files failed to LOAD (missing EstadoSnoozeNativo, accionSnoozed, obtenerEstadoSnoozeNativo, alarmSnoozeOptionLabel) — captured before any implementation. Anchor test would fail under old now+min semantics (verified by design: old code returned 7:05 vs expected 7:35) All bridge/state/service changes added; targeted run 23/23 green Shared FakePuertoAlarmasAndroid extracted to test/helpers/fakes_alarmas.dart; existing estado_alarmas_test.dart deduplicated
T-S2a-14..16 Widget test load failure (l10n key missing) then tap-test failure (snoozeHasta null) Buttons + _posponer implemented; 3/3 green Debug prints removed; unawaited cancel documented
T-S2b-01..02 / T-S2b-03..08 Scaffold tests failed (no PluriWaveScaffold/Animate); all 5 editor tests failed (key not found) — captured in dedicated RED run (0 passed / 7 failed) pluri_animate.dart, scaffold migration, editor preview/pickers/snooze/volume; 7/7 green Material(transparency) wrappers; initState-l10n fix; dart format

RED run evidence: first run +0 -3 (loading failures, three files); ringing tap test Expected: DateTime:<07:35> Actual: <null>; S2b run +0 -7 before implementation. GREEN: targeted 23/23 then 7/7; full suite 00:24 +77: All tests passed!.

Files changed (Batch 2)

File Action ~Lines
android/.../AlarmScheduler.kt Modified +57/-12 (unified snooze + NativeSnoozeResult + nativeSnoozeStates)
android/.../MainActivity.kt Modified +36/-1 (notifyAlarmEvent companion, activeInstance, getNativeSnoozeState)
android/.../PluriWaveAlarmService.kt Modified +19/-1 (snooze report-back)
lib/servicios/servicio_alarmas_android.dart Modified +60 (snoozed event, EstadoSnoozeNativo, bridge method)
lib/servicios/servicio_alarmas.dart Modified +20/-3 (anchor, activa-aware snooze clearing)
lib/estado/estado_alarmas.dart Modified +95/-15 (event subscription, snooze recording, cold-start import)
lib/pantallas/pantalla_alarma_sonando.dart Modified +70/-15 (snooze buttons, teardown, PluriWaveScaffold, tokens, fade-in)
lib/pantallas/pantalla_alarmas.dart Modified ~+330/-60 net (preview, pickers, snooze field, volume floor, initState fix, Material wrappers; large diff partly dart-format reflow)
lib/modelos/alarma_musical.dart Modified +10/-2 (limpiarEmisora/limpiarEmisoraFallback)
lib/app.dart Modified +7 (ignore snoozed events)
lib/tema/pluri_animate.dart Created +39
lib/l10n/app_*.arb (13 files) Modified +12 each (5 keys + metadata)
lib/l10n/gen/* (15 files) Regenerated by flutter gen-l10n
test/helpers/fakes_alarmas.dart Created +120
test/helpers/fakes.dart Modified +13 (pausar/setVolumen on FakeServicioAudio)
test/estado/estado_alarmas_snooze_test.dart Created +250
test/servicios/servicio_alarmas_snooze_test.dart Created +155
test/pantallas/pantalla_alarma_sonando_test.dart Created +165
test/pantallas/pantalla_alarma_sonando_scaffold_test.dart Created +150
test/pantallas/pantalla_alarmas_editor_test.dart Created +210
test/estado/estado_alarmas_test.dart Modified -78/+8 (fake deduplicated to helper; anchor expectations 7:36:00 → 7:36:02)

Deviations from design (Batch 2)

  1. Event subscription lives in the EstadoAlarmas CONSTRUCTOR, not inicializar (task text said inicializar). Rationale: snoozed events must be recorded even before/without full init, and tests with iniciarAutomaticamente: false stay light. Cancelled in dispose.
  2. posponerEjecucion clamps minutes to 1..120 instead of coercing to 3/5/10. The old calcularSnooze coercion would have made the custom snooze button (e.g. "7 min", S2-R1-C) silently snooze 5. The native notification path still sanitizes to 3/5/10 (sanitizeSnoozeMinutes), unchanged. calcularSnooze kept (unused by this path) for API compatibility.
  3. Existing test expectation updated (estado_alarmas_test.dart): unified anchor makes snooze land at proximaEjecucion(+inminencia normalization) + 5min = 7:36:02, not now+5 = 7:36:00. This is the spec'd behavior change (S2-R6), documented inline.
  4. _recalcular now clears snooze for INACTIVE alarms — required by S2-R5-A ("snoozeHasta is null in persistent storage" after disable); previously a disabled alarm kept a stale snoozeHasta forever.
  5. AlarmaMusical.copyWith gained limpiarEmisora/limpiarEmisoraFallback (not in task text). Without them the picker's "no station" could never clear an existing station (latent pre-existing bug: copyWith null-coalesced).
  6. Pre-existing debug crash fixed: _EditorAlarmaSheetState.initState called AppLocalizations.of(context)dependOnInheritedWidgetOfExactType assert in debug builds. Name controller now created lazily in didChangeDependencies. (The sibling _EditorVacacionesSheet has the same latent issue — NOT fixed here, out of S2 scope; flag for S5/S6.)
  7. Material(type: transparency) wrappers added inside PluriGlassSurface for the editor sheet and the station picker — ListTiles inside a DecoratedBox trigger a debug assert and invisible ink splashes otherwise.
  8. _liberarAudioLocal does NOT await _estadoSub.cancel() — a broadcast-subscription cancel future may not resolve until the stream closes (observed in tests); cancellation of delivery is synchronous, so fire-and-forget (unawaited) is correct and prevents the snooze tap from stalling.
  9. AlarmScheduler.snooze returns NativeSnoozeResult (until/origin/title) so the service can build the report-back payload; postponeNext untouched (already had the unified anchor).
  10. Ringing screen blurSigma capped to 10 (PluriGlassSurface default 18) as the Design 2.4 cold-GPU mitigation, plus reduced-motion users skip the entry animation entirely.

Issues found

  • flutter test does NOT auto-run gen-l10n in this setup despite generate: true; flutter gen-l10n must be run manually after editing .arb files (gen files are committed).
  • tester.tap + an awaited broadcast-subscription cancel() deadlocks the gesture handler chain in widget tests (see deviation 8) — worth remembering for S3/S7 work.

On-device verification checklist for the user

From tasks.md Section 11 — S1 items still pending from Batch 1, plus new S2 items:

  1. Kotlin compiles — native layer edited again without compilation (HIGH risk): build FIRST.
  2. Alarm fires app-killed on Android 14+ (S1-R1/R2); channel v2 on alarm stream (S1-R3); 3-stage fallback (S1-R4); native fade (S1-R6); battery dialog once (S1-R5); reboot persistence.
  3. Snooze from ringing screen (S2-R1, S2-R4): ring → tap "5 min" → notification dismissed, list shows snoozeHasta immediately, re-fires at that time.
  4. Snooze from notification while app killed (S2-R3): tap "Posponer" on the fire notification with the app killed → system alarm icon persists → reopen the app → list shows the snooze WITHOUT waiting for the 60 s poll (cold-start getNativeSnoozeState import).
  5. Snooze from notification while app foregrounded (S2-R3): same, but the list updates within the same frame via the snoozed MethodChannel event.
  6. Stop cancels pending snooze (S2-R5): snooze → disable the alarm from the list → does NOT re-fire.
  7. Ringing screen visuals (S2-R7): PluriWaveScaffold gradient + entry fade; verify no first-frame stutter on screen-off FSI wake (blur capped); with "remove animations" accessibility setting the entry is instant.
  8. Editor (S2-R8..R11): next-trigger preview updates live; searchable pickers for primary AND backup station; snooze duration control; volume slider reaches 0%.

Verification summary (Batch 2)

  • flutter test: 77/77 passing (54 pre-batch + 23 new)
  • flutter analyze: No issues found (identical to baseline)
  • dart format: applied to all touched Dart files only (gen/ untouched by hand)
  • flutter gen-l10n: run once after .arb edits
  • flutter build: NOT run (forbidden)

Workload / boundary

  • Mode: auto-chain local slices (no PRs)
  • Current work units: S2a + S2b (complete)
  • Boundary: starts from S1-complete tree; ends with S2a+S2b fully checked off, suite green. Rollback = revert the Batch-2 files listed above (S1 files only touched additively in AlarmScheduler.kt/MainActivity.kt/PluriWaveAlarmService.kt).
  • Next batch: S3a (test seams) — prerequisite: user performs on-device verification for S1+S2 Kotlin, especially compile.