- Integrate audio_session (new servicio_audio_session.dart): incoming calls pause the radio and resume on end, headphone unplug pauses without auto-resume, permanent focus loss never auto-resumes, duck lowers volume - Add play-intent flag to ServicioAudio so interruption handling and future reconnect logic can distinguish user pause from system-driven stops - Eliminate read-modify-write race in ServicioAlarmas with an in-memory cache and single-writer queue across all mutations; recalcularTodas persists only when state actually changed - Convert ServicioAlarmasAndroid static StreamController/handler to injectable instance fields, restoring test isolation - Inject a single cached SharedPreferences from main.dart across services and state (removes 23 inline getInstance() calls) - Move configurarLocalizaciones out of MiniReproductor.build() (was running on every rebuild during playback) - Bound the alarm fire-dedup set (cap 200 entries, 24h pruning) - 12 new tests (89 total green), flutter analyze clean
30 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 3)
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 |
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 |
Remaining slices (not started)
S7, 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!.
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.
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 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.
Workload / boundary
- Mode: auto-chain local slices (no PRs)
- Current work units: S1, S2a, S2b (committed
f3e9487), S3a + S3b (complete, in working tree) - Boundary (Batch 3): starts from the clean post-f3e9487 tree; ends with S3a+S3b fully checked off, suite green. Rollback = revert the Batch-3 files listed above (Dart-only; no native edits).
- Next batch: S7 (streaming resilience) — depends on the
_intencionReproducirseam andObjetivoAudioInterrumpiblelanded here. No on-device prerequisite for S7 implementation, but items 1-2 above validate the seam S7 builds on.