Files
pluriwave/openspec/changes/app-quality-and-native-alarms/apply-progress.md
T
FreeTLab 0380bbb1e7 feat(streaming): buffer resilience and automatic reconnection
- Construct the audio player with an enlarged live-stream buffer (15-50s forward cushion, 2.5s to start, 5s after rebuffer) so short network drops play through silently
- Add reconnect-on-stall state machine with bounded exponential backoff (1/2/4/8/16s, ~90s total window, 5 attempts) that re-prepares to the live edge; backoff/decision logic extracted to controlador_reconexion.dart as pure testable code
- Surface a new reconnecting playback state in the mini player and full player (localized in all 13 locales) instead of error dialogs during the retry window; a single friendly error appears only after exhaustion
- Guard interplay: user pause/stop cancels retries, audio interruptions cancel reconnect, alarm wake-up path keeps precedence, recording fails cleanly during drops
- Reset retry budget on station change; route stream timeouts through the network-error class
- 10 new tests (99 total green), flutter analyze clean
2026-06-11 19:54:30 +02:00

41 KiB
Raw Blame History

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

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

Batch log

Batch Slice Status Date
1 S1 — Alarm native reliability COMPLETE (Dart verified; Kotlin/manifest on-device verification deferred to user) 2026-06-11
2 S2a + S2b — Snooze correctness end-to-end + Alarm UX parity COMPLETE (Dart verified; Kotlin on-device verification deferred to user) 2026-06-11
3 S3a + S3b — Test seams (statics/prefs/cache/mutex/dirty-guard/bounded set) + audio_session COMPLETE (Dart-only batch; call-pause on-device verification deferred to user) 2026-06-11
4 S7 — Streaming resilience (buffer config, reconnect state machine, UI wiring) COMPLETE (Dart-only batch; stream-drop on-device verification deferred to user) 2026-06-11

Task status (cumulative)

Slice S1 — Alarm native reliability — 17/17 complete

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

Slice S2a — Snooze correctness — 20/20 complete

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

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

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

Slice S3a — Test seams — 15/15 complete

Task Status Notes
T-S3a-01 [x] RED: servicio_alarmas_android_instance_test.dart — two channels, simulated alarmFired via handlePlatformMessage, isolation asserted both ways
T-S3a-02 [x] RED: servicio_alarmas_cache_test.dart_PrefsEspia implements SharedPreferences (setString/getString counters); no-write-when-clean, exactly-one-write-when-dirty, concurrent-no-lost-write (+ single cache hydration)
T-S3a-03 [x] RED: estado_alarmas_ejecuciones_test.dart — 100 stale entries pruned (1 fresh survives) + 250-entry cap test
T-S3a-04 [x] RED: mini_reproductor_configurar_test.dart — 10 rebuilds → 1 configurar; locale es→en → 2
T-S3a-05 [x] GREEN: ServicioAlarmasAndroid statics → instance fields; handler installed per instance in ctor. DEVIATION: no deprecated static shim (Dart name clash + only call site rewired in same change); configurarLocalizaciones added to PuertoAlarmasAndroid interface instead
T-S3a-06 [x] GREEN: MiniReproductor → StatefulWidget, locale-guarded didChangeDependencies; alarm-bridge l10n hoisted to app.dart _PaginaPrincipalState.didChangeDependencies (design 3.3 alternative), before the early-return
T-S3a-07 [x] GREEN: main.dart resolves prefs ONCE; PluriWaveApp(prefs:)EstadoRadio/EstadoAlarmas(→ServicioAlarmas)/EstadoIdioma
T-S3a-08 [x] GREEN: injected-with-fallback _resolverPrefs() in estado_radio (10 sites), servicio_ecualizador (6), servicio_grabacion_radio (4), servicio_contenido_app (3). rg check: only main.dart + one fallback per class remain
T-S3a-09 [x] GREEN: recalcularTodas dirty-guard — serialized comparison vs _cacheRaw, skips write when identical
T-S3a-10 [x] GREEN: _cache/_cacheRaw + _enCola writer queue; all 8 mutation methods queued over _configActual(). DEVIATION: public cargar() still re-reads prefs (queued cache reset) because EstadoRadio.importarConfig writes the raw alarms key directly — a fully cached cargar would hide imports until restart
T-S3a-11 [x] GREEN: bounded _ejecucionesEmitidas — cap 200 + 24 h retention, pruned on every add and each _vigilarAlarmasVencidas pass; @visibleForTesting length getter
T-S3a-12 [x] Targeted S3a tests green (RED first: 1 passed / 6 failed across the batch)
T-S3a-13 [x] Full suite 89/89 (77 baseline + 12 new)
T-S3a-14 [x] flutter analyze — No issues found
T-S3a-15 [x] dart format on 19 touched files

Slice S3b — audio_session + intent flag — 7/7 complete

Task Status Notes
T-S3b-01 [x] RED: servicio_audio_session_test.dart — 5 tests (pause-begin, resume-end, no-resume-without-prior-pause, duck begin/end, becoming-noisy hard pause) over fake ObjetivoAudioInterrumpible
T-S3b-02 [x] GREEN: lib/servicios/servicio_audio_session.dartmusic().copyWith(androidWillPauseWhenDucked: true); interruption + becoming-noisy subscriptions; _pausadoPorInterrupcion gate for auto-resume; defines ObjetivoAudioInterrumpible (test seam)
T-S3b-03 [x] GREEN: PluriWaveAudioHandler implements ObjetivoAudioInterrumpible; _intencionReproducir true in play()/playMediaItem(), false in pause()/stop() (S7 seam); duck via setAtenuado ×0.3 (_volumenEfectivo); configurar() wired in main.dart
T-S3b-04 [x] Targeted run 5/5 green (RED first: load failure)
T-S3b-05 [x] Full suite 89/89
T-S3b-06 [x] flutter analyze — No issues found
T-S3b-07 [x] dart format applied

Slice S7 — Streaming resilience — 13/13 complete

Task Status Notes
T-S7-01 [x] RED: servicio_audio_reconnect_test.dart — 8 tests over ControladorReconexion (backoff sequence + cap, retry scheduled with intent=true, NO retry with intent=false, exhaustion → agotado, restablecer resets counter + backoff base, cancelar kills pending timer) via injectable fake-timer factory
T-S7-02 [x] RED: buffer-config test asserts PluriWaveAudioHandler.configuracionCargaAndroid values (15s/50s/2.5s/5s, prioritizeTime=true) — construction wiring not unit-testable without platform channels (see deviations)
T-S7-03 [x] RED: reconnect_ui_test.dart — reconectando shows spinner + "Reconectando..." label, NO AlertDialog/SnackBar, NO manual-retry button; second test locks retry button to error state only
T-S7-04 [x] GREEN: _crearPlayer passes audioLoadConfiguration: configuracionCargaAndroid; named static const durations. just_audio 0.9.46 API verified in pub-cache source — all design params exist, NO deviation
T-S7-05 [x] GREEN: EstadoReproduccion.reconectando added; estadoStream maps the handler's reconectando flag (error wins, then reconectando, then cargando)
T-S7-06 [x] GREEN: NEW lib/servicios/controlador_reconexion.dart (pure logic: maxReintentos=5, base=1s, cap=30s); handler enters reconnect on network-class errors (PlayerException 2xxx OR TimeoutException) with intent=play; retry re-issues source via revision-guarded _cambiarFuente; ready+playing resets; pause/stop/playMediaItem cancel/reset; exhaustion → single terminal error; _cambiarFuente returns normally when reconnect engaged so EstadoRadio.reproducir doesn't snackbar mid-retry
T-S7-07 [x] GREEN: mini player (spinner + playbackStatusReconnecting in _labelEstado), full player (_WaveHero/_Controles: reconectando = loading, not error), visualizer stays active; l10n key in ALL 13 .arb locales + gen-l10n
T-S7-08 [x] GREEN: S7-R4 boundary comment at _estadoSub listener — only reproduciendo cancels the alarm's 12s fallback timer; reconectando never counts as playing (code already correct, now documented + locked by enum distinctness)
T-S7-09 [x] GREEN: ServicioGrabacionRadio untouched except S7-R5 invariant comment above _fallar
T-S7-10 [x] Targeted run 10/10 green (RED first: +0 -2 load failures)
T-S7-11 [x] Full suite 99/99 (89 baseline + 10 new)
T-S7-12 [x] flutter analyze — No issues found
T-S7-13 [x] dart format on 9 touched files (2 reflowed); re-ran suite + analyze after format

Remaining slices (not started)

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

Snooze defect fixes (design audit D1D5 / S1S5)

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

TDD Cycle Evidence (Strict TDD hard gate)

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

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

Batch 3 TDD Cycle Evidence (S3a + S3b)

Task RED (test written first, failing) GREEN (implementation passes) REFACTOR
T-S3a-01/T-S3a-05 Static controller shared events: expect(eventosB, isEmpty) FAILED ('solo-a' leaked into B) Statics → instance fields; isolation test passes Comment documenting handler re-bind semantics
T-S3a-02-A/T-S3a-09 recalcularTodas always wrote: Expected: <1> Actual: <2> writes Dirty-guard skips clean writes _serializar extracted, shared with _guardar
T-S3a-02-B Passed pre-fix (exactly-once lock-in guard) Still passes (regression lock)
T-S3a-02-C/T-S3a-10 Lost write: final config had 1 of 2 alarms; 2 hydration reads Cache + _enCola queue: both alarms persisted, 1 hydration read Mutation bodies kept verbatim, only wrapped
T-S3a-03/T-S3a-11 Load failure: ejecucionesEmitidasLength/maxEjecucionesEmitidas undefined Bounded set: 1 survivor of 101, cap respected Prune helper shared by add-path and watch-pass
T-S3a-04/T-S3a-06 Expected: <1> Actual: <11> (configurar on every rebuild) StatefulWidget + locale guard: 1 then 2 FakeServicioAudio gained l10n override (assert fix)
T-S3b-01/02/03 Load failure: servicio_audio_session.dart missing 5/5 green against fake objetivo

RED run evidence (Batch 3): 00:06 +1 -6 before implementation (the single pass is the exactly-once write lock-in). GREEN: targeted 12/12; full suite 00:12 +89: All tests passed!.

Batch 4 TDD Cycle Evidence (S7)

Task RED (test written first, failing) GREEN (implementation passes) REFACTOR
T-S7-01/T-S7-06 Load failure: controlador_reconexion.dart missing (+0 -2 run) ControladorReconexion created; 6 decision/backoff tests pass Doc comments tying defaults to the ~60-90s design window
T-S7-02/T-S7-04 Same RED run: configuracionCargaAndroid undefined Const config + _crearPlayer wiring; values test passes Durations extracted as named static const
T-S7-03/T-S7-05/T-S7-07 Compile failure: EstadoReproduccion.reconectando missing Enum + stream mapping + UI wiring; both widget tests pass Test fixed to double-pump (stream event delivery + frame); diag run proved impl correct before the fix

RED run evidence (Batch 4): 00:00 +0 -2 (both files fail to load). GREEN: targeted 00:01 +10: All tests passed!; full suite 00:08 +99: All tests passed! (89 baseline + 10 new).

Files changed (Batch 2)

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

Files changed (Batch 3)

File Action ~Lines
lib/servicios/servicio_audio_session.dart Created +115 (session config, interruption/noisy handling, ObjetivoAudioInterrumpible)
lib/servicios/servicio_alarmas.dart Modified +106/-64 (cache, _enCola queue, dirty-guard, _parsear/_serializar)
lib/estado/estado_alarmas.dart Modified +55/-2 (bounded set, configurarLocalizaciones, prefs→servicio default)
lib/servicios/servicio_alarmas_android.dart Modified +18/-11 (statics → instance, interface method)
lib/servicios/servicio_audio.dart Modified +52/-3 (intent flag, ObjetivoAudioInterrumpible impl, duck volume)
lib/estado/estado_radio.dart Modified +16/-15 (prefs param + _resolverPrefs, static alarm-l10n call removed)
lib/widgets/mini_reproductor.dart Modified +22/-2 (StatefulWidget, locale-guarded didChangeDependencies)
lib/main.dart Modified +14/-2 (prefs once, ServicioAudioSession wiring)
lib/app.dart Modified +22/-3 (PluriWaveApp.prefs, alarm l10n locale guard)
lib/servicios/servicio_ecualizador.dart Modified +14/-6 (prefs injection)
lib/servicios/servicio_grabacion_radio.dart Modified +13/-4 (prefs injection)
lib/servicios/servicio_contenido_app.dart Modified +11/-3 (prefs injection)
test/helpers/fakes.dart Modified +8 (configurarLocalizaciones override on FakeServicioAudio)
test/helpers/fakes_alarmas.dart Modified +4 (interface no-op)
test/servicios/servicio_alarmas_android_instance_test.dart Created +53
test/servicios/servicio_alarmas_cache_test.dart Created +105
test/estado/estado_alarmas_ejecuciones_test.dart Created +85
test/widgets/mini_reproductor_configurar_test.dart Created +85
test/servicios/servicio_audio_session_test.dart Created +130

Total Batch 3 diff: ~362 insertions / ~122 deletions in lib + helpers, plus ~458 lines of new tests.

Files changed (Batch 4)

File Action ~Lines
lib/servicios/controlador_reconexion.dart Created +100 (decision enum + backoff controller, injectable timer factory)
lib/servicios/servicio_audio.dart Modified +148/-7 (enum reconectando, buffer config consts, reconnect integration, TimeoutException routing, pause/stop/play resets)
lib/widgets/mini_reproductor.dart Modified +6/-2 (spinner for reconectando, label case)
lib/pantallas/pantalla_reproductor.dart Modified +8/-2 (reconectando = loading in _WaveHero + _Controles)
lib/widgets/visualizador_audio.dart Modified +2/-1 (reconectando keeps visualizer active)
lib/pantallas/pantalla_alarma_sonando.dart Modified +5 (S7-R4 boundary comment)
lib/servicios/servicio_grabacion_radio.dart Modified +4 (S7-R5 invariant comment)
lib/l10n/app_*.arb (13 files) Modified +1 each (playbackStatusReconnecting)
lib/l10n/gen/* (14 files) Regenerated by flutter gen-l10n
test/servicios/servicio_audio_reconnect_test.dart Created +210 (8 tests)
test/widgets/reconnect_ui_test.dart Created +100 (2 tests)

Total Batch 4 diff: ~235 insertions / ~13 deletions in lib (incl. ARB/gen), plus ~310 lines of new tests. Within the ~285-line slice estimate. No Kotlin/native files touched.

Deviations from design (Batch 3)

  1. No deprecated static shim for ServicioAlarmasAndroid.configurarLocalizaciones (task text asked for one). Dart forbids a static and an instance member with the same name in one class, and the ONLY external call site (estado_radio.dart:74) was rewired in this same slice — a renamed shim would be unreachable dead code. Instead configurarLocalizaciones joined the PuertoAlarmasAndroid interface (fakes no-op it).
  2. Alarm-bridge l10n configured from app.dart, not from MiniReproductor — design 3.3 offered both options; the mini player now only configures its real dependency (EstadoRadio), and _PaginaPrincipalState.didChangeDependencies (locale-guarded, placed BEFORE the existing early-return) forwards l10n to EstadoAlarmas.
  3. Public ServicioAlarmas.cargar() re-reads from prefs instead of serving the cache. EstadoRadio.importarConfig writes the raw alarmas_musicales_v1 key directly to SharedPreferences; a fully cached cargar() would make a settings import invisible until app restart. Mutations DO use the cache (_configActual), which is what S3-R7's race fix and "one cargar per mutation burst" require. The re-read is queued, so it cannot interleave with a mutation.
  4. Duck handling added beyond the task text: setAtenuado on the handler scales effective volume ×0.3 (restored on interruption end). With androidWillPauseWhenDucked: true Android delivers duck as pause, so this is mostly the iOS/edge path; kept minimal.
  5. _PrefsEspia implements SharedPreferences via noSuchMethod rather than pulling shared_preferences_platform_interface into the tests — avoids a depend_on_referenced_packages lint on a transitive dep.
  6. servicio_contenido_app.dart also migrated (3 getInstance sites; not named in the task). Its only construction site is static final in pluri_onboarding_dialog.dart, which keeps the fallback path at runtime — acceptable under the injected-with-fallback compat net; full injection there would require a dialog refactor out of S3 scope.
  7. Two-instances-same-channel semantics documented, not prevented: with instance handlers, constructing a second ServicioAlarmasAndroid over the SAME MethodChannel re-binds the platform handler to the newest instance. Production creates exactly one per channel (provider singleton); tests use distinct channels.

Deviations from design (Batch 4)

  1. Reconnect logic lives in a NEW file lib/servicios/controlador_reconexion.dart, not inline in servicio_audio.dart (task text said "edit servicio_audio.dart"). Pure decision/backoff logic must be testable without platform channels (S7-R7); the handler cannot be constructed in tests (AudioPlayer hits MethodChannels at construction). The handler keeps only the integration glue.
  2. Buffer-config test asserts the config VALUES, not the construction call. Asserting that AudioPlayer(...) received the config would require constructing the real player (platform channels). The config is a static const on the handler; _crearPlayer passes it (one-line wiring, verified by review + on-device item 9). Honest scope of S7-R1's [flutter test] portion.
  3. TimeoutException treated as network-class in addition to the spec'd PlayerException 2xxx range. A real network drop usually surfaces as the existing 12s source-change timeout, NOT as a 2xxx PlayerException — without this, the most common stall would bypass reconnect entirely. The generic-Exception terminal path is otherwise unchanged.
  4. Stall detection is error-driven only (per task T-S7-06 text); the design's optional "buffering > 8-10s watchdog" was NOT implemented. ExoPlayer/just_audio surfaces dead live streams as errors or our timeout; a buffering watchdog would add a timer racing the buffer config for marginal gain. Flagged for on-device validation (item 9): if a silent endless-buffering hang is observed, add the watchdog in a follow-up.
  5. _cambiarFuente completes normally when reconnect is engaged (returns instead of rethrowing). Previously every failure rejected the playMediaItem future and EstadoRadio.reproducir's catch pushed an error snackbar — that would show an error on the FIRST failure even while reconnecting, violating S7-R3. User-facing rejection still happens when reconnect does NOT engage (non-network error, no intent, exhausted).
  6. restablecer() on playMediaItem (fresh user play/source switch restarts the backoff budget). Not explicit in the task text but required so the retry path (which goes through _cambiarFuente internally, not playMediaItem) can exhaust while user-initiated switches always get a full budget.
  7. Widget tests need a double tester.pump() after a broadcast-stream emission (one pump delivers the event, the second rebuilds). Verified with a diagnostic harness that the implementation was correct and only the test needed the fix.

Deviations from design (Batch 2)

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

Issues found

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

On-device verification checklist for the user

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

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

Verification summary (Batch 2)

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

Verification summary (Batch 3)

  • flutter test: 89/89 passing (77 baseline + 12 new across 5 files)
  • flutter analyze: No issues found (identical to baseline)
  • dart format: applied to all 19 touched Dart files (5 reflowed)
  • rg 'SharedPreferences.getInstance()' lib/: only main.dart startup resolution + one injected-with-fallback expression per class (estado_alarmas, estado_idioma, estado_radio, servicio_alarmas, servicio_ecualizador, servicio_grabacion_radio, servicio_contenido_app)
  • flutter build: NOT run (forbidden)
  • No Kotlin/native files touched in this batch

On-device verification items added by Batch 3 (user — Android device)

  1. Phone call pauses radio (S3-R1, checklist item 10): while the radio plays, receive a call → radio pauses (or ducks); after the call ends it resumes automatically (transient loss).
  2. Headphones unplugged pauses radio (S3-R1): unplug wired headphones / disconnect BT while playing → radio pauses and does NOT auto-resume.
  3. Another media app takes focus: start playback in another app → PluriWave pauses; it must not resume on its own when focus is permanent loss.
  4. Locale switch sanity: change app language in Ajustes → alarm titles/station names sent to new native schedules use the new language (l10n now configured per locale change, not per rebuild).
  5. Settings import still reflects alarms immediately (cache bypass in cargar()): import a backup with alarms → the alarms list shows them without restarting the app.

Verification summary (Batch 4)

  • flutter test: 99/99 passing (89 baseline + 10 new across 2 files); re-run after dart format
  • flutter analyze: No issues found (identical to baseline); re-run after format
  • dart format: applied to all 9 touched Dart files (2 reflowed); gen/ untouched by hand
  • flutter gen-l10n: run once after the 13 .arb edits
  • flutter build: NOT run (forbidden)
  • No Kotlin/native files touched in this batch (S7-R4: native alarm audio path untouched by construction)

What the buffer actually buys (honest expectations, Design 7.1)

  • Configured: ExoPlayer keeps a 15-50s forward buffer; playback (re)starts after 2.5s buffered (5s after a rebuffer); time prioritized over byte thresholds.
  • Real drop ≲ buffered cushion (typically a few seconds up to ~15-30s depending on bitrate and how full the buffer was): audio keeps playing through the cushion, no UI change.
  • Drop longer than the cushion: playback stalls → "Reconectando..." spinner state (no error dialog/snackbar) → up to 5 backoff retries (1/2/4/8/16s delays + 12s attempt timeout each, total window ≈ up to ~90s) → on recovery the player rejoins the LIVE edge (live radio has no rewind — the missed audio is gone, not replayed) → on exhaustion, the single existing friendly error with manual retry.

On-device verification items added by Batch 4 (user — Android device)

  1. Short drop plays through (S7-R1, checklist item 9): while the radio plays (let it run ~1 min so the buffer fills), disable WiFi/LTE for ~10s → audio continues without interruption and no UI state change.
  2. Long drop reconnects (S7-R2/R3, checklist item 9): disable connectivity ~45s → mini player and full player show "Reconectando..." with spinner (NO error dialog/snackbar); re-enable within ~90s → playback resumes at the live edge automatically.
  3. Exhaustion surfaces one error (S7-R2-C): leave connectivity off >2 min → exactly ONE error state with the manual retry button appears after retries exhaust; no error spam during the retry window.
  4. User pause/stop during reconnect (S7-R6): trigger a drop, then tap pause/stop while "Reconectando..." → playback stays stopped; it must NOT restart on its own when connectivity returns.
  5. Alarm fallback not delayed (S7-R4, checklist item 11): alarm with a non-responding station URL → bundled WAV fires within the existing ~12-15s window, NOT extended by reconnect attempts.
  6. Recording during a drop (S7-R5): record while forcing a drop → recording fails/stops cleanly per existing behavior; a subsequent recording start works.
  7. Sleep timer during a drop (S7-R6): sleep timer expiring during "Reconectando..." stops audio for good.

Workload / boundary

  • Mode: auto-chain local slices (no PRs)
  • Current work units: S1, S2a, S2b, S3a, S3b (committed f3e9487, 079e19f), S7 (complete, in working tree)
  • Boundary (Batch 4): starts from the clean post-079e19f tree; ends with S7 fully checked off, suite green (99/99). Rollback = revert the Batch-4 files listed above (Dart-only; no native edits).
  • Next batch: S4a (ServicioExportImport + EstadoEcualizador extraction). No dependency on S7; on-device items above can be verified in parallel.