- 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
41 KiB
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 analyze — No 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; 0xFFFFB86B → tokens.warmCoral; blurSigma: 10 + cold-GPU comment (Design 2.4) |
| T-S2b-04 | [x] | GREEN: lib/tema/pluri_animate.dart — pluriFadeIn/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.dart — music().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 D1–D5 / S1–S5)
| Defect | Fix | Where |
|---|---|---|
| D1 (audit S1) — native snooze never notifies Flutter | ACTION_SNOOZE → MainActivity.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)
- 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. InsteadconfigurarLocalizacionesjoined thePuertoAlarmasAndroidinterface (fakes no-op it). - Alarm-bridge l10n configured from
app.dart, not fromMiniReproductor— 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 toEstadoAlarmas. - Public
ServicioAlarmas.cargar()re-reads from prefs instead of serving the cache.EstadoRadio.importarConfigwrites the rawalarmas_musicales_v1key directly to SharedPreferences; a fully cachedcargar()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. - Duck handling added beyond the task text:
setAtenuadoon the handler scales effective volume ×0.3 (restored on interruption end). WithandroidWillPauseWhenDucked: trueAndroid delivers duck as pause, so this is mostly the iOS/edge path; kept minimal. _PrefsEspiaimplements SharedPreferences via noSuchMethod rather than pullingshared_preferences_platform_interfaceinto the tests — avoids adepend_on_referenced_packageslint on a transitive dep.servicio_contenido_app.dartalso migrated (3 getInstance sites; not named in the task). Its only construction site isstatic finalinpluri_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.- Two-instances-same-channel semantics documented, not prevented: with instance handlers, constructing a second
ServicioAlarmasAndroidover 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)
- Reconnect logic lives in a NEW file
lib/servicios/controlador_reconexion.dart, not inline inservicio_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 (AudioPlayerhits MethodChannels at construction). The handler keeps only the integration glue. - 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 astatic conston the handler;_crearPlayerpasses it (one-line wiring, verified by review + on-device item 9). Honest scope of S7-R1's[flutter test]portion. TimeoutExceptiontreated 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.- 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.
_cambiarFuentecompletes normally when reconnect is engaged (returns instead of rethrowing). Previously every failure rejected theplayMediaItemfuture andEstadoRadio.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).restablecer()onplayMediaItem(fresh user play/source switch restarts the backoff budget). Not explicit in the task text but required so the retry path (which goes through_cambiarFuenteinternally, notplayMediaItem) can exhaust while user-initiated switches always get a full budget.- 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)
- Event subscription lives in the
EstadoAlarmasCONSTRUCTOR, notinicializar(task text said inicializar). Rationale: snoozed events must be recorded even before/without full init, and tests withiniciarAutomaticamente: falsestay light. Cancelled indispose. posponerEjecucionclamps minutes to 1..120 instead of coercing to 3/5/10. The oldcalcularSnoozecoercion 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.calcularSnoozekept (unused by this path) for API compatibility.- Existing test expectation updated (
estado_alarmas_test.dart): unified anchor makes snooze land atproximaEjecucion(+inminencia normalization) + 5min= 7:36:02, notnow+5= 7:36:00. This is the spec'd behavior change (S2-R6), documented inline. _recalcularnow 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.AlarmaMusical.copyWithgainedlimpiarEmisora/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).- Pre-existing debug crash fixed:
_EditorAlarmaSheetState.initStatecalledAppLocalizations.of(context)→dependOnInheritedWidgetOfExactTypeassert in debug builds. Name controller now created lazily indidChangeDependencies. (The sibling_EditorVacacionesSheethas the same latent issue — NOT fixed here, out of S2 scope; flag for S5/S6.) Material(type: transparency)wrappers added insidePluriGlassSurfacefor the editor sheet and the station picker — ListTiles inside a DecoratedBox trigger a debug assert and invisible ink splashes otherwise._liberarAudioLocaldoes 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.AlarmScheduler.snoozereturnsNativeSnoozeResult(until/origin/title) so the service can build the report-back payload;postponeNextuntouched (already had the unified anchor).- Ringing screen
blurSigmacapped 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 testdoes NOT auto-run gen-l10n in this setup despitegenerate: true;flutter gen-l10nmust be run manually after editing .arb files (gen files are committed).tester.tap+ an awaited broadcast-subscriptioncancel()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:
- Kotlin compiles — native layer edited again without compilation (HIGH risk): build FIRST.
- 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.
- Snooze from ringing screen (S2-R1, S2-R4): ring → tap "5 min" → notification dismissed, list shows snoozeHasta immediately, re-fires at that time.
- 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
getNativeSnoozeStateimport). - Snooze from notification while app foregrounded (S2-R3): same, but the list updates within the same frame via the
snoozedMethodChannel event. - Stop cancels pending snooze (S2-R5): snooze → disable the alarm from the list → does NOT re-fire.
- 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.
- 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 editsflutter 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/: onlymain.dartstartup 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)
- 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).
- Headphones unplugged pauses radio (S3-R1): unplug wired headphones / disconnect BT while playing → radio pauses and does NOT auto-resume.
- Another media app takes focus: start playback in another app → PluriWave pauses; it must not resume on its own when focus is permanent loss.
- 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).
- 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 afterdart formatflutter analyze: No issues found (identical to baseline); re-run after formatdart format: applied to all 9 touched Dart files (2 reflowed); gen/ untouched by handflutter gen-l10n: run once after the 13 .arb editsflutter 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)
- 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.
- 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.
- 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.
- 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.
- 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.
- Recording during a drop (S7-R5): record while forcing a drop → recording fails/stops cleanly per existing behavior; a subsequent recording start works.
- 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.