52855e75c2
- 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
470 lines
48 KiB
Markdown
470 lines
48 KiB
Markdown
# Tasks: app-quality-and-native-alarms
|
||
|
||
## Review Workload Forecast
|
||
|
||
| Field | Value |
|
||
|-------|-------|
|
||
| Estimated changed lines (total) | ~1 850–2 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.1–2.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.4–2.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.1–7.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 1–5 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 |
|