Files
pluriwave/openspec/changes/app-quality-and-native-alarms/tasks.md
T
FreeTLab 079e19f0ee feat(audio): audio session integration and runtime robustness
- Integrate audio_session (new servicio_audio_session.dart): incoming calls pause the radio and resume on end, headphone unplug pauses without auto-resume, permanent focus loss never auto-resumes, duck lowers volume
- Add play-intent flag to ServicioAudio so interruption handling and future reconnect logic can distinguish user pause from system-driven stops
- Eliminate read-modify-write race in ServicioAlarmas with an in-memory cache and single-writer queue across all mutations; recalcularTodas persists only when state actually changed
- Convert ServicioAlarmasAndroid static StreamController/handler to injectable instance fields, restoring test isolation
- Inject a single cached SharedPreferences from main.dart across services and state (removes 23 inline getInstance() calls)
- Move configurarLocalizaciones out of MiniReproductor.build() (was running on every rebuild during playback)
- Bound the alarm fire-dedup set (cap 200 entries, 24h pruning)
- 12 new tests (89 total green), flutter analyze clean
2026-06-11 16:25:09 +02:00

470 lines
44 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Tasks: app-quality-and-native-alarms
## Review Workload Forecast
| Field | Value |
|-------|-------|
| Estimated changed lines (total) | ~1 8502 100 |
| 400-line budget risk (overall) | High — all slices combined |
| Chained PRs recommended | N/A (local apply — no PRs) |
| Suggested split | S1 → S2a → S2b → S3a → S3b → S7 → S4a → S4b → S5 → S6 |
| Delivery strategy | auto-chain |
| Chain strategy | N/A (local apply — user commits at own cadence) |
Decision needed before apply: No
Chained PRs recommended: N/A (local apply)
Chain strategy: N/A (local apply)
400-line budget risk: High
> **Per-slice risks** are noted inline. Each slice is an autonomous apply batch; the
> user reviews and commits before the next slice begins.
### Suggested Work Units (apply batches)
| Batch | Slices | Goal | Prerequisite | Est. lines |
|-------|--------|------|--------------|------------|
| 1 | S1 | Native alarm reliability (manifest, FSI, channels, fallback, fade) | — | ~330 |
| 2 | S2a | Snooze correctness: bridge sync + ringing-screen buttons | S1 complete | ~260 |
| 3 | S2b | Editor redesign + visual (next-trigger, station picker, snooze field, scaffold) | S2a complete | ~180 |
| 4 | S3a | Test seams: statics→instance, prefs injection, cache/mutex, dirty-guard, bounded set | S2 complete | ~270 |
| 5 | S3b | audio_session integration + becoming-noisy + intent flag seam | S3a complete | ~100 |
| 6 | S7 | Streaming resilience: buffer config, reconnect state machine, UI wiring | S3b complete | ~285 |
| 7 | S4a | ServicioExportImport + EstadoEcualizador extraction + compat getters | S3 complete | ~350 |
| 8 | S4b | EstadoGrabacion + EstadoBusqueda + context.select rewiring + remove compat getters | S4a complete | ~380 |
| 9 | S5 | Design system, a11y, i18n, polish | S2b complete | ~210 |
| 10 | S6 | Quality gates: analysis_options + top-5 tests + lint fix-ups | S4b + S5 complete | ~120 |
---
## Slice S1 — Alarm native reliability (~330 lines)
> **Verification verbs** — on-device items are deferred to the user's device checklist (Section 11).
> Dart items: `flutter test`, `flutter analyze`, `dart format`.
### S1 pre-work: write failing tests
- [x] **T-S1-01** [RED] Write failing test: `test/servicios/servicio_alarmas_android_test.dart` — assert `programar()` MethodChannel payload contains keys `fallbackStationUrl`, `fallbackStationName`, `fadeInSegundos`, `fallbackSound`. **Reqs:** S1-R4, S1-R6. **~20 lines.**
- [x] **T-S1-02** [RED] Write failing test in same file — assert `solicitarExencionBateria()` invokes `requestIgnoreBatteryOptimizations` on the MethodChannel. **Reqs:** S1-R5. **~15 lines.**
### S1 implementation: Kotlin / manifest (on-device verification)
- [x] **T-S1-03** Edit `android/app/src/main/AndroidManifest.xml`: add `<uses-permission android:name="android.permission.FOREGROUND_SERVICE_ALARM"/>` near line 5; change `PluriWaveAlarmService` to `android:foregroundServiceType="mediaPlayback|alarm"` (lines 54-57). **Reqs:** S1-R1. *On-device verify deferred to user.* **DEVIATION:** `alarm` FGS type / `FOREGROUND_SERVICE_ALARM` permission do NOT exist in the Android SDK (verified against android-36 `android.jar`); implemented with `systemExempted` / `FOREGROUND_SERVICE_SYSTEM_EXEMPTED`, the documented type for alarm-clock apps holding `SCHEDULE_EXACT_ALARM`/`USE_EXACT_ALARM`.
- [x] **T-S1-04** Edit `android/app/src/main/kotlin/.../PluriWaveAlarmService.kt` line ~75: on API ≥ 34 call `startForeground(NOTIFICATION_ID, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK or ServiceInfo.FOREGROUND_SERVICE_TYPE_ALARM)`; on API < 34 keep 2-arg overload. **Reqs:** S1-R1. *On-device verify.* **DEVIATION:** uses `FOREGROUND_SERVICE_TYPE_SYSTEM_EXEMPTED` (see T-S1-03; `FOREGROUND_SERVICE_TYPE_ALARM` does not exist).
- [x] **T-S1-05** Edit `PluriWaveAlarmReceiver.kt`: remove `showFireNotification` call (lines 37, 95-133). The service `startForeground` notification (ID 92841) is now the single owner of the FSI. Keep `fireNotificationIdForAlarm` helper for `cancelAlarm` migration safety — do NOT post to it. **Reqs:** S1-R2. *On-device verify.*
- [x] **T-S1-06** Edit `PluriWaveAlarmService.kt` `buildNotification`: add `setFullScreenIntent(...)` so the FSI appears instantly at `startForeground` before audio prepares. Ensure `stopAlarm` (line ~224) calls `stopForeground(STOP_FOREGROUND_REMOVE)` and also cancels any legacy `fireNotificationIdForAlarm` id as migration guard. **Reqs:** S1-R2. *On-device verify.* (`setFullScreenIntent` and both `stopAlarm` guards were already present; verified and documented ordering with a comment.)
- [x] **T-S1-07** Edit `PluriWaveAlarmService.kt` (~line 374) and `PluriWaveAlarmReceiver.kt` (~line 269): introduce versioned channel id `pluriwave_alarm_fire_v2` (IMPORTANCE_HIGH) with `setSound(DEFAULT_ALARM_ALERT_URI, USAGE_ALARM AudioAttributes)` and `enableVibration(true)`. Add one-time channel migration: delete `pluriwave_alarm_native` and `pluriwave_alarm_fire` guarded by SharedPreferences flag `channels_migrated_v2`. Service's `startForeground` notification now uses `_fire_v2`. **Reqs:** S1-R3. *On-device verify.*
- [x] **T-S1-08** Edit `AlarmScheduler.kt` `NativeAlarmSpec` (lines 571-648): add `fallbackStationName: String?`, `fallbackStationUrl: String?`, `fadeInSegundos: Int` fields; bump `schemaVersion` 2→3; update `toJson`/`fromJson` (additive, defaults null/0 for missing fields). Wire through `scheduleAlarm` signature, `MainActivity` handler (lines 68-106), and `EXTRA_*` constants / `fireIntent` extras. **Reqs:** S1-R4, S1-R6. *On-device verify.*
- [x] **T-S1-09** Edit `PluriWaveAlarmService.kt` `startAudio` (lines 86-108): implement three-stage ordered fallback state machine (primary station 15s → fallback station 15s → bundled WAV). Reuse `scheduleStationFallback`/`cancelStationFallback` per stage. **Reqs:** S1-R4. *On-device verify.*
- [x] **T-S1-10** Edit `PluriWaveAlarmService.kt` `setOnPreparedListener` (lines 128-136, 179-183): if `fadeInSegundos > 0`, start at 0.05 × target volume and step every 250 ms toward `volume` via `mainHandler` runnable. Cancel ramp runnable in `stopAlarm` and on snooze. **Reqs:** S1-R6. *On-device verify.*
- [x] **T-S1-11** Add `requestIgnoreBatteryOptimizations` MethodChannel handler in `MainActivity.kt` (mirror `requestExactAlarmPermission` ~lines 255-270): launch `Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS`. **Reqs:** S1-R5. *On-device verify.*
### S1 implementation: Dart bridge
- [x] **T-S1-12** [GREEN] Edit `lib/servicios/servicio_alarmas_android.dart` `programar()` (lines 148-174): add `fallbackStationUrl`, `fallbackStationName` (from `alarma.emisoraFallback`), and `fadeInSegundos` to the MethodChannel args map. **Reqs:** S1-R4, S1-R6. **~15 lines.**
- [x] **T-S1-13** [GREEN] Add `solicitarExencionBateria()` method to `PuertoAlarmasAndroid` interface and `ServicioAlarmasAndroid` implementation (`lib/servicios/servicio_alarmas_android.dart` ~lines 93-107, 196-218): invoke `requestIgnoreBatteryOptimizations` MethodChannel. **Reqs:** S1-R5. **~20 lines.**
- [x] **T-S1-14** [GREEN] Edit `lib/estado/estado_alarmas.dart` `_solicitarPermisosNecesariosParaAlarma` (lines 268-284): call `android.solicitarExencionBateria()` ONLY when `!diag.ignoraOptimizacionBateria` AND a `bateria_exencion_solicitada` flag is unset in SharedPreferences (asked-once guard). **Reqs:** S1-R5. **~15 lines.**
### S1 verification
- [x] **T-S1-15** Run `flutter test test/servicios/servicio_alarmas_android_test.dart` — T-S1-01, T-S1-02 must pass (GREEN). Verify T-S1-12, T-S1-13, T-S1-14 output. (Full suite: 54 tests passing.)
- [x] **T-S1-16** Run `flutter analyze` — zero errors. (`No issues found!`, identical to pre-S1 baseline.)
- [x] **T-S1-17** Run `dart format lib/servicios/servicio_alarmas_android.dart lib/estado/estado_alarmas.dart`. (Also formatted both touched test files.)
### S1 Definition of Done
- `flutter test` green (T-S1-01, T-S1-02 passing; no regressions in existing 12 test files).
- `flutter analyze` clean.
- `dart format` applied to all edited Dart files.
- Reqs checked off: S1-R1 (on-device), S1-R2 (on-device), S1-R3 (on-device), S1-R4 (Dart portion), S1-R5 (Dart portion), S1-R6 (Dart portion).
- User performs on-device verification (see Section 11) for the Kotlin/manifest tasks before starting S2.
---
## Slice S2a — Snooze correctness (~260 lines)
> Covers Design Decisions 2.12.3. Must ship before S2b.
### S2a pre-work: write failing tests
- [x] **T-S2a-01** [RED] Create `test/estado/estado_alarmas_snooze_test.dart`:
- Test A: `posponerAlarma(alarma, 5)` calls `android.programar` once with `snoozeHasta = proximaEjecucion + 5 min`; calls `notifyListeners`. (S2-R6-A, S2-R1)
- Test B: A `snoozed` native event triggers `servicio.posponerEjecucionHasta` + `notifyListeners` WITHOUT a second `android.programar`. (S2-R3, Decision 2.1)
- Test C: `recalcularTodas` called after `posponerAlarma` PRESERVES `snoozeHasta` (S4 regression guard). (S2-R6)
**DONE — plus extra tests: cold-start snooze import, stop-cancels-snooze (S2-R5), finalizarEjecucion clears snooze. Shared fake moved to `test/helpers/fakes_alarmas.dart`.**
- [x] **T-S2a-02** [RED] Create `test/servicios/servicio_alarmas_snooze_test.dart`:
- Test A: `posponerEjecucionHasta(id, origin, until)` computes `snoozeHasta = origin + minutes` and persists. (S2-R6)
- Test B: MethodChannel payload for a snoozed alarm contains `snoozeUntilMillis` matching `snoozeHasta`. (S2-R6)
- Test C: `finalizarEjecucion` clears `snoozeHasta` and calls `android.cancelar` (or `programar` without `snoozeHasta`). (S2-R5, S2-R6)
**DONE — Test C lives in `estado_alarmas_snooze_test.dart` (it is EstadoAlarmas behavior). Added anchor-clamp and custom-minutes tests + `getNativeSnoozeState` bridge tests.**
- [x] **T-S2a-03** [RED] Add test in `test/estado/estado_alarmas_snooze_test.dart`: after `posponerAlarma`, the alarm list in the state reflects updated `snoozeHasta` synchronously (no poll wait). (S2-R2) **DONE.**
### S2a implementation: Kotlin native→Flutter sync (on-device portion)
- [x] **T-S2a-04** Edit `PluriWaveAlarmService.kt` snooze handler (`ACTION_SNOOZE`, now lines 56-80): after `AlarmScheduler.snooze(...)` (which now returns `NativeSnoozeResult`), calls `MainActivity.notifyAlarmEvent` with `alarmAction="snoozed"`, `occurrenceAtMillis`, `snoozeUntilMillis`, title and minutes. **Reqs:** S2-R3, Decision 2.1. *On-device verify.*
- [x] **T-S2a-05** `MainActivity.kt` companion `notifyAlarmEvent(payload)` (lines ~610-635): posts `alarmFired` on the main handler through a `@Volatile activeInstance` (set in `configureFlutterEngine`, cleared in `onDestroy`); no-op with log when engine dead. **Reqs:** S2-R3. *On-device verify.*
- [x] **T-S2a-06** `AlarmScheduler.kt` `snooze()` (lines 266-292): anchor unified to `occurrenceAt + minutes` clamped to `now + minutes` (postponeNext logic adopted; also persists `snoozeMinutes`); returns `NativeSnoozeResult` for the bridge callback. **Reqs:** S2-R4, Decision 2.2. *On-device verify.*
- [x] **T-S2a-07** `AlarmScheduler.kt` `nativeSnoozeStates()` (lines 366-385) returns active future snoozes (alarmId + snoozeUntilMillis + snoozeOriginMillis); wired as `getNativeSnoozeState` in `MainActivity` (line 192). **Reqs:** S2-R3, Decision 2.1 engine-dead case. *On-device verify.*
### S2a implementation: Dart bridge and state
- [x] **T-S2a-08** [GREEN] `EventoAlarmaAndroid` extended with `snoozeUntilMillis` field and `accionSnoozed` const; `app.dart` `_abrirAlarmaSonando` ignores `snoozed` events (EstadoAlarmas owns them). **DONE.**
- [x] **T-S2a-09** [GREEN] `programar()` already sent `snoozeUntilMillis`/`snoozeOriginMillis` (pre-existing); now LOCKED by test (`servicio_alarmas_snooze_test.dart` payload test). **Reqs:** S2-R6. **No code change needed.**
- [x] **T-S2a-10** [GREEN] `EstadoAlarmas` subscribes to `android.eventosAlarma` in the CONSTRUCTOR (not `inicializar` — see deviations); `_alRecibirEventoNativo` (estado_alarmas.dart:266) records the snooze via `posponerEjecucionHasta` + `_aplicar` + `notifyListeners`, with NO second `android.programar`. Subscription cancelled in `dispose`. **Reqs:** S2-R3, S2-R2. **DONE.**
- [x] **T-S2a-11** [GREEN] `_sincronizarEjecucionesGestionadasPorAndroid` now always ends with `_importarSnoozesNativosActivos()` (estado_alarmas.dart:306,312): imports active future native snoozes for active alarms when they differ from the stored value. **Reqs:** S2-R3. **DONE.**
- [x] **T-S2a-12** [GREEN] `obtenerEstadoSnoozeNativo()` added to `PuertoAlarmasAndroid` + impl invoking `getNativeSnoozeState`; new `EstadoSnoozeNativo` model with `fromMap`. **DONE.**
- [x] **T-S2a-13** [GREEN] S2-R5 implemented in `servicio_alarmas.dart` `_recalcular` (line 395): `snoozeActivo` now requires `alarma.activa`, so disabling an alarm clears its snooze; `finalizarEjecucion` already cleared it via `completarEjecucion` and re-programs without snooze through `_sincronizarTodas` (the real bridge cancels natively for inactive alarms). Both paths covered by tests. **Reqs:** S2-R5. **DONE.**
### S2a implementation: ringing screen snooze buttons
- [x] **T-S2a-14** [RED] Widget tests in `test/pantallas/pantalla_alarma_sonando_test.dart`:
- Test A: snooze buttons 3/5/10 + custom 7 present; no-dup test when snoozeMinutos=5. (S2-R1-A, S2-R1-C)
- Test B: tapping 5-min snooze records snoozeHasta, pauses audio, hides the native notification and pops. (S2-R1-B)
**DONE.**
- [x] **T-S2a-15** [GREEN] `_liberarAudioLocal()` (pantalla_alarma_sonando.dart:138) cancels `_fallbackTimer`/`_fadeInTimer`, cancels `_estadoSub` (fire-and-forget — see deviations), stops `_fallbackPlayer`; `_posponer(int)` (line 161) = teardown → `radio.audio.pausar()``posponerAlarma` → pop; `_detener` refactored to reuse it. **Reqs:** S2-R1. **DONE.**
- [x] **T-S2a-16** [GREEN] Snooze button row (`_opcionesSnooze()` = sorted {3,5,10,custom}) rendered with `l10n.alarmSnoozeOptionLabel(min)` + `l10n.snoozeAction` header, each wired to `_posponer`. New ARB keys added to ALL 13 locales. **Reqs:** S2-R1-A/B/C. **DONE.**
### S2a verification
- [x] **T-S2a-17** `flutter test` on the three snooze test files — all green (RED phase captured first: compile failures + anchor mismatch).
- [x] **T-S2a-18** `flutter test` (full suite) — 77/77 passing, no regressions.
- [x] **T-S2a-19** `flutter analyze``No issues found!`.
- [x] **T-S2a-20** `dart format` applied to all touched Dart files (lib + test).
### S2a Definition of Done
- `flutter test` green (new snooze tests passing; 12 existing files unbroken).
- `flutter analyze` clean.
- `dart format` applied.
- Reqs checked off: S2-R1, S2-R2, S2-R3 (Dart portion), S2-R4 (Kotlin deferred), S2-R5, S2-R6.
---
## Slice S2b — Editor + visual redesign (~180 lines)
> Covers Design Decisions 2.42.5.
### S2b pre-work: write failing tests
- [x] **T-S2b-01** [RED] `test/pantallas/pantalla_alarma_sonando_scaffold_test.dart`: asserts `PluriWaveScaffold` present; no `Scaffold` with `Color(0xFF061722)`; `Animate` present normally and ABSENT with `disableAnimations=true`. **Reqs:** S2-R7, S5-R3. **DONE.**
- [x] **T-S2b-02** [RED] `test/pantallas/pantalla_alarmas_editor_test.dart` (5 tests):
- Test A: next-trigger preview present (key `next-trigger-preview`) and changes when weekday recurrence changes (Mon→Tue, date-independent). (S2-R8)
- Test B: station field opens bottom sheet with `SearchBar`; typing filters the list. (S2-R9)
- Test B2: fallback-station field opens the same picker. (S2-R9)
- Test C: snooze SegmentedButton present; selecting 10 + save persists `snoozeMinutos = 10`. (S2-R10)
- Test D: volume slider min is 0.0. (S2-R11)
**DONE.**
### S2b implementation
- [x] **T-S2b-03** [GREEN] Ringing screen migrated to `PluriWaveScaffold`; `Color(0xFF061722)` removed, `Color(0xFFFFB86B)``tokens.warmCoral`; `blurSigma` capped to 10 with cold-GPU comment (Design 2.4 mitigation). **Reqs:** S2-R7, S5-R1 (partial). **DONE.**
- [x] **T-S2b-04** [GREEN] `lib/tema/pluri_animate.dart` created: `pluriFadeIn`/`pluriScaleIn` returning the child untouched when `MediaQuery.maybeDisableAnimationsOf(context)` is true. **Reqs:** S5-R3. **DONE.**
- [x] **T-S2b-05** [GREEN] Glass surface wrapped in `.pluriFadeIn(context)` entry animation. **Reqs:** S2-R7. **DONE.**
- [x] **T-S2b-06** [GREEN] `_vistaProximaEjecucion` in the editor: computes `calcularProxima` from the in-progress draft (respects vacations/exceptions), renders `alarmNextExecution`/`alarmNoNextExecution`, recomputed on every `setState` so it tracks time/recurrence edits live. **Reqs:** S2-R8. **DONE.**
- [x] **T-S2b-07** [GREEN] `DropdownButtonFormField` replaced by `_CampoSelectorEmisora` + `_SelectorEmisoraSheet` (bottom sheet with `SearchBar` over favorites + "no station" option); second identical picker added for `emisoraFallback` (NEW field in the editor). `AlarmaMusical.copyWith` gained `limpiarEmisora`/`limpiarEmisoraFallback` so "none" actually clears. **Reqs:** S2-R9. **DONE.**
- [x] **T-S2b-08** [GREEN] Snooze duration SegmentedButton (3/5/10 + current custom value) writing `_snoozeMinutos` (saved via `copyWith(snoozeMinutos: ...)` — the editor previously hardcoded 5 for new alarms); volume slider floor lowered 0.25 → 0.0 (divisions 20). **Reqs:** S2-R10, S2-R11. **DONE.**
### S2b verification
- [x] **T-S2b-09** `flutter test` on both S2b test files — 7/7 green (RED captured first).
- [x] **T-S2b-10** `flutter test` (full suite) — 77/77 passing, no regressions.
- [x] **T-S2b-11** `flutter analyze``No issues found!`.
- [x] **T-S2b-12** `dart format` applied to all touched files.
### S2b Definition of Done
- `flutter test` green.
- `flutter analyze` clean.
- `dart format` applied.
- Reqs checked off: S2-R7, S2-R8, S2-R9, S2-R10, S2-R11.
---
## Slice S3a — Test seams: statics, prefs, cache, mutex, bounded set (~270 lines)
> Covers Design Decisions 3.2, 3.3, 3.4, 3.5. Must complete before S3b and S7 (S7 depends on the intent flag seam from 3.1, which is in S3b).
### S3a pre-work: write failing tests
- [x] **T-S3a-01** [RED] Create `test/servicios/servicio_alarmas_android_instance_test.dart`: two `ServicioAlarmasAndroid` instances do not share `_eventosController` (S3-R2-A). Use a fake `MethodChannel`. **DONE — two distinct channels, simulated `alarmFired` via `handlePlatformMessage`, both directions asserted.**
- [x] **T-S3a-02** [RED] Create `test/servicios/servicio_alarmas_cache_test.dart`:
- Test A: `recalcularTodas` does NOT call `SharedPreferences.setString` when schedule unchanged (S3-R5-A).
- Test B: `recalcularTodas` calls `SharedPreferences.setString` exactly once when changed (S3-R5-B).
- Test C: Two concurrent `guardarAlarma` calls produce consistent final state — no lost write (S3-R7-A, S6-R2 test #1 preview). **DONE — `_PrefsEspia implements SharedPreferences` (counts setString/getString); Test C also asserts the mutations hydrate the cache at most once.**
- [x] **T-S3a-03** [RED] Create `test/estado/estado_alarmas_ejecuciones_test.dart`: `_ejecucionesEmitidas` with 100 stale entries triggers pruning; length ≤ max threshold after prune (S3-R6-A). **DONE — plus a cap test (250 fresh entries → ≤ 200).**
- [x] **T-S3a-04** [RED] Create `test/widgets/mini_reproductor_configurar_test.dart`: `configurarLocalizaciones` called at most once per locale change across 10 rebuilds (S3-R3-A). **DONE — counter subclass of `EstadoRadio`; 10 notifyListeners rebuilds → 1 call; locale es→en → 2nd call.**
### S3a implementation
- [x] **T-S3a-05** [GREEN] Edit `lib/servicios/servicio_alarmas_android.dart` (lines 117-120): convert `static _eventosController`, `static _handlerInstalado`, `static _l10n` to INSTANCE fields. Install handler in constructor. ~~Add deprecated static shim~~ **DEVIATION:** Dart forbids a static and an instance member with the same name; the ONLY call site (`estado_radio.dart:74`) was rewired in this same change, so no shim exists. `configurarLocalizaciones` was added to the `PuertoAlarmasAndroid` INTERFACE; `EstadoAlarmas.configurarLocalizaciones` forwards to its bridge; `app.dart` configures it once per locale change. **Reqs:** S3-R2. **DONE.**
- [x] **T-S3a-06** [GREEN] `MiniReproductor` converted to `StatefulWidget`; `configurarLocalizaciones` moved to `didChangeDependencies` guarded by cached `Locale`. Alarm-bridge l10n hoisted to `app.dart` `_PaginaPrincipalState.didChangeDependencies` (design 3.3 alternative), also locale-guarded and placed BEFORE the early-return. **Reqs:** S3-R3. **DONE.**
- [x] **T-S3a-07** [GREEN] `main.dart` resolves `SharedPreferences.getInstance()` ONCE before `runApp`; `PluriWaveApp(prefs:)` injects it into `EstadoRadio`, `EstadoAlarmas` (→ default `ServicioAlarmas(prefs:)`), and `EstadoIdioma`. **Reqs:** S3-R4. **DONE.**
- [x] **T-S3a-08** [GREEN] All inline `getInstance()` sites migrated to injected-with-fallback `_resolverPrefs()`: `estado_radio.dart` (10 sites), `servicio_ecualizador.dart` (6), `servicio_grabacion_radio.dart` (4), `servicio_contenido_app.dart` (3). `rg 'SharedPreferences.getInstance()'` in lib/ now shows ONLY main.dart plus one fallback expression per class. **Reqs:** S3-R4. **DONE.**
- [x] **T-S3a-09** [GREEN] Dirty-guard in `recalcularTodas` (servicio_alarmas.dart:189-207): serializes the recalculated config and compares against the cached raw; skips `_guardar` and returns the loaded config when identical. **Reqs:** S3-R5. **DONE.**
- [x] **T-S3a-10** [GREEN] In-memory `ConfiguracionAlarmas? _cache` + `_cacheRaw` + `_enCola` Future-chain writer queue (mirrors `_colaCambioFuente`). ALL mutations (`guardarAlarma`, `eliminarAlarma`, `guardarVacaciones`, `recalcularTodas`, `sincronizarEjecucionesNativas`, `saltarProxima`, `posponerEjecucionHasta`, `completarEjecucion`) run queued over `_configActual()` (cache-or-hydrate). **DEVIATION (intentional):** public `cargar()` still re-reads from prefs (cache reset inside the queue) because `EstadoRadio.importarConfig` writes the raw alarms key DIRECTLY to prefs — a fully cached `cargar()` would make imports invisible until restart. **Reqs:** S3-R7. **DONE.**
- [x] **T-S3a-11** [GREEN] `_ejecucionesEmitidas` bounded: `maxEjecucionesEmitidas = 200` cap with oldest-millis eviction + 24 h retention prune (`_depurarEjecucionesEmitidas`), run on every add (`_registrarEjecucionEmitida`) and at the start of each `_vigilarAlarmasVencidas` pass. `@visibleForTesting` length getter. **Reqs:** S3-R6. **DONE.**
### S3a verification
- [x] **T-S3a-12** Run `flutter test` on the four new S3a files — green (RED captured first: 1 passed / 6 failed across the batch).
- [x] **T-S3a-13** Run `flutter test` (full suite) — 89/89 passing (77 baseline + 12 new), no regressions.
- [x] **T-S3a-14** Run `flutter analyze``No issues found!`.
- [x] **T-S3a-15** Run `dart format` on all edited Dart files (19 files, 5 reflowed).
### S3a Definition of Done
- `flutter test` green.
- `flutter analyze` clean.
- `dart format` applied.
- Reqs checked off: S3-R2, S3-R3, S3-R4, S3-R5, S3-R6, S3-R7.
---
## Slice S3b — audio_session + becoming-noisy + intent flag (~100 lines)
> Provides the `_intencionReproducir` flag seam that S7 requires.
### S3b pre-work: write failing tests
- [x] **T-S3b-01** [RED] Create `test/servicios/servicio_audio_session_test.dart`:
- Test A: interruption `begin/pause` event sets `_intencionReproducir` to false and pauses playback. (S3-R1)
- Test B: interruption `end/shouldResume` resumes playback. (S3-R1)
- Test C: becoming-noisy event pauses playback. (S3-R1)
**DONE — 5 tests (also: end without prior interruption-pause does NOT resume; duck begin/end attenuates and restores) against a fake `ObjetivoAudioInterrumpible`.**
### S3b implementation
- [x] **T-S3b-02** [GREEN] `lib/servicios/servicio_audio_session.dart` created: `ServicioAudioSession` configures `AudioSessionConfiguration.music().copyWith(androidWillPauseWhenDucked: true)`, subscribes to `interruptionEventStream` + `becomingNoisyEventStream`. Pause-type begin → pause (remembers `_pausadoPorInterrupcion`); end/pause-type → resume ONLY if we paused; end/unknown → never resume; duck begin/end → `setAtenuado(true/false)`; noisy → hard pause, clears the resume flag. Also defines the `ObjetivoAudioInterrumpible` interface (test seam). **Reqs:** S3-R1. **DONE.**
- [x] **T-S3b-03** [GREEN] `PluriWaveAudioHandler implements ObjetivoAudioInterrumpible`: `_intencionReproducir` set true in `play()`/`playMediaItem()` (covers `reproducir`/`reanudar`), false in `pause()`/`stop()` (covers `detener`; interruption pauses route through `pausar()``pause()`). Duck = `setAtenuado` scaling effective volume by 0.3 (`_volumenEfectivo`, respected by `setVolumen` and `_recrearPlayer`). `ServicioAudioSession.configurar()` wired in `main.dart` after `registrarHandler`. This is the seam S7 reads. **Reqs:** S3-R1. **DONE.**
### S3b verification
- [x] **T-S3b-04** Run `flutter test test/servicios/servicio_audio_session_test.dart` — 5/5 green (RED captured first: load failure, file missing).
- [x] **T-S3b-05** Run `flutter test` (full suite) — 89/89, no regressions.
- [x] **T-S3b-06** Run `flutter analyze``No issues found!`.
- [x] **T-S3b-07** Run `dart format lib/servicios/servicio_audio_session.dart lib/servicios/servicio_audio.dart` — applied (included in the 19-file format pass).
### S3b Definition of Done
- `flutter test` green.
- `flutter analyze` clean.
- `dart format` applied.
- Reqs checked off: S3-R1 (`flutter analyze` import present; on-device call-pause deferred to user).
---
## Slice S7 — Streaming resilience (~285 lines)
> Depends on S3b (intent flag seam). Covers Design Decisions 7.17.2.
### S7 pre-work: write failing tests
- [ ] **T-S7-01** [RED] Create `test/servicios/servicio_audio_reconnect_test.dart`:
- Test A: backoff delay sequence for retries 15 is [1s, 2s, 4s, 8s, 16s] capped at maxDelay (S7-R7).
- Test B: `_intencionReproducir=true` + stall → `reconectando` state emitted, reconnect scheduled (S7-R2-A, S7-R7).
- Test C: `_intencionReproducir=false` + stall → NO reconnect (S7-R2-B, S7-R7).
- Test D: after `maxRetries` exhausted → error state emitted (S7-R2-C, S7-R7).
- Test E: successful reconnect resets retry counter (S7-R7).
- Test F: user stop during stall cancels reconnect (S7-R6, S7-R7).
**~70 lines.**
- [ ] **T-S7-02** [RED] Add test in `test/servicios/servicio_audio_reconnect_test.dart`: buffer config (`AndroidLoadControl`) applied to player construction (S7-R1). **~15 lines.**
- [ ] **T-S7-03** [RED] Add widget test `test/widgets/reconnect_ui_test.dart`: no `AlertDialog`/`SnackBar` shown while handler in `reconectando` state (S7-R3-A). **~20 lines.**
### S7 implementation
- [ ] **T-S7-04** [GREEN] Edit `lib/servicios/servicio_audio.dart` `_crearPlayer` (lines 159-163): pass `AudioLoadConfiguration(androidLoadControl: AndroidLoadControl(minBufferDuration: 15s, maxBufferDuration: 50s, bufferForPlaybackDuration: 2.5s, bufferForPlaybackAfterRebufferDuration: 5s, prioritizeTimeOverSizeThresholds: true))` at construction. Values extracted as named constants, NOT magic literals. **Reqs:** S7-R1. **~25 lines.**
- [ ] **T-S7-05** [GREEN] Edit `lib/servicios/servicio_audio.dart`: add `EstadoReproduccion.reconectando` to the state enum (line 14). **Reqs:** S7-R2, S7-R3. **~3 lines.**
- [ ] **T-S7-06** [GREEN] Edit `lib/servicios/servicio_audio.dart` `_gestionarErrorReproduccion` (lines 207-236) and `_eventosSub.onError` (lines 189-194): instead of transitioning immediately to terminal error when `_intencionReproducir == true` and error is network-class (2xxx range), enter the reconnect state machine — emit `reconectando`, schedule backoff retry using `_cambiarFuente` revision guard. Cancel/reset on user stop or source switch. After `maxRetries` exhaustion fall through to existing terminal error path. Configurable: `_maxRetries = 5`, `_baseDelay = 1s`, `_maxDelay = 30s`. **Reqs:** S7-R2. **~130 lines.**
- [ ] **T-S7-07** [GREEN] Edit `lib/widgets/mini_reproductor.dart` and any player UI: map `EstadoReproduccion.reconectando` → buffering/loading indicator (NOT error dialog). **Reqs:** S7-R3. **~20 lines.**
- [ ] **T-S7-08** [GREEN] Edit `lib/pantallas/pantalla_alarma_sonando.dart` (alarm pre-start / estadoStream listener): ensure `reconectando` is NOT treated as `reproduciendo`; the alarm's existing 12-second fallback timer remains authoritative. Add a comment documenting the boundary. **Reqs:** S7-R4. **~10 lines.**
- [ ] **T-S7-09** [GREEN] Confirm `ServicioGrabacionRadio` error-handling code is NOT modified by S7 changes. Add inline comment referencing S7-R5 invariant. **Reqs:** S7-R5. **~3 lines (comment only).**
### S7 verification
- [ ] **T-S7-10** Run `flutter test test/servicios/servicio_audio_reconnect_test.dart test/widgets/reconnect_ui_test.dart`.
- [ ] **T-S7-11** Run `flutter test` (full suite) — no regressions.
- [ ] **T-S7-12** Run `flutter analyze` — zero errors.
- [ ] **T-S7-13** Run `dart format lib/servicios/servicio_audio.dart lib/widgets/mini_reproductor.dart lib/pantallas/pantalla_alarma_sonando.dart`.
### S7 Definition of Done
- `flutter test` green (all reconnect tests passing).
- `flutter analyze` clean.
- `dart format` applied.
- Reqs checked off: S7-R1 (Dart buffer config), S7-R2, S7-R3, S7-R4, S7-R5, S7-R6, S7-R7; on-device stream-drop deferred to user.
---
## Slice S4a — ServicioExportImport + EstadoEcualizador (~350 lines)
> Extraction order: ServicioExportImport first (pure logic, zero UI coupling), then EstadoEcualizador.
### S4a pre-work: write failing tests
- [ ] **T-S4a-01** [RED] Create `test/servicios/servicio_export_import_test.dart`:
- Test A: full round-trip (favorites, groups, EQ, alarms, vacations) — serialize then deserialize produces deep-equal config. (S4-R4-A, S6-R2 test #4)
- Test B: malformed JSON input to `importar()` → graceful empty result, no throw. (S4-R4)
**~40 lines.**
- [ ] **T-S4a-02** [RED] Create `test/estado/estado_ecualizador_test.dart`:
- Test A: `aplicarPreset` notifies `EstadoEcualizador` listeners. (S4-R1-A)
- Test B: `EstadoRadio` listeners are NOT rebuilt on EQ preset change. (S4-R1-A, S4-R5)
**~30 lines.**
### S4a implementation
- [ ] **T-S4a-03** [GREEN] Create `lib/servicios/servicio_export_import.dart`: `ServicioExportImport` class with `exportar(config) → String` and `importar(json) → ConfiguracionCompleta?`. Move all `jsonEncode`/`jsonDecode` backup/restore logic from `lib/pantallas/pantalla_ajustes.dart` (~1391 lines). `PantallaAjustes` delegates to this service. **Reqs:** S4-R4. **~100 lines (service) + ~30 lines cleanup in pantalla_ajustes.**
- [ ] **T-S4a-04** [GREEN] Create `lib/estado/estado_ecualizador.dart`: `EstadoEcualizador extends ChangeNotifier` — owns preset, bands, enabled flag. Move EQ state fields and methods from `lib/estado/estado_radio.dart`. Add `ProxyProvider` registration in `MultiProvider` (wherever providers are registered, likely `lib/main.dart` or `lib/app.dart`). **Reqs:** S4-R1. **~90 lines.**
- [ ] **T-S4a-05** [GREEN] Edit `lib/estado/estado_radio.dart`: add backward-compatible getters delegating EQ state to `EstadoEcualizador` (transition bridge). These are removed in S4b. Add `// TODO(S4b): remove getter` comments. **~20 lines.**
- [ ] **T-S4a-06** [GREEN] Edit `lib/widgets/ecualizador_widget.dart` and `lib/pantallas/pantalla_ajustes.dart` EQ sections: consume `context.watch<EstadoEcualizador>()` (scoped). Screens still compile via compat getters if missed. **Reqs:** S4-R5. **~20 lines.**
### S4a verification
- [ ] **T-S4a-07** Run `flutter test test/servicios/servicio_export_import_test.dart test/estado/estado_ecualizador_test.dart`.
- [ ] **T-S4a-08** Run `flutter test` (full suite) — no regressions.
- [ ] **T-S4a-09** Run `flutter analyze` — zero errors.
- [ ] **T-S4a-10** Run `dart format lib/servicios/servicio_export_import.dart lib/estado/estado_ecualizador.dart lib/estado/estado_radio.dart lib/pantallas/pantalla_ajustes.dart`.
### S4a Definition of Done
- `flutter test` green.
- `flutter analyze` clean.
- `dart format` applied.
- Reqs checked off: S4-R1, S4-R4.
---
## Slice S4b — EstadoGrabacion + EstadoBusqueda + context.select rewiring (~380 lines)
### S4b pre-work: write failing tests
- [ ] **T-S4b-01** [RED] Create `test/estado/estado_grabacion_test.dart`: `ServicioGrabacionRadio` is managed by `EstadoGrabacion`; notifies listeners on recording state change. (S4-R2) **~20 lines.**
- [ ] **T-S4b-02** [RED] Create `test/estado/estado_busqueda_test.dart`: search query update notifies `EstadoBusqueda` listeners. (S4-R3) **~15 lines.**
- [ ] **T-S4b-03** [RED] Add widget test: changing EQ preset does NOT rebuild `PantallaInicio` (S4-R5-A). **~20 lines.**
### S4b implementation
- [ ] **T-S4b-04** [GREEN] Create `lib/estado/estado_grabacion.dart`: `EstadoGrabacion extends ChangeNotifier` — owns recording state + `_escucharGrabacion` subscription (currently `estado_radio.dart:51, :79`). Register in `MultiProvider`. **Reqs:** S4-R2. **~80 lines.**
- [ ] **T-S4b-05** [GREEN] Create `lib/estado/estado_busqueda.dart`: `EstadoBusqueda extends ChangeNotifier` — owns search query, results, loading state. Register in `MultiProvider`. **Reqs:** S4-R3. **~60 lines.**
- [ ] **T-S4b-06** [GREEN] Edit `lib/pantallas/pantalla_inicio.dart` (line 43): replace root `context.watch<EstadoRadio>()` with `context.select` / `Consumer` scoped to fields it actually reads. **Reqs:** S4-R5. **~30 lines.**
- [ ] **T-S4b-07** [GREEN] Edit `lib/pantallas/pantalla_ajustes.dart` (~6 watch sites): replace each `context.watch<EstadoRadio>()` with scoped `context.select` / `Consumer` for the specific field. **Reqs:** S4-R5. **~40 lines.**
- [ ] **T-S4b-08** [GREEN] Edit `lib/pantallas/pantalla_favoritos.dart`: scope the `EstadoRadio` watch. **Reqs:** S4-R5. **~15 lines.**
- [ ] **T-S4b-09** [GREEN] Edit `lib/estado/estado_radio.dart`: remove EQ, recording, and search state fields/methods; remove backward-compatible getters added in S4a (they carried `// TODO(S4b): remove getter` comments). **Reqs:** S4-R1, S4-R2, S4-R3. **~80 lines removed.**
### S4b verification
- [ ] **T-S4b-10** Run `flutter test test/estado/estado_grabacion_test.dart test/estado/estado_busqueda_test.dart` plus the rebuild scope test.
- [ ] **T-S4b-11** Run `flutter test` (full suite) — no regressions.
- [ ] **T-S4b-12** Run `flutter analyze` — zero errors.
- [ ] **T-S4b-13** Run `dart format lib/estado/estado_grabacion.dart lib/estado/estado_busqueda.dart lib/estado/estado_radio.dart lib/pantallas/pantalla_inicio.dart lib/pantallas/pantalla_ajustes.dart lib/pantallas/pantalla_favoritos.dart`.
### S4b Definition of Done
- `flutter test` green.
- `flutter analyze` clean.
- `dart format` applied.
- Reqs checked off: S4-R2, S4-R3, S4-R5.
---
## Slice S5 — Design system, a11y, i18n (~210 lines)
> Parallelizable after S2b completes (ringing screen literals migrated in S2b).
### S5 pre-work: write failing tests
- [ ] **T-S5-01** [RED] Create `test/widgets/tarjeta_emisora_a11y_test.dart`: favorite `InkWell` has semantic label + `button:true`; size ≥ 48×48 dp (S5-R2-A). **~20 lines.**
- [ ] **T-S5-02** [RED] Add test in `test/tema/pluri_animate_test.dart`: `pluriFadeIn` returns unanimated child when `disableAnimations=true` (S5-R3-A). **~15 lines.**
- [ ] **T-S5-03** [RED] Create `test/pantallas/pantalla_alarmas_fecha_test.dart`: `_fechaCorta` with locale `en-US` returns `DateFormat.yMd('en-US')` result, NOT `11/06/2026` (S5-R4-A). **~15 lines.**
- [ ] **T-S5-04** [RED] Add test `test/pantallas/pantalla_favoritos_plural_test.dart`: plural form changes between 1 and 5 station count strings (S5-R5). **~10 lines.**
- [ ] **T-S5-05** [RED] Add widget test: shimmer present during loading state in `PantallaBuscar` (S5-R6). **~10 lines.**
- [ ] **T-S5-06** [RED] Add unit test: `AudioServiceConfig.notificationColor` equals brand color token (S5-R8). **~10 lines.**
### S5 implementation
- [ ] **T-S5-07** [GREEN] Edit all 14+ remaining `Color(0x...)` literal sites identified in explore C3 (files: `lib/pantallas/`, `lib/widgets/`, excluding `pantalla_alarma_sonando.dart` done in S2b): replace with `PluriWaveTokens` or `Theme.of(context).colorScheme` references. **Reqs:** S5-R1. **~30 lines across files.**
- [ ] **T-S5-08** [GREEN] Edit `lib/widgets/tarjeta_emisora.dart` (lines 238-289): wrap mini favorite `InkWell` in `Semantics(button: true, label: l10n.toggleFavorite)`; set `constraints: BoxConstraints(minWidth: 48, minHeight: 48)`. Add `semanticLabel` to `_AssetIcon`/alarm PNG. **Reqs:** S5-R2. **~15 lines.**
- [ ] **T-S5-09** [GREEN] `lib/tema/pluri_animate.dart` already created in S2b (T-S2b-04). Verify tests pass (no new code needed here unless edge case found).
- [ ] **T-S5-10** [GREEN] Edit `lib/pantallas/pantalla_alarmas.dart` `_fechaCorta` (line 1114): replace hardcoded format string with `intl.DateFormat.yMd(Localizations.localeOf(context).toLanguageTag()).format(date)`. **Reqs:** S5-R4. **~5 lines.**
- [ ] **T-S5-11** [GREEN] Edit `lib/pantallas/pantalla_favoritos.dart` (line 138): replace bare counter string with ARB plural form using `AppLocalizations` `stationCount(n)` plural message. Add the ARB plural entry to `lib/l10n/*.arb` files for all supported locales. **Reqs:** S5-R5. **~20 lines (Dart) + ARB entries.**
- [ ] **T-S5-12** [GREEN] Edit `lib/widgets/tarjeta_emisora.dart` shimmer placeholders (lines 389-420): apply `BorderRadius` matching card corners. Edit `lib/pantallas/pantalla_buscar.dart` (lines 241-245): replace spinner with shimmer during loading state. **Reqs:** S5-R6. **~20 lines.**
- [ ] **T-S5-13** [GREEN] Edit `lib/pantallas/pantalla_ajustes.dart` icon sites (lines 985, 1028, 1031): replace non-`_rounded` icon variants with their `_rounded` equivalents. **Reqs:** S5-R7. **~5 lines.**
- [ ] **T-S5-14** [GREEN] Edit `lib/main.dart` (line 23) `AudioServiceConfig`: set `notificationColor` to `PluriWaveTokens.brandColor` (or equivalent token). **Reqs:** S5-R8. **~3 lines.**
### S5 verification
- [ ] **T-S5-15** Run `flutter test test/widgets/tarjeta_emisora_a11y_test.dart test/tema/pluri_animate_test.dart test/pantallas/pantalla_alarmas_fecha_test.dart test/pantallas/pantalla_favoritos_plural_test.dart`.
- [ ] **T-S5-16** Run `flutter test` (full suite) — no regressions.
- [ ] **T-S5-17** Run `flutter analyze` — zero errors (no `Color(0x...)` in modified files beyond token definitions).
- [ ] **T-S5-18** Run `dart format` on all edited files.
### S5 Definition of Done
- `flutter test` green.
- `flutter analyze` clean (no new color literals in modified files).
- `dart format` applied.
- Reqs checked off: S5-R1 through S5-R8.
---
## Slice S6 — Quality gates (~120 lines)
> Hardening pass; depends on S4b + S5 complete (all code settled before lint enforcement).
### S6 pre-work: write failing tests (top-5 required tests not yet written)
- [ ] **T-S6-01** [RED] `test/servicios/servicio_alarmas_cache_test.dart` — Test C (concurrent mutation, S6-R2 test #1): already written as T-S3a-02 Test C. Verify it is present and passing.
- [ ] **T-S6-02** [RED] `test/estado/estado_alarmas_ejecuciones_test.dart` (fire dedup, S6-R2 test #2): already written as T-S3a-03. Verify passing.
- [ ] **T-S6-03** [RED] Create `test/servicios/servicio_audio_source_switch_test.dart`: rapid `playMediaItem(A)`, `playMediaItem(B)`, `playMediaItem(C)` — only C's source active; no stale error from A/B (S6-R2 test #3). Use fake `AudioPlayer` seam. **~35 lines.**
- [ ] **T-S6-04** Confirm `test/servicios/servicio_export_import_test.dart` (S6-R2 test #4, round-trip) exists from T-S4a-01. Verify passing.
- [ ] **T-S6-05** [RED] Create `test/servicios/servicio_grabacion_radio_test.dart`: recording error clears state and releases resources; subsequent start succeeds (S6-R2 test #5, S7-R5 invariant). **~30 lines.**
### S6 implementation
- [ ] **T-S6-06** [GREEN] Edit `analysis_options.yaml`: under `linter.rules` add `cancel_subscriptions`, `close_sinks`, `unawaited_futures`, `prefer_final_locals`, `avoid_dynamic_calls`. **Reqs:** S6-R1. **~6 lines.**
- [ ] **T-S6-07** [GREEN] Fix violations surfaced by the new lint rules across `lib/` (empty catches → `developer.log`, unawaited futures → `unawaited()` or `await`, open sinks/subscriptions — ensure they are tracked and cancelled). Scope: sites already noted in design B7/B10 plus any new violations. **~30 lines across files.**
- [ ] **T-S6-08** [GREEN] Run `flutter analyze` with new rules and fix remaining violations until clean.
### S6 verification
- [ ] **T-S6-09** Run `flutter test test/servicios/servicio_audio_source_switch_test.dart test/servicios/servicio_grabacion_radio_test.dart` — green.
- [ ] **T-S6-10** Run `flutter test` (full suite) — all passing including 12 original files.
- [ ] **T-S6-11** Run `flutter analyze` — zero errors under hardened rules.
- [ ] **T-S6-12** Run `dart format` on all edited files.
### S6 Definition of Done
- `flutter test` green — all 5 required tests present and passing; 12 original files unbroken.
- `flutter analyze` clean under hardened `analysis_options.yaml`.
- `dart format` applied.
- Reqs checked off: S6-R1, S6-R2 (tests 1-5).
---
## Cross-cutting batch — state.yaml + on-device checklist
- [ ] **T-CC-01** Update `openspec/changes/app-quality-and-native-alarms/state.yaml`: set `phase: tasks-ready`, `updated: 2026-06-11`.
- [ ] **T-CC-02** After the full apply and all flutter test / analyze passes, run final `dart format lib/` sweep.
### On-device verification checklist (user — Android 14 device)
Perform after S1 and after all slices are applied. **No `flutter build` from this repo — build from IDE or `flutter run`.**
1. **Alarm fires app-killed (S1-R1, S1-R2):** kill the app; wait for a scheduled alarm; confirm `PluriWaveAlarmService` starts with no `ForegroundServiceTypeException` in logcat; exactly one notification in the tray.
2. **Alarm channel uses alarm stream (S1-R3):** lower the alarm volume to 0; raise media volume; confirm alarm sound is silent (alarm volume, not media volume).
3. **Snooze from ringing screen (S2-R1, S2-R4):** with app foreground, let alarm ring; tap 5-min snooze; confirm notification dismissed; alarm list shows `snoozeHasta = now+5min`; alarm re-fires at that time.
4. **Snooze from notification while app killed (S2-R3):** kill the app; let alarm fire to notification; tap "Posponer"; confirm system alarm icon still present; bring app to foreground — alarm list shows snoozed state WITHOUT waiting for 60-second poll.
5. **Stop cancels pending snooze (S2-R5):** snooze an alarm; before re-fire, disable the alarm from the list; confirm alarm does NOT re-fire at the snooze time.
6. **Reboot persistence (S1-R1, S2-R4):** schedule an alarm; reboot device; confirm alarm still fires at scheduled time.
7. **Fallback station attempted (S1-R4):** set primary station to an invalid URL, set `emisoraFallback` to a valid one; let alarm fire; confirm the fallback station plays (or bundled WAV if fallback also fails).
8. **Battery optimization exemption requested (S1-R5):** fresh install; grant alarm permission; confirm the battery-optimization dialog appears exactly once.
9. **Stream drop recovery (S7-R1, S7-R2):** while radio plays, briefly disable WiFi/LTE for ~10 s; confirm audio continues if buffered; on reconnect, playback resumes to live edge without error dialog; a longer drop (>30s) shows reconnecting state, eventually surfaces error after retries exhausted.
10. **Phone call pauses radio (S3-R1):** while radio plays, receive a call; confirm radio pauses/ducks; confirm it resumes after the call.
11. **No alarm regression after S7 (S7-R4):** with S7 changes applied, let an alarm fire with a non-responding URL; confirm WAV fallback fires within ~15 seconds (not delayed by reconnect loop).
---
## Per-slice estimated lines and budget risk
| Slice | Est. lines | 400-line budget risk | Notes |
|-------|-----------|----------------------|-------|
| S1 | ~330 | Medium | Kotlin edits not compilable here; on-device only |
| S2a | ~260 | Medium | Snooze correctness + ringing buttons |
| S2b | ~180 | Low | Editor + visual |
| S3a | ~270 | Medium | Test seams across multiple files |
| S3b | ~100 | Low | audio_session wrapper |
| S7 | ~285 | Medium | Reconnect state machine |
| S4a | ~350 | Medium-High | Two extractions + compat getters |
| S4b | ~380 | Medium-High | Two more extractions + rewiring |
| S5 | ~210 | Low | Design system / i18n |
| S6 | ~120 | Low | Lint rules + 2 new tests |
| **Total** | **~2 285** | **High (overall)** | Distributed across 10 local slices |