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

48 KiB
Raw Blame History

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

  • 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.
  • 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)

  • 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.
  • 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).
  • 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.
  • 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.)
  • 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.
  • 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.
  • 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.
  • 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.
  • 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

  • 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.
  • 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.
  • 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

  • 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.)
  • T-S1-16 Run flutter analyze — zero errors. (No issues found!, identical to pre-S1 baseline.)
  • 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

  • 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.
  • 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.
  • 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)

  • 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.
  • 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.
  • 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.
  • 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

  • T-S2a-08 [GREEN] EventoAlarmaAndroid extended with snoozeUntilMillis field and accionSnoozed const; app.dart _abrirAlarmaSonando ignores snoozed events (EstadoAlarmas owns them). DONE.
  • 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.
  • 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.
  • 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.
  • T-S2a-12 [GREEN] obtenerEstadoSnoozeNativo() added to PuertoAlarmasAndroid + impl invoking getNativeSnoozeState; new EstadoSnoozeNativo model with fromMap. DONE.
  • 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

  • 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.
  • 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.
  • 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

  • T-S2a-17 flutter test on the three snooze test files — all green (RED phase captured first: compile failures + anchor mismatch).
  • T-S2a-18 flutter test (full suite) — 77/77 passing, no regressions.
  • T-S2a-19 flutter analyzeNo issues found!.
  • 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

  • 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.
  • 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

  • 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.
  • 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.
  • T-S2b-05 [GREEN] Glass surface wrapped in .pluriFadeIn(context) entry animation. Reqs: S2-R7. DONE.
  • 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.
  • 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.
  • 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

  • T-S2b-09 flutter test on both S2b test files — 7/7 green (RED captured first).
  • T-S2b-10 flutter test (full suite) — 77/77 passing, no regressions.
  • T-S2b-11 flutter analyzeNo issues found!.
  • 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

  • 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.
  • 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.
  • 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).
  • 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

  • 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.
  • 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.
  • 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.
  • 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.
  • 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.
  • 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.
  • 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

  • T-S3a-12 Run flutter test on the four new S3a files — green (RED captured first: 1 passed / 6 failed across the batch).
  • T-S3a-13 Run flutter test (full suite) — 89/89 passing (77 baseline + 12 new), no regressions.
  • T-S3a-14 Run flutter analyzeNo issues found!.
  • 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

  • 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

  • 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.
  • 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

  • T-S3b-04 Run flutter test test/servicios/servicio_audio_session_test.dart — 5/5 green (RED captured first: load failure, file missing).
  • T-S3b-05 Run flutter test (full suite) — 89/89, no regressions.
  • T-S3b-06 Run flutter analyzeNo issues found!.
  • T-S3b-07 Run dart format lib/servicios/servicio_audio_session.dart lib/servicios/servicio_audio.dart — applied (included in the 19-file format pass).

S3b Definition of Done

  • flutter test green.
  • flutter analyze clean.
  • dart format applied.
  • Reqs checked off: S3-R1 (flutter analyze import present; on-device call-pause deferred to user).

Slice S7 — Streaming resilience (~285 lines)

Depends on S3b (intent flag seam). Covers Design Decisions 7.17.2.

S7 pre-work: write failing tests

  • T-S7-01 [RED] Create test/servicios/servicio_audio_reconnect_test.dart:
    • Test A: backoff delay sequence for retries 15 is [1s, 2s, 4s, 8s, 16s] capped at maxDelay (S7-R7). 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).
  • 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.
  • 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

  • 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.
  • 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.
  • 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.
  • 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.
  • 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.
  • T-S7-09 [GREEN] ServicioGrabacionRadio error handling untouched; S7-R5 invariant comment added above _fallar. Reqs: S7-R5. DONE.

S7 verification

  • 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).
  • T-S7-11 Run flutter test (full suite) — 99/99 passing (89 baseline + 10 new), no regressions.
  • T-S7-12 Run flutter analyzeNo issues found!.
  • 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

  • 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.
  • 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

  • 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 (importarMap?, 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).
  • 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.
  • 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.
  • 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

  • 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).
  • T-S4a-08 Run flutter test (full suite) — 103/103 passing (99 baseline + 4 new), no regressions.
  • T-S4a-09 Run flutter analyzeNo issues found!.
  • 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

  • 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.
  • 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).
  • 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

  • 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.
  • 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.
  • 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.
  • 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; Timer/Orden/Grupos/Preferida/Emisoras → context.select; _SeccionInfo keeps its scoped Consumer.
  • 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; pantalla_reproductor _GrabacionWidget → watch (required: EstadoRadio no longer notifies on recording/search).
  • 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

  • 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).
  • T-S4b-11 Run flutter test (full suite) — 110/110 passing (103 baseline 1 moved pagination test + 8 new), no regressions.
  • T-S4b-12 Run flutter analyzeNo issues found!.
  • 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