Files
pluriwave/openspec/changes/app-quality-and-native-alarms/tasks.md
T
FreeTLab 52855e75c2 refactor(state): extract recording and search state, scope screen rebuilds
- New EstadoGrabacion owns the recording service, subscription, directory/size preferences and open-file actions
- New EstadoBusqueda owns search, nearby stations, pagination and the min-bitrate filter
- New orden_emisoras.dart with the OrdenEmisoras enum, shared sorter and list identity memoization so context.select comparisons work on derived lists
- Large screens (inicio, buscar, favoritos, ajustes, reproductor) consume scoped selects/dedicated notifiers instead of root context.watch<EstadoRadio>, so audio buffer events no longer rebuild whole screens
- Remove all 15 TODO(S4b) compat members from EstadoRadio; consumers use the dedicated providers. EstadoRadio drops from ~1121 to 753 lines, keeping playback/stations/favorites orchestration
- 8 new tests including a rebuild-scoping probe (110 total green), flutter analyze clean
2026-06-11 21:43:18 +02:00

470 lines
48 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
- [x] **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). **DONE (+ cap test with custom maxDelay).**
- Test B: `_intencionReproducir=true` + stall → `reconectando` state emitted, reconnect scheduled (S7-R2-A, S7-R7). **DONE — decision/scheduling tested at `ControladorReconexion` level (extracted pure logic; the handler itself needs platform channels); state emission covered by the widget test.**
- Test C: `_intencionReproducir=false` + stall → NO reconnect (S7-R2-B, S7-R7). **DONE.**
- Test D: after `maxRetries` exhausted → error state emitted (S7-R2-C, S7-R7). **DONE (`DecisionReconexion.agotado` → handler falls through to existing terminal error path).**
- Test E: successful reconnect resets retry counter (S7-R7). **DONE (`restablecer()` + backoff restarts at base).**
- Test F: user stop during stall cancels reconnect (S7-R6, S7-R7). **DONE (`cancelar()` kills the pending timer; fired-after-cancel never retries).**
**8 tests, RED captured first (load failure: controller file missing).**
- [x] **T-S7-02** [RED] Add test in `test/servicios/servicio_audio_reconnect_test.dart`: buffer config (`AndroidLoadControl`) applied to player construction (S7-R1). **DONE — asserts `PluriWaveAudioHandler.configuracionCargaAndroid` values (15s/50s/2.5s/5s/prioritizeTime); construction wiring not unit-testable without platform channels, verified by code + on-device.**
- [x] **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). **DONE — also asserts spinner + localized "Reconectando..." label and that the manual-retry button appears ONLY in error state.**
### S7 implementation
- [x] **T-S7-04** [GREEN] `_crearPlayer` now passes `audioLoadConfiguration: configuracionCargaAndroid` (15s/50s/2.5s/5s, `prioritizeTimeOverSizeThresholds: true`) — values as named `static const` (`bufferMinimo`/`bufferMaximo`/`bufferParaIniciar`/`bufferTrasRebuffer`). API verified against installed just_audio 0.9.46 source: all params exist, no deviation. **Reqs:** S7-R1. **DONE.**
- [x] **T-S7-05** [GREEN] `EstadoReproduccion.reconectando` added; `ServicioAudio.estadoStream` maps it from the handler's `reconectando` flag (after the terminal-error check, before cargando). **Reqs:** S7-R2, S7-R3. **DONE.**
- [x] **T-S7-06** [GREEN] Reconnect state machine: pure backoff/decision logic extracted to NEW `lib/servicios/controlador_reconexion.dart` (`ControladorReconexion`, maxReintentos=5, base=1s, cap=30s, injectable timer factory); `_gestionarErrorReproduccion` enters it for network-class errors (`PlayerException` 2xxx OR `TimeoutException` from the 12s source guard) when intent=play; retries re-issue the source through the revision-guarded `_cambiarFuente` queue; success (`ready`+`playing`) resets; `pause`/`stop`/`playMediaItem` cancel/reset; exhaustion falls through to the single terminal error. `_cambiarFuente` completes normally when reconnect engaged so `EstadoRadio.reproducir` does NOT snackbar during retries (S7-R3). **Reqs:** S7-R2. **DONE.**
- [x] **T-S7-07** [GREEN] `mini_reproductor.dart` (spinner for reconectando + `playbackStatusReconnecting` label in `_labelEstado`), `pantalla_reproductor.dart` (`_WaveHero` + `_Controles` treat reconectando as loading, not error), `visualizador_audio.dart` (reconectando keeps the visualizer active like cargando). NEW l10n key `playbackStatusReconnecting` in ALL 13 .arb locales + gen-l10n. **Reqs:** S7-R3. **DONE.**
- [x] **T-S7-08** [GREEN] Boundary comment added at the `_estadoSub` listener in `pantalla_alarma_sonando.dart`: only `reproduciendo` cancels the 12s fallback timer; `reconectando` does NOT count as playing, WAV fallback stays authoritative. (Code already correct by construction — the listener checks `== reproduciendo`.) **Reqs:** S7-R4. **DONE.**
- [x] **T-S7-09** [GREEN] `ServicioGrabacionRadio` error handling untouched; S7-R5 invariant comment added above `_fallar`. **Reqs:** S7-R5. **DONE.**
### S7 verification
- [x] **T-S7-10** Run `flutter test test/servicios/servicio_audio_reconnect_test.dart test/widgets/reconnect_ui_test.dart` — 10/10 green (RED captured first: `+0 -2` load failures).
- [x] **T-S7-11** Run `flutter test` (full suite) — 99/99 passing (89 baseline + 10 new), no regressions.
- [x] **T-S7-12** Run `flutter analyze``No issues found!`.
- [x] **T-S7-13** Run `dart format` on all 9 touched Dart files (incl. new controller, pantalla_reproductor, visualizador, grabacion, both test files; 2 reflowed).
### 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
- [x] **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)
**DONE — round-trip also locks alarmas raw passthrough + "sin asignar" group never exported; malformed cases: invalid JSON, empty string, non-object JSON.**
- [x] **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)
**DONE — Test A via `cambiarPreset` (the public preset-change API); Test B counts both notifiers on `estado.ecualizador.cambiarPreset` (radio = 0, eq ≥ 1).**
### S4a implementation
- [x] **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. **DONE — service owns the v2 envelope (`construirExportacion`), pretty-print (`exportar`) and graceful parse (`importar` → `Map?`, null on malformed). `dart:convert` removed from pantalla_ajustes; EstadoRadio exposes `exportarConfigJson`/`parsearConfigJson`. DEVIATION: `Map<String,dynamic>` instead of a `ConfiguracionCompleta` model (see apply-progress).**
- [x] **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. **DONE — owns principal/actual/per-station presets + activo, persistence via ServicioEcualizador, application via ServicioAudio; `emisoraActualUuid` callback decouples it from station lists. Registered via `ListenableProvider` (not ProxyProvider — see deviation) in `app.dart`; instance owned/disposed by EstadoRadio during the S4 transition.**
- [x] **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. **DONE — 15 delegating getters/methods, every one tagged `// TODO(S4b): remove getter`. EQ fields, `_cargarEcualizadorPersistido`, `_aplicarPresetActivo`, `_presetParaEmisora` removed from EstadoRadio.**
- [x] **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. **DONE — `_SeccionEcualizador` now `Consumer2<EstadoRadio, EstadoEcualizador>` (radio only for station/favorite info); `ecualizador_widget.dart` is purely presentational (props + callbacks), no change needed. ALSO rewired `pantalla_reproductor.dart` EQ toggle (required for correctness — see deviation).**
### S4a verification
- [x] **T-S4a-07** Run `flutter test test/servicios/servicio_export_import_test.dart test/estado/estado_ecualizador_test.dart` — 4/4 green (RED captured first: `+0 -2` load failures).
- [x] **T-S4a-08** Run `flutter test` (full suite) — 103/103 passing (99 baseline + 4 new), no regressions.
- [x] **T-S4a-09** Run `flutter analyze``No issues found!`.
- [x] **T-S4a-10** Run `dart format` on all 8 touched Dart files (4 reflowed); analyze + full suite re-run after format.
### 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
- [x] **T-S4b-01** [RED] Create `test/estado/estado_grabacion_test.dart`: `ServicioGrabacionRadio` is managed by `EstadoGrabacion`; notifies listeners on recording state change. (S4-R2) **DONE — 4 tests: notify-on-state-change, iniciar delegates with current station, no-station → alError without service call, service error state → alError.**
- [x] **T-S4b-02** [RED] Create `test/estado/estado_busqueda_test.dart`: search query update notifies `EstadoBusqueda` listeners. (S4-R3) **DONE — 3 tests: notify on buscar, pagination/memory cap (moved from estado_radio_test), identity-stable `resultados` getter (S4-R5 enabler).**
- [x] **T-S4b-03** [RED] Add widget test: changing EQ preset does NOT rebuild `PantallaInicio` (S4-R5-A). **DONE — `test/pantallas/pantalla_inicio_rebuild_test.dart` via `debugPrintRebuildDirtyWidgets` log probe (dirty-flag probe is invalid: provider defers dependent notification to the next build phase) + positive control (cargarPopulares DOES rebuild).**
### S4b implementation
- [x] **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. **DONE — owns ServicioGrabacionRadio, the state subscription, dir/maxBytes/open-file actions and the `pluriwave/file_actions` MethodChannel; `emisoraActual` + `alError` callback seams (mirrors S4a). ListenableProvider in app.dart.**
- [x] **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. **DONE — also owns nearby-stations (cercanas) lookup and min-bitrate filter (they shared search state); `ordenListas`/`textos`/`alError` callback seams. ListenableProvider in app.dart.**
- [x] **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. **DONE — selects over identity-memoized getters (NEW `lib/estado/orden_emisoras.dart` MemoLista); cercanas/genre-search sections consume EstadoBusqueda.**
- [x] **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. **DONE — Grabaciones → watch<EstadoGrabacion>; Timer/Orden/Grupos/Preferida/Emisoras → context.select; _SeccionInfo keeps its scoped Consumer.**
- [x] **T-S4b-08** [GREEN] Edit `lib/pantallas/pantalla_favoritos.dart`: scope the `EstadoRadio` watch. **Reqs:** S4-R5. **DONE — selects listaFavoritos + gruposFavoritos. ALSO: pantalla_buscar root watch → watch<EstadoBusqueda>; pantalla_reproductor `_GrabacionWidget` → watch<EstadoGrabacion> (required: EstadoRadio no longer notifies on recording/search).**
- [x] **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. **DONE — all 15 compat members removed (zero TODO(S4b) in lib/); recording + search state/methods extracted; EstadoRadio 1121 (pre-split) → 753 lines, focused on playback/stations/favorites orchestration.**
### S4b verification
- [x] **T-S4b-10** Run `flutter test test/estado/estado_grabacion_test.dart test/estado/estado_busqueda_test.dart` plus the rebuild scope test — 8/8 green (RED captured first: `+0 -3` load failures).
- [x] **T-S4b-11** Run `flutter test` (full suite) — 110/110 passing (103 baseline 1 moved pagination test + 8 new), no regressions.
- [x] **T-S4b-12** Run `flutter analyze``No issues found!`.
- [x] **T-S4b-13** Run `dart format` on all 15 touched Dart files (10 reflowed); analyze + suite re-run after format.
### 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 |