- Use mediaPlayback|systemExempted FGS type with FOREGROUND_SERVICE_SYSTEM_EXEMPTED so alarms fire on Android 14+ (FOREGROUND_SERVICE_ALARM does not exist in the SDK) - Deduplicate fire notifications: the foreground service FSI notification is the single owner; receiver path removed - Notification channel v2 with alarm sound URI and USAGE_ALARM attributes, one-time guarded migration from legacy channels - Pass fallback station through the MethodChannel (NativeAlarmSpec schemaVersion 3) with a three-stage audio chain: primary -> fallback station -> bundled WAV - Native fade-in volume ramp honoring fadeInSegundos when the app is killed - Request battery-optimization exemption once, tracked with a persisted asked-once flag - Fix snooze end-to-end: native ACTION_SNOOZE now reports back to Flutter (snoozed event + cold-start sync), snooze anchor unified to occurrence+minutes on both sides, periodic recalc no longer erases an active snooze - Add snooze buttons (3/5/10/custom) to the ringing screen with shared audio teardown - Redesign ringing screen on PluriWaveScaffold with reduced-motion-aware entry animation (new PluriAnimate helper) - Alarm editor: live next-trigger preview, searchable station pickers (primary and fallback), configurable snooze duration, volume floor down to 0 - New alarm strings localized across all 13 locales - New unit/widget tests for the snooze flow, alarm bridge payloads, ringing screen and editor (77 tests green) - SDD artifacts for the app-quality-and-native-alarms change (explore, proposal, spec, design, tasks, apply progress)
43 KiB
Design: app-quality-and-native-alarms
Technical design for the seven chained slices that raise PluriWave to native-Android-Clock alarm reliability, fix the snooze divergence, harden the audio/runtime layer, add streaming resilience, split the EstadoRadio god-class, and close the design-system / a11y / i18n / quality-gate gaps. The custom native alarm stack is KEPT and hardened. Every decision below is grounded in the actual code (file:line) so sdd-tasks can act without re-discovery.
This document is the HOW at the architectural level. It does not enumerate task steps (that is sdd-tasks). flutter build is FORBIDDEN; Kotlin is verified on-device by the user. Strict TDD applies via flutter test.
Architecture at a glance
| Layer | Owns | Key files |
|---|---|---|
| Flutter domain | Alarm data, recurrence math, persistence, single source of truth for snooze state | servicio_alarmas.dart, servicio_programacion_alarmas.dart, estado_alarmas.dart |
| Flutter audio | Radio playback, EQ, recording, reconnect-on-stall, audio focus | servicio_audio.dart (PluriWaveAudioHandler), new ServicioAudioSession |
| Flutter UI | Ringing screen, editor, mini player, screens | pantalla_alarma_sonando.dart, pantalla_alarmas.dart, mini_reproductor.dart |
| Bridge | MethodChannel pluriwave/alarm_scheduler (Flutter↔native), alarmFired callback (native→Flutter) |
servicio_alarmas_android.dart, MainActivity.kt |
| Native delivery | Exact wakeup (setAlarmClock), FSI notification, native audio + fade, snooze reschedule |
AlarmScheduler.kt, PluriWaveAlarmReceiver.kt, PluriWaveAlarmService.kt, AndroidManifest.xml |
The guiding principle for this change: the Flutter ServicioAlarmas config is the single source of truth for "postponed until". Native reschedules autonomously for wakeup reliability, but every native state mutation that changes the next occurrence MUST be reflected back to Flutter through the bridge so the two never diverge. Slice 2 establishes that protocol; the current divergence is the root cause of the user-reported broken snooze.
Slice 1 — Alarm native reliability (CRITICAL)
Decision 1.1 — Foreground-service type and permission (A1)
- Manifest: change
PluriWaveAlarmServicetoandroid:foregroundServiceType="mediaPlayback|alarm"(AndroidManifest.xml:54-57). Add<uses-permission android:name="android.permission.FOREGROUND_SERVICE_ALARM"/>next to the existingFOREGROUND_SERVICE_MEDIA_PLAYBACK(AndroidManifest.xml:5). - Service: when calling
startForeground(PluriWaveAlarmService.kt:75), on API ≥ 34 pass the explicit type bitmaskServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK or ServiceInfo.FOREGROUND_SERVICE_TYPE_ALARMvia the 3-argstartForeground(id, notification, type)overload. On API < 34 keep the 2-arg overload. - Rationale: include BOTH types because the service plays a radio stream (mediaPlayback) AND is an alarm (alarm). The
alarmtype is the one that satisfies the API 34+ FGS-launch-from-background restriction when started fromPluriWaveAlarmReceiver.onReceive(PluriWaveAlarmReceiver.kt:27). Alarm-triggered broadcasts receive a temporary FGS-while-in-use exemption, so starting analarm-typed FGS from the fire receiver is the documented, allowed path on API 34+. - ADR note (rejected): using only
mediaPlaybackkeeps the silent-failure bug; using onlyalarmwould block the legitimate media-playback path the radio stream needs. KEEP both.
Decision 1.2 — Single FSI notification, ownership and ordering (A3)
Today two notifications fire for one event: the receiver posts fireNotificationIdForAlarm (PluriWaveAlarmReceiver.kt:95-133) AND the service posts NOTIFICATION_ID 92841 via startForeground (PluriWaveAlarmService.kt:75). dismissFireNotification only cancels the receiver's (AlarmScheduler.kt:304-308), leaving the service's orphaned.
- Decision: the service's
startForegroundnotification (NOTIFICATION_ID 92841) is the single owner of the ringing FSI. RemoveshowFireNotificationfrom the receiver entirely (PluriWaveAlarmReceiver.kt:37, 95-133). - Ordering problem: an FSI must appear immediately, even before the radio stream prepares (
prepareAsyncis async, ~seconds). The service already callsstartForegroundsynchronously at the top ofstartAlarmBEFOREstartAudio(PluriWaveAlarmService.kt:75then:83). That notification carriessetFullScreenIntent(...)(add it tobuildNotification, currently the service builder has it at:267). So the FSI shows the instant the service enters foreground, audio attaches afterward — correct ordering, no gap. - The receiver still does
context.startActivity(launch)(PluriWaveAlarmReceiver.kt:38-43) to bring the Flutter ringing screen forward; that is the activity launch, not a notification, so no duplication. - Cleanup:
stopAlarmmust cancelNOTIFICATION_ID 92841throughstopForeground(STOP_FOREGROUND_REMOVE)(already at:242) and ALSO cancel any legacyfireNotificationIdForAlarmid for installs upgrading mid-ring (:236-240already does this — keep it as a migration safety net for one release). fireNotificationIdForAlarmhelper stays (referenced bycancelAlarmAlarmScheduler.kt:300) but is no longer posted to.
Decision 1.3 — Channel sound versioning to apply USAGE_ALARM (A4)
Android locks setSound(uri, attributes) at channel creation; the existing channels pluriwave_alarm_native (PluriWaveAlarmService.kt:374) and pluriwave_alarm_fire (PluriWaveAlarmReceiver.kt:269) were created without sound, so editing them in place is a no-op on existing installs.
- Decision: channel versioning. Introduce versioned channel ids and delete the old ones once:
- New ringing channel:
pluriwave_alarm_fire_v2(IMPORTANCE_HIGH) withsetSound(<bundled-alarm-uri>, alarmAudioAttributes)andenableVibration(true). This is the channel the service'sstartForegroundnotification uses. - Keep the pre-notice channel id but bump only if its config changes; it stays silent (
setSound(null, null),PluriWaveAlarmReceiver.kt:232) so no version bump needed. - Consolidate to TWO channels total (A8): the ringing channel (
_fire_v2) and the pre-notice channel. The service stops using its ownpluriwave_alarm_nativechannel and posts on_fire_v2.
- New ringing channel:
- Sound URI: a
Settings.System.DEFAULT_ALARM_ALERT_URI(system default alarm tone) is the channel-level sound for the heads-up/locked alert. Note the bundled WAV played byMediaPlayer(PluriWaveAlarmService.kt:355-362) is the AUDIO that loops; the channel sound is the notification-attached alert. They are distinct; channel sound exists so even if the service audio fails the channel still produces an alarm-attributed sound. UseUSAGE_ALARMAudioAttributes on both. - Migration: add a one-time deletion of the obsolete channels (
pluriwave_alarm_native,pluriwave_alarm_fire) viamanager.deleteNotificationChannel(...)guarded by a SharedPreferences flagchannels_migrated_v2so it runs once and does not reset the user's settings on the new channel repeatedly. Deleting a channel and recreating under a NEW id is the only way Android lets you change locked sound settings without the user manually clearing data. - ADR note (rejected): mutating the existing channel in place — Android ignores
setSoundafter creation, so it would silently keep the bug.
Decision 1.4 — emisoraFallback passthrough and second prepare attempt (A5)
Today programar() sends stationName/stationUrl/fallbackSound but NEVER emisoraFallback (servicio_alarmas_android.dart:167-173); the Kotlin NativeAlarmSpec has no fallback-station field. The native fallback is only the bundled WAV.
- Bridge payload: add
fallbackStationNameandfallbackStationUrlto thescheduleAlarmMethodChannel args inprogramar()(servicio_alarmas_android.dart:148-174), sourced fromalarma.emisoraFallback. - Kotlin model: add
fallbackStationName: String?andfallbackStationUrl: String?toNativeAlarmSpec(AlarmScheduler.kt:571-648), totoJson(bumpschemaVersion2→3 at:594) andfromJson(:618-646, read withoptString(...).takeIf { isNotBlank() }). Wire throughscheduleAlarm(...)signature (AlarmScheduler.kt:21-40) and theMainActivityhandler (MainActivity.kt:68-106). Add the two extras toEXTRA_*constants and thefireIntentextras (PluriWaveAlarmReceiver.kt:277-279,AlarmScheduler.kt:487-507). - Service audio chain: extend
startAudio(PluriWaveAlarmService.kt:86-108) to a three-stage ordered fallback:- Primary station:
startStationAudio(primary)with a 15s timeout (STATION_START_TIMEOUT_MILLISalready 15s at:379). - On primary timeout/error/completion → fallback station (if present): a SECOND
prepareAsyncagainstfallbackStationUrl, again with a 15s timeout. - On fallback-station timeout/error (or absent) → bundled WAV (
startFallbackAudio,:162).
- Implement as a small state machine: pass the next-stage lambda into the prepared/error/completion/timeout handlers instead of jumping straight to the WAV. Reuse
scheduleStationFallbackper stage with its own runnable so the 15s windows are independent andcancelStationFallbackclears the current stage.
- Primary station:
- Persisted-spec migration: already-scheduled alarms have
schemaVersion: 2specs in device-protected SharedPreferences (AlarmScheduler.kt:436-444).fromJsonmust default the two new fields tonullwhen absent (no fallback station) — additive and backward compatible. On the next Flutterprogramar()(boot resync viareschedulePersistedAlarms:315or app open via_sincronizarTodasestado_alarmas.dart:286-296) the v3 fields are written. No destructive migration needed.
Decision 1.5 — Native fade-in honoring fadeInSegundos (A6)
The native service plays at constant volume (PluriWaveAlarmService.kt:120-121, 176-177). The Dart screen fade (pantalla_alarma_sonando.dart:92-115) only runs when the screen is foregrounded.
- Bridge: add
fadeInSegundosto thescheduleAlarmpayload (servicio_alarmas_android.dart) and toNativeAlarmSpec(default 0).AlarmaMusical.fadeInSegundosalready exists (used atpantalla_alarma_sonando.dart:96). - Service: implement a
MediaPlayer.setVolumeramp in the service driven by the existingmainHandler(PluriWaveAlarmService.kt:27). On thesetOnPreparedListenerstart (:128-136and the fallback:179-183), iffadeInSegundos > 0, start at a low floor (e.g. 0.05 * target) and step every 250 ms towardvolumeoverfadeInSegundos, mirroring the Dart algorithm (pantalla_alarma_sonando.dart:101-114). Cancel the ramp runnable instopAlarm(:224) and on snooze. - Coordination with the Dart fade: when the ringing screen is foregrounded with native audio already ramping, the screen must NOT double-ramp. The screen owns the fade ONLY for its own
_fallbackPlayerand theradio.audioit pre-started; the native service owns the fade for native MediaPlayer audio. They never play the same source simultaneously (service stops when Flutter confirms audio viaconfirmFlutterAudio,MainActivity.kt:139-148). Document this hand-off boundary in the service header comment.
Decision 1.6 — Battery-optimization request placement (A7)
REQUEST_IGNORE_BATTERY_OPTIMIZATIONS is declared (AndroidManifest.xml:13) and diagnostico.ignoraOptimizacionBateria is read (servicio_alarmas_android.dart:65) but never requested.
- Decision: add a native
requestIgnoreBatteryOptimizationsMethodChannel method inMainActivity(mirrorrequestExactAlarmPermission:255-270) that launchesSettings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONSwithpackage:data. AddsolicitarExencionBateria()toPuertoAlarmasAndroid/ServicioAlarmasAndroid(servicio_alarmas_android.dart:93-107, 196-218). - Placement and prompt-fatigue guard: call it inside
_solicitarPermisosNecesariosParaAlarma(estado_alarmas.dart:268-284) ONLY when!diag.ignoraOptimizacionBateriaAND an asked-once flag is unset. Persist the flag (bateria_exencion_solicitada) in the injected SharedPreferences (Slice 3). Request once; never re-prompt automatically. The user can re-request from the diagnostics UI manually. - ADR note: requesting on every
guardarAlarma(which calls this method,estado_alarmas.dart:87) would spam the user. The asked-once flag is mandatory.
Slice 1 size note
Kotlin (manifest + service FGS type + channel v2 + 3-stage fallback + fade ramp) + Dart bridge (payload fields + battery method) ≈ 300-340 lines. Within budget. Native edits are surgical and mirror existing patterns to minimize compile risk.
Slice 2 — Alarm UX parity and SNOOZE CORRECTNESS (HIGH)
Snooze audit — found defects (file:line)
The user reports snooze did not work correctly. Root cause is state divergence between the native scheduler and the Flutter source of truth, plus inconsistent snooze anchoring. Precise findings:
| # | Defect | Evidence | Effect |
|---|---|---|---|
| S1 | The native ringing-notification snooze (ACTION_SNOOZE) reschedules natively then stopAlarm(), and NEVER notifies Flutter. The alarmFired callback only fires from MainActivity.onNewIntent on an activity launch (MainActivity.kt:227-234); the snooze service path starts no activity. |
PluriWaveAlarmService.kt:42-49 → AlarmScheduler.snooze() → stopAlarm(); no startActivity; MainActivity.kt:233 never reached. |
Flutter EstadoAlarmas keeps the OLD next-occurrence; native fires at the snoozed time. UI shows wrong "next alarm"; reconciliation only happens on the next 60s refrescarProgramacion (estado_alarmas.dart:316), which RECALCULATES from scratch and can lose the native snooze. Divergence. |
| S2 | Two different snooze anchors in native code. snooze() uses now + minutes (AlarmScheduler.kt:257); postponeNext() uses occurrenceAt + minutes clamped to now (AlarmScheduler.kt:270-273). |
AlarmScheduler.kt:254-287 |
Snooze-from-ringing and snooze-from-pre-notice land at different times for the same intent. Inconsistent with Flutter posponerEjecucion which uses calcularSnooze(now, minutos) (servicio_alarmas.dart:262). |
| S3 | The ringing screen has NO snooze button at all — only "Detener" (_detener). The only ringing-time snooze is the notification action, which is the S1-broken path. |
pantalla_alarma_sonando.dart:200-204 |
User cannot snooze from the screen; if they snooze from the notification, S1 divergence triggers. |
| S4 | refrescarProgramacion calls servicio.recalcularTodas() which runs _recalcular → clears snooze when !snoozeActivo and recomputes proximaEjecucion ignoring any native-only snooze. |
estado_alarmas.dart:98-107, servicio_alarmas.dart:159-172, 378-397 |
A native snooze that Flutter never recorded (S1) is erased by the next periodic recalculation; native and Flutter then disagree on the next trigger. |
| S5 | preserveNativeSnooze (AlarmScheduler.kt:342-357) only preserves a native snooze when Flutter reschedules WITHOUT a snooze and the origin matches requestedTriggerAtMillis. After S4 erases Flutter's snooze and recalculates a new trigger, the origin no longer matches, so preservation fails and the native snooze is dropped on the next programar(). |
AlarmScheduler.kt:347-356 |
The one safety net for divergence is defeated by the recalculation in S4. |
Decision 2.1 — Single source of truth + native→Flutter snooze sync protocol
- Flutter
ServicioAlarmasconfig is the canonical "postponed until" (snoozeHasta/snoozeOrigenonAlarmaMusical, written byposponerEjecucionHasta,servicio_alarmas.dart:266-294). Native is a mirror that must report back. - New native→Flutter event: when the service handles
ACTION_SNOOZE(PluriWaveAlarmService.kt:42-49), afterAlarmScheduler.snooze(...), fire thealarmFiredMethodChannel callback with a new actionsnoozedcarryingalarmId,occurrenceAtMillis(the snooze origin) andsnoozeUntilMillis. Because the service has no activity, route it through the EXISTINGalarmMethodChannelheld byMainActivity: expose a staticMainActivity.notifyAlarmEvent(map)that invokesalarmMethodChannel?.invokeMethod("alarmFired", map)on the main thread when the engine is alive; if the engine is dead, the native snooze is already persisted and Flutter will reconcile on next launch via the handled-occurrences sync (extended below). This avoids inventing a second channel. - Flutter side:
ServicioAlarmasAndroid._instalarHandler(servicio_alarmas_android.dart:269-285) already forwardsalarmFiredevents. ExtendEventoAlarmaAndroidto carry the actionsnoozed.EstadoAlarmaslistens (wire a subscription ininicializar) and on asnoozedevent callsservicio.posponerEjecucionHasta(alarmId, occurrence, snoozeUntil)+_aplicar+notifyListeners— WITHOUT callingandroid.programar()again (native already scheduled it). This makes Flutter record the snooze native chose, killing S1. - Extend the launch-on-snooze reconciliation: the native already persists snooze in its spec. Add
snoozeUntilMillis/snoozeOriginMillistogetHandledAlarmOccurrencesOR add a newgetNativeSnoozeStatemethod so_sincronizarEjecucionesGestionadasPorAndroid(estado_alarmas.dart:251-266) also imports active native snoozes on cold start. This covers the engine-dead case in the previous bullet.
Decision 2.2 — Unify the snooze anchor (fixes S2, S4, S5)
- Make native
snooze()use the SAME anchor aspostponeNext():occurrenceAt + minuteswhereoccurrenceAt = snoozeOriginMillis ?: triggerAtMillis, clamped tonow + minutesif already past (AlarmScheduler.kt:254-265adopts:270-273logic). One snooze semantic everywhere, matching the FlutterposponerEjecucionHasta(ejecucion = snoozeOrigen ?? proximaEjecucion)anchor (estado_alarmas.dart:165-167). - Guard
recalcularTodasagainst erasing an active snooze:_recalcularalready preserves snooze whensnoozeActivo(servicio_alarmas.dart:384-385, 395). The real fix is S1/S4 — once Flutter RECORDS the native snooze (Decision 2.1),snoozeActivois true andrecalcularTodaspreserves it. So S4/S5 are resolved transitively by 2.1. KeeppreserveNativeSnoozeas a belt-and-suspenders net.
Decision 2.3 — End-to-end snooze from the ringing screen (fixes S3)
- Add snooze buttons to
PantallaAlarmaSonando(pantalla_alarma_sonando.dart:200-204area): 3 / 5 / 10 plus the alarm's configured default (alarma.snoozeMinutos). A_posponer(int minutos)handler that mirrors_detener(:132-143) teardown: cancel_fallbackTimer,_fadeInTimer,_estadoSub; stop_fallbackPlayer; pauseradio.audio; thenawait context.read<EstadoAlarmas>().posponerAlarma(widget.alarma, minutos); thennavigator.pop(). posponerAlarmaalready exists and does the right Flutter-first sequence: hides the native notification, recordsposponerEjecucion, re-programs native (estado_alarmas.dart:165-183). This is the CANONICAL path — the screen uses it, so Flutter is always the writer and native is the mirror. No divergence by construction.- Teardown coordination:
_fadeInTimerand_fallbackPlayerMUST be torn down beforeposponerAlarmare-programs native, otherwise the Dart fallback keeps looping after snooze. Add the same teardown to a shared private_liberarAudioLocal()used by both_detenerand_posponer.
Decision 2.4 — Ringing screen redesign (C1, C3)
- Migrate from raw
Scaffold(pantalla_alarma_sonando.dart:158) toPluriWaveScaffold. Replace hardcodedColor(0xFF061722)(:159) andColor(0xFFFFB86B)(:167) withPluriWaveTokensfrom the theme extension. - Entry animation: wrap the glass surface content in a
flutter_animatefade+scale entry, gated by the reduced-motion guard from Slice 5 (PluriAnimateextension). Do NOT introduce a Hero here (Hero work is C2, deferred/optional in Slice 5). - BackdropFilter perf note:
PluriGlassSurfaceusesBackdropFilter. On a cold GPU wake (screen-off → FSI), the first frame can stutter. Mitigation: render a cheap solid/gradient placeholder for the first ~1 frame, then enable the blur after the firstaddPostFrameCallback, OR cap the blur sigma on this screen. Document the constraint; keep the blur optional behind the reduced-motion guard so accessibility users also skip the expensive filter.
Decision 2.5 — Editor improvements (C7)
- Next-trigger preview inside
_EditorAlarmaSheet(pantalla_alarmas.dart:387-636): compute via the existingServicioProgramacionAlarmas.calcularProximaagainst the in-progress alarm draft and render a localized "Next: <date/time>" line. Reuse the Slice 5 locale-awareDateFormat. - Searchable station picker: replace the
DropdownButtonFormFieldwith a bottom-sheet usingSearchBarover the user's favorites (and optionally recent stations). Returns the selectedEmisorafor both primary and fallback fields. This also surfacesemisoraFallbackselection in the UI, which Slice 1 now honors natively. - Configurable snooze duration field: a segmented/slider control writing
alarma.snoozeMinutos(sanitized to 3/5/10 to match nativesanitizeSnoozeMinutes). Lower the volume-slider floor from 0.25 toward 0 (proposal scope).
Slice 2 size note
Snooze sync (bridge event + EstadoAlarmas listener + native callback + anchor unify) ≈ 120 lines; ringing screen redesign + buttons ≈ 130; editor (preview + picker + snooze field) ≈ 130. Total ≈ 380, AT the budget ceiling. Risk: may exceed 400. Proposed sub-split if the forecast trips the guard: 2a = snooze correctness (audit fixes 2.1–2.3, ringing buttons) and 2b = editor + visual redesign (2.4–2.5). 2a is the user-trust fix and ships first.
Slice 3 — Audio / runtime robustness (test seams) (HIGH)
Decision 3.1 — audio_session integration (B1)
- Add a
ServicioAudioSessionwrapper aroundpackage:audio_session(already in pubspec:19). Configure on app init withAudioSession.instance→configure(AudioSessionConfiguration.music())adjusted:AVAudioSessionCategory.playback,AndroidAudioAttributes(usage: media, contentType: music),androidAudioFocusGainType: gain,androidWillPauseWhenDucked: true. - Hook point: inside
PluriWaveAudioHandler(servicio_audio.dart:113-147) or a thin collaborator it owns. Subscribe to:interruptionEventStream: onbeginwithAudioInterruptionType.pause(phone call) → pause and remember "was playing"; onbeginwithduck→ lower volume; onendwithshouldResume→ resume. This is what makes calls pause the radio (the missing B1 behavior).becomingNoisyEventStream(headphones unplugged) → pause.
- Interaction with reconnect (Slice 7): an audio-session pause is a USER-INTENT pause-equivalent and MUST set the same "intentional pause" flag the reconnect logic checks (Decision 7.2), so the stall detector does not fight the interruption handler and try to reconnect during a phone call.
- Alarm path: the native alarm uses
USAGE_ALARMand its own MediaPlayer, independent of this media session, so audio focus for the radio does not interfere with native alarm audio.
Decision 3.2 — Kill static state in ServicioAlarmasAndroid (B2)
- Convert
static _eventosController,static _handlerInstalado,static _l10n(servicio_alarmas_android.dart:117-120) to INSTANCE fields. Install the handler in the constructor per instance. - Make the channel injectable (the constructor already accepts
MethodChannel,:110-112) and the localizations injectable per instance instead of a static setter.EstadoRadio.configurarLocalizaciones(estado_radio.dart:70-75) currently calls the staticServicioAlarmasAndroid.configurarLocalizaciones; rewire it to call the instance held byEstadoAlarmas.android. This makes the service unit-testable with aTestDefaultBinaryMessengerand removes global shared state. - Backward-compat: keep a deprecated static shim for one release if other call sites reference it (grep shows only
estado_radio.dart:74).
Decision 3.3 — Move configurarLocalizaciones out of build() (B3)
MiniReproductor.build()callsconfigurarLocalizaciones(l10n)every rebuild (mini_reproductor.dart:23), firing on every buffer notification. Move it todidChangeDependencies(fires when locale/inherited widgets change, not on everynotifyListeners), guarded so it only re-runs when theLocaleactually changes. ConvertMiniReproductortoStatefulWidgetif needed, or hoist the call to a top-level locale listener inapp.dartthat runs once per locale change.
Decision 3.4 — Inject a single cached SharedPreferences (B4)
- Resolve
SharedPreferences.getInstance()ONCE at startup (inmain.dart) and inject the instance intoServicioAlarmas(constructor already acceptsprefs,servicio_alarmas.dart:23-29),ServicioEcualizador,ServicioGrabacionRadio, and any service doinggetInstance()(25+ sites per B4). Provide a backward-compatible default (_resolverPrefsalready falls back togetInstance(),:399-400) so partial adoption stays functional and a revert is safe. - This is also a test seam: tests pass
SharedPreferences.setMockInitialValues({})once.
Decision 3.5 — Guard recalcularTodas writes + single-writer cache (B5, B6, B9)
recalcularTodaswrites SharedPreferences unconditionally every minute (estado_alarmas.dart:316-318→servicio_alarmas.dart:159-172). Add a dirty-check: compute the new config, compare serialized JSON (or a change flag from_recalcular) against the loaded config, and only_guardarwhen something changed. Returns the loaded config unchanged when clean (mirror thesincronizarEjecucionesNativashuboCambiospattern,:181, 219).- In-memory cache + single-writer mutex in
ServicioAlarmasto kill the read-modify-write N+1 race (B6):cargar()runs before every mutation (:86, 111, 124, 160, 179, 230, 271, 300) with no cache and no serialization, so concurrentguardarAlarma/posponer/refrescarinterleave and lose writes. Decision: hold an in-memoryConfiguracionAlarmas?cache, hydrate on firstcargar, and serialize ALL mutations through a singleFuture-chain mutex (the same patternPluriWaveAudioHandler._colaCambioFuenteuses,servicio_audio.dart:125, 282-285). Every mutation =await _lock→ read cache → mutate → persist → update cache → release. This is THE seam the Slice 6 concurrency test exercises. - Bound
_ejecucionesEmitidas(estado_alarmas.dart:32): replace the unboundedSet<String>with a bounded LRU (cap ~200) or prune entries older than a day on each_vigilarAlarmasVencidaspass (:326-348). Keys arealarmId:millis; prune by parsing the millis suffix.
Decision 3.6 — Tame empty catch + unawaited (B7, B10) — scoped
- Not the focus of Slice 3 structurally, but where the seams touch
servicio_audio.dartempty catches (:343, 346, 406, 428, 448) andapp.dart:324unawaited(radio.reproducir), replace silent swallow with at least adeveloper.log. Full lint enforcement is Slice 6 (unawaited_futures). Keep edits minimal here to stay under budget.
Slice 3 size note
Audio session ≈ 90, statics→instance + l10n rewire ≈ 70, prefs injection ≈ 60, dirty-guard + cache/mutex + bounded set ≈ 120, logging touch-ups ≈ 30. Total ≈ 370. Within budget but tight; if it trips, split 3a = test seams (statics, prefs, cache/mutex, dirty-guard) and 3b = audio_session + becoming-noisy. 3a unblocks Slice 6 tests; 3b is the call-pause feature.
Slice 7 — Streaming resilience (NEW, user request) (MEDIUM-HIGH)
Radio streams should survive short connection drops (seconds) via buffering and auto-recover.
Decision 7.1 — just_audio buffer configuration for LIVE streams
just_audio ^0.9.42supportsAudioLoadConfiguration(androidLoadControl: AndroidLoadControl(...)), applied atAudioPlayerconstruction (servicio_audio.dart:159-163_crearPlayer). Set:minBufferDuration: 15s,maxBufferDuration: 50s— pre-roll the player keeps; larger min buffer absorbs jitter.bufferForPlaybackDuration: 2.5s,bufferForPlaybackAfterRebufferDuration: 5s— how much must buffer before (re)starting playback; higher after-rebuffer value reduces re-stutter.targetBufferBytes,prioritizeTimeOverSizeThresholds: true.
- Achievable window — be honest: for LIVE icy/HTTP radio there is NO seekable rewind window; the stream is unbounded and not stored to disk by default. The buffer is a forward jitter cushion, not a rewind history. So:
- What we CAN achieve: tolerate network jitter / micro-drops up to roughly the
maxBufferDurationworth of already-buffered audio (realistically a few to ~15-30 seconds of cushion depending on bitrate and how full the buffer was when the drop hit), and FAST automatic re-prepare when the connection returns. - What we CANNOT achieve: pause-and-resume across a long outage without a gap (live audio advances in real time; on reconnect you rejoin the live edge, not where you dropped). We do not promise gap-free recovery for outages longer than the buffered cushion.
- What we CAN achieve: tolerate network jitter / micro-drops up to roughly the
- Decision: the realistic goal is jitter tolerance + fast recovery to the live edge, with a clear "reconnecting" UI state, not seamless time-shift. State this in the spec acceptance criteria so expectations are honest.
Decision 7.2 — Reconnect-on-stall with bounded exponential backoff
- Stall vs user-pause discrimination: track an explicit
_intencionReproducir(intent-to-play) flag, set true onplay/reproducir/reanudarand false onpause/stopand on an audio-session interruption pause (Decision 3.1). A STALL is:_intencionReproducir == trueANDplayerStateStreamreportsprocessingState == bufferingfor longer than a threshold (e.g. 8-10s) OR an error event arrives (servicio_audio.dart:189-194_eventosSub.onError, currently routes to_gestionarErrorReproduccion). - Reconnect loop in
PluriWaveAudioHandler: on detected stall, instead of going straight to terminal error (_gestionarErrorReproduccion,:207-236), enter a reconnect state machine:- Surface a new
EstadoReproduccion.reconectando(extend the enumservicio_audio.dart:14) so mini player / player UI shows "reconnecting". - Re-issue the source (
_player.setUrl(mediaItem.id)thenplay) using the existing revision-guarded_cambiarFuentemachinery (:288-332) so a user source-switch during reconnect cancels it (revision mismatch). - Bounded exponential backoff: delays 1s, 2s, 4s, 8s, 16s, 32s, then cap; total window ~60-90s (configurable max attempts ~7-8). After exhaustion, fall through to the EXISTING terminal
_gestionarErrorReproduccionwith the friendly message. This preserves current error UX as the final state. - Cancel/reset the backoff and counter on successful
ready+playing, on userstop/pause, and on source switch.
- Surface a new
- Interaction with EQ/volume/recording: reconnect re-creates the player via
_recrearPlayer(:334-354) which already re-applies_volumen(:352) and re-activates the EQ (_activarEcualizador,:306, 372-383). Recording (ServicioGrabacionRadio) reads the live PCM/visualizer; on reconnect theandroidAudioSessionIdchanges (re-emitted at:196-203) and recording/visualizer resubscribe via the existing session-id stream — no extra wiring needed, but the design MUST verify recording survives a session-id change (Slice 6 recording-recovery test covers it). - Interaction with the alarm pre-start path (
app.dart:316-325_prearrancarAudioAlarma): when the alarm pre-starts the radio and the stream stalls, reconnect should engage normally, BUT the alarm screen already has its own 12s station timeout → bundled-WAV fallback (pantalla_alarma_sonando.dart:74-79). Decision: during an active alarm ring, the alarm's fallback timer takes precedence — if the radio has not reachedreproduciendowithin the alarm's timeout, the alarm switches to the bundled fallback regardless of reconnect attempts, because waking the user reliably beats reconnect persistence. The reconnect loop must not extend the alarm wake-up window. Expose enough state for the alarm screen to make that call (it already listens toestadoStream;reconectandomust NOT be misread asreproduciendo).
Slice 7 size note
Buffer config ≈ 25, reconnect state machine + enum + backoff ≈ 180, UI "reconnecting" wiring in mini/player ≈ 60, alarm-path guard ≈ 20. Total ≈ 285. Within budget. Depends on Slice 3 (audio-session intent flag) landing first.
Slices 4-6
Slice 4 — EstadoRadio god-class split (HIGH, broad)
EstadoRadio (1121 lines, estado_radio.dart) owns 6 services + direct I/O (:30-62). Split seams and EXTRACTION ORDER (lowest-coupling first so each PR stays small and backward-compatible):
ServicioExportImport— extract the backup/importjsonDecode/jsonEncodefrompantalla_ajustes.dart(1391 lines) AND any export logic inEstadoRadio. Pure logic, highest test value (round-trip), zero UI coupling. Extract FIRST.EstadoEcualizador(ChangeNotifier) — EQ preset/active/band state currently proxied throughEstadoRadiotoaudio/servicioEcualizador. Self-contained.EstadoGrabacion(ChangeNotifier) — recording state +_escucharGrabacionsubscription (estado_radio.dart:51, :79).EstadoBusqueda(ChangeNotifier) — search results/query state.
- Provider wiring: register the new notifiers in the existing
MultiProvider(alongsideEstadoRadio). UseProxyProviderwhere a notifier needsServicioAudiofromEstadoRadio, OR pass the shared service instances at construction. - Migration strategy keeping each PR <400 lines: KEEP backward-compatible getters on
EstadoRadiothat delegate to the new notifiers during the transition, so screens compile unchanged. Migrate consuming screens tocontext.select/Consumerscopes (B11:pantalla_inicio.dart:43root watch + 6 sites inpantalla_ajustes) in the SAME or a follow-up PR. - Confirmed 4a/4b split (the proposal flags it; forecast WILL exceed 400 lines for all four extractions + rewiring):
- 4a =
ServicioExportImport+EstadoEcualizadorextraction with backward-compatible getters (no screen rewiring yet). ≈ 350 lines. - 4b =
EstadoGrabacion+EstadoBusquedaextraction +context.selectrewiring ofPantallaInicio/Ajustes/Favoritos+ removal of the temporary getters. ≈ 380 lines.
- 4a =
- ADR note: a big-bang single PR would blow the budget and the blast radius; the getters-bridge keeps each PR independently revertible (proposal rollback plan).
Slice 5 — Design system, a11y, i18n (LOW, parallelizable)
- Color tokens: replace the 14+
Color(0x...)literals (explore C3 sites) withPluriWaveTokensfrom the theme extension. The ringing screen literals are migrated in Slice 2 (Decision 2.4); the rest here. - A11y:
Semantics(button: true, label: ...)on the 36x36 favoriteInkWell(tarjeta_emisora.dart:238-289, also enlarge tap target toward 48dp),semanticLabelon_AssetIconand alarm images (C4, C5). - Reduced-motion guard —
PluriAnimateextension design: a Dart extension onWidget(e.g.extension PluriAnimate on Widget) exposingpluriFadeIn(...),pluriScaleIn(...)etc. Each method readsMediaQuery.maybeDisableAnimationsOf(context)(orMediaQuery.of(context).disableAnimations); when true it returns the child UNANIMATED (or with duration zero), otherwise it applies theflutter_animateeffect. Centralizes C6 so every entry animation (including the Slice 2 ringing screen) respects the OS reduced-motion setting through ONE call site. Requires aBuildContext, so it is a method takingcontextrather than a pure getter. - i18n: locale-aware
_fechaCortaviaintlDateFormat.yMd(localeName)(pantalla_alarmas.dart:1114, fixes C8 for ja/en-US/ar). Pluralization for bare counters via ARB plural messages (pantalla_favoritos.dart:138, C9). - Polish: rounded shimmer corners + shimmer in
PantallaBuscar(C10, C11),_roundedicon variants (C12), brandnotificationColorinmain.dart:23(C13).
Slice 6 — Quality gates (LOW, mostly tests)
- Harden
analysis_options.yaml(currently bareflutter_lints,:10). Add underlinter.rules:cancel_subscriptions,close_sinks,unawaited_futures,prefer_final_locals,avoid_dynamic_calls. Fix the violations surfaced (the empty-catch/unawaited sites from B7/B10 land here if not in Slice 3). - Tests (strict TDD,
flutter test, mirrortest/helpers/fakes.dartpatterns). See Test Designs below.
Test designs (Slice 6 + seams introduced earlier)
| Test | Target | Design |
|---|---|---|
| ServicioAlarmas concurrency | servicio_alarmas.dart mutex (Decision 3.5) |
Inject mock prefs (setMockInitialValues). Fire N concurrent guardarAlarma/posponerEjecucion/recalcularTodas without awaiting between them; await all; assert the final persisted config reflects ALL writes (no lost update) and the mutation count matches. Without the mutex this fails (read-modify-write race). |
| Fire dedup across refrescarProgramacion | estado_alarmas.dart _ejecucionesEmitidas + _vigilarAlarmasVencidas (:326-348) |
Drive a due alarm; call refrescarProgramacion repeatedly; assert alarmasVencidasStream emits the occurrence exactly once per alarmId:millis key, and the bounded set prunes old keys. |
| Audio handler rapid source-switch | PluriWaveAudioHandler._colaCambioFuente / _revisionFuente (:280-332) |
Issue rapid playMediaItem(A), playMediaItem(B), playMediaItem(C); assert only C's source ends active, earlier revisions are cancelled (revision guard), and no stale error is surfaced from A/B. Use a fake/seam over AudioPlayer URL-set. |
| Export/import round-trip | new ServicioExportImport (Slice 4a) |
Build a full config (favorites, groups, EQ, alarms, vacations); export to JSON; import into a fresh service; assert deep equality. Edge: malformed JSON → graceful empty, not throw. |
| Recording error recovery | ServicioGrabacionRadio (B10 empty catches :156,165,177,288) |
Simulate a recorder failure mid-recording (session-id change / IO error); assert the service transitions to a recoverable error state, releases resources, and a subsequent start succeeds. Covers the Slice 7 reconnect session-id-change interaction. |
| Snooze logic | AlarmScheduler.snooze/postponeNext anchor + Flutter posponerEjecucionHasta (Decisions 2.1-2.2) |
Dart-side: assert posponerAlarma writes snoozeHasta = origin + minutes and that a subsequent recalcularTodas PRESERVES the active snooze (not erased — S4 regression guard). Assert a snoozed native event recorded via the bridge updates state WITHOUT a second programar() call. |
| Reconnect/backoff | PluriWaveAudioHandler reconnect state machine (Decision 7.2) |
Seam over the player so a stall/error can be injected; assert backoff delays follow 1/2/4/8…cap, reconectando state is emitted, success resets the counter, and exhaustion lands on terminal error with the friendly message. Assert an intentional pause during stall cancels reconnect (intent flag). |
Kotlin (AlarmScheduler.snooze anchor, FGS type, channels) is NOT covered by flutter test and NOT by flutter analyze; it is verified ON-DEVICE by the user. Design keeps Kotlin diffs small and mirrors existing patterns to minimize compile risk.
Cross-cutting decisions and ADRs
| Decision | Choice | Rejected alternative | Rationale |
|---|---|---|---|
| Snooze source of truth | Flutter ServicioAlarmas config; native mirrors and reports back via alarmFired/snoozed |
Native as source of truth | Flutter already owns recurrence math and persistence; UI reads from it; a second authority is what caused the divergence. |
| Native→Flutter snooze channel | Reuse existing alarmFired MethodChannel via a static MainActivity.notifyAlarmEvent |
New dedicated EventChannel | Fewer moving parts, no new channel lifecycle to manage, engine-dead case covered by cold-start sync. |
| Channel sound change | New versioned channel id _fire_v2 + one-time delete of old ids |
Mutate existing channel | Android locks channel sound at creation; in-place edit is silently ignored. |
| FGS notification owner | Service startForeground notification (id 92841) |
Receiver notification | Service is the long-lived owner; the FSI must persist for the whole ring and be cancelled by stopForeground. |
| Streaming recovery scope | Jitter tolerance + fast reconnect to live edge | Gap-free time-shift across long outages | Live radio has no rewind history; honesty in acceptance criteria. |
| Concurrency fix | Single-writer Future-chain mutex + in-memory cache in ServicioAlarmas |
Per-mutation lock library | Mirrors the existing _colaCambioFuente pattern; zero new deps; testable. |
| Slice 4 migration | Backward-compatible getters bridge, 4a/4b split | Big-bang split | Keeps each PR <400 lines and independently revertible. |
Risks
- Kotlin compile risk (HIGH): Slices 1, 2, 7-adjacent native edits cannot be compiled here (
flutter buildforbidden, never run peralarm-clock-module). Mitigation: surgical diffs, mirror existing patterns, schemaVersion bump 2→3 is additive; ask the user to build after Slice 1 before chaining further native work. - FGS type behavior is OEM/version-sensitive (HIGH): the
alarmFGS type + permission pairing must be exact; some OEMs are stricter. Mitigation: pair type and permission precisely; on-device verification by user. - Channel migration could reset user notification prefs (MEDIUM): deleting old channels and creating
_fire_v2resets per-channel user settings for the ringing channel only. Mitigation: one-time guarded migration; pre-notice channel untouched; document in release notes. - Snooze sync engine-dead case (MEDIUM): if the Flutter engine is dead when native snoozes, the
alarmFiredcallback is lost; reconciliation relies on the extended cold-start sync (getNativeSnoozeState). Mitigation: import active native snoozes oninicializar. - Slice 2 and Slice 3 at the 400-line ceiling (MEDIUM): both may need the documented 2a/2b and 3a/3b sub-splits. Flagged for the Review Workload Forecast.
- BackdropFilter cold-GPU stutter on FSI (MEDIUM): the ringing screen blur may drop the first frame on screen-off wake. Mitigation: deferred blur / capped sigma / reduced-motion bypass.
- Reconnect vs alarm wake-up window (MEDIUM): reconnect persistence must never delay the alarm's bundled-WAV fallback. Mitigation: alarm fallback timer takes precedence;
reconectandomust not be read as playing. - audio_session interruption vs reconnect fighting (LOW-MEDIUM): a call-pause must set the same intent flag the stall detector reads. Mitigation: shared intent flag (Decision 3.1 ↔ 7.2).
Open assumptions requiring validation
- The
alarm-typed FGS started fromPluriWaveAlarmReceiveris exempt under the alarm-broadcast FGS-while-in-use allowance on API 34+ — validate on a real API 34/35 device (user build). AndroidLoadControlbuffer values are tunable but the effective jitter cushion depends on stream bitrate; final values may need on-device tuning.MainActivity.notifyAlarmEventinvoked from the service requires the Flutter engine to be alive and the channel bound; the cold-start sync is the fallback — confirm the engine lifecycle assumption holds when the ringing screen is foregrounded.