Files
pluriwave/openspec/changes/app-quality-and-native-alarms/design.md
T
FreeTLab f3e9487215 feat(alarms): native reliability fixes and end-to-end snooze
- 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)
2026-06-11 15:33:30 +02:00

43 KiB
Raw Blame History

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 PluriWaveAlarmService to android:foregroundServiceType="mediaPlayback|alarm" (AndroidManifest.xml:54-57). Add <uses-permission android:name="android.permission.FOREGROUND_SERVICE_ALARM"/> next to the existing FOREGROUND_SERVICE_MEDIA_PLAYBACK (AndroidManifest.xml:5).
  • Service: when calling startForeground (PluriWaveAlarmService.kt:75), on API ≥ 34 pass the explicit type bitmask ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK or ServiceInfo.FOREGROUND_SERVICE_TYPE_ALARM via the 3-arg startForeground(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 alarm type is the one that satisfies the API 34+ FGS-launch-from-background restriction when started from PluriWaveAlarmReceiver.onReceive (PluriWaveAlarmReceiver.kt:27). Alarm-triggered broadcasts receive a temporary FGS-while-in-use exemption, so starting an alarm-typed FGS from the fire receiver is the documented, allowed path on API 34+.
  • ADR note (rejected): using only mediaPlayback keeps the silent-failure bug; using only alarm would 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 startForeground notification (NOTIFICATION_ID 92841) is the single owner of the ringing FSI. Remove showFireNotification from the receiver entirely (PluriWaveAlarmReceiver.kt:37, 95-133).
  • Ordering problem: an FSI must appear immediately, even before the radio stream prepares (prepareAsync is async, ~seconds). The service already calls startForeground synchronously at the top of startAlarm BEFORE startAudio (PluriWaveAlarmService.kt:75 then :83). That notification carries setFullScreenIntent(...) (add it to buildNotification, 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: stopAlarm must cancel NOTIFICATION_ID 92841 through stopForeground(STOP_FOREGROUND_REMOVE) (already at :242) and ALSO cancel any legacy fireNotificationIdForAlarm id for installs upgrading mid-ring (:236-240 already does this — keep it as a migration safety net for one release).
  • fireNotificationIdForAlarm helper stays (referenced by cancelAlarm AlarmScheduler.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) with setSound(<bundled-alarm-uri>, alarmAudioAttributes) and enableVibration(true). This is the channel the service's startForeground notification 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 own pluriwave_alarm_native channel and posts on _fire_v2.
  • 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 by MediaPlayer (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. Use USAGE_ALARM AudioAttributes on both.
  • Migration: add a one-time deletion of the obsolete channels (pluriwave_alarm_native, pluriwave_alarm_fire) via manager.deleteNotificationChannel(...) guarded by a SharedPreferences flag channels_migrated_v2 so 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 setSound after 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 fallbackStationName and fallbackStationUrl to the scheduleAlarm MethodChannel args in programar() (servicio_alarmas_android.dart:148-174), sourced from alarma.emisoraFallback.
  • Kotlin model: add fallbackStationName: String? and fallbackStationUrl: String? to NativeAlarmSpec (AlarmScheduler.kt:571-648), to toJson (bump schemaVersion 2→3 at :594) and fromJson (:618-646, read with optString(...).takeIf { isNotBlank() }). Wire through scheduleAlarm(...) signature (AlarmScheduler.kt:21-40) and the MainActivity handler (MainActivity.kt:68-106). Add the two extras to EXTRA_* constants and the fireIntent extras (PluriWaveAlarmReceiver.kt:277-279, AlarmScheduler.kt:487-507).
  • Service audio chain: extend startAudio (PluriWaveAlarmService.kt:86-108) to a three-stage ordered fallback:
    1. Primary station: startStationAudio(primary) with a 15s timeout (STATION_START_TIMEOUT_MILLIS already 15s at :379).
    2. On primary timeout/error/completion → fallback station (if present): a SECOND prepareAsync against fallbackStationUrl, again with a 15s timeout.
    3. 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 scheduleStationFallback per stage with its own runnable so the 15s windows are independent and cancelStationFallback clears the current stage.
  • Persisted-spec migration: already-scheduled alarms have schemaVersion: 2 specs in device-protected SharedPreferences (AlarmScheduler.kt:436-444). fromJson must default the two new fields to null when absent (no fallback station) — additive and backward compatible. On the next Flutter programar() (boot resync via reschedulePersistedAlarms :315 or app open via _sincronizarTodas estado_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 fadeInSegundos to the scheduleAlarm payload (servicio_alarmas_android.dart) and to NativeAlarmSpec (default 0). AlarmaMusical.fadeInSegundos already exists (used at pantalla_alarma_sonando.dart:96).
  • Service: implement a MediaPlayer.setVolume ramp in the service driven by the existing mainHandler (PluriWaveAlarmService.kt:27). On the setOnPreparedListener start (:128-136 and the fallback :179-183), if fadeInSegundos > 0, start at a low floor (e.g. 0.05 * target) and step every 250 ms toward volume over fadeInSegundos, mirroring the Dart algorithm (pantalla_alarma_sonando.dart:101-114). Cancel the ramp runnable in stopAlarm (: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 _fallbackPlayer and the radio.audio it 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 via confirmFlutterAudio, 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 requestIgnoreBatteryOptimizations MethodChannel method in MainActivity (mirror requestExactAlarmPermission :255-270) that launches Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS with package: data. Add solicitarExencionBateria() to PuertoAlarmasAndroid / 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.ignoraOptimizacionBateria AND 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-49AlarmScheduler.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 ServicioAlarmas config is the canonical "postponed until" (snoozeHasta / snoozeOrigen on AlarmaMusical, written by posponerEjecucionHasta, 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), after AlarmScheduler.snooze(...), fire the alarmFired MethodChannel callback with a new action snoozed carrying alarmId, occurrenceAtMillis (the snooze origin) and snoozeUntilMillis. Because the service has no activity, route it through the EXISTING alarmMethodChannel held by MainActivity: expose a static MainActivity.notifyAlarmEvent(map) that invokes alarmMethodChannel?.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 forwards alarmFired events. Extend EventoAlarmaAndroid to carry the action snoozed. EstadoAlarmas listens (wire a subscription in inicializar) and on a snoozed event calls servicio.posponerEjecucionHasta(alarmId, occurrence, snoozeUntil) + _aplicar + notifyListeners — WITHOUT calling android.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/snoozeOriginMillis to getHandledAlarmOccurrences OR add a new getNativeSnoozeState method 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 as postponeNext(): occurrenceAt + minutes where occurrenceAt = snoozeOriginMillis ?: triggerAtMillis, clamped to now + minutes if already past (AlarmScheduler.kt:254-265 adopts :270-273 logic). One snooze semantic everywhere, matching the Flutter posponerEjecucionHasta(ejecucion = snoozeOrigen ?? proximaEjecucion) anchor (estado_alarmas.dart:165-167).
  • Guard recalcularTodas against erasing an active snooze: _recalcular already preserves snooze when snoozeActivo (servicio_alarmas.dart:384-385, 395). The real fix is S1/S4 — once Flutter RECORDS the native snooze (Decision 2.1), snoozeActivo is true and recalcularTodas preserves it. So S4/S5 are resolved transitively by 2.1. Keep preserveNativeSnooze as 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-204 area): 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; pause radio.audio; then await context.read<EstadoAlarmas>().posponerAlarma(widget.alarma, minutos); then navigator.pop().
  • posponerAlarma already exists and does the right Flutter-first sequence: hides the native notification, records posponerEjecucion, 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: _fadeInTimer and _fallbackPlayer MUST be torn down before posponerAlarma re-programs native, otherwise the Dart fallback keeps looping after snooze. Add the same teardown to a shared private _liberarAudioLocal() used by both _detener and _posponer.

Decision 2.4 — Ringing screen redesign (C1, C3)

  • Migrate from raw Scaffold (pantalla_alarma_sonando.dart:158) to PluriWaveScaffold. Replace hardcoded Color(0xFF061722) (:159) and Color(0xFFFFB86B) (:167) with PluriWaveTokens from the theme extension.
  • Entry animation: wrap the glass surface content in a flutter_animate fade+scale entry, gated by the reduced-motion guard from Slice 5 (PluriAnimate extension). Do NOT introduce a Hero here (Hero work is C2, deferred/optional in Slice 5).
  • BackdropFilter perf note: PluriGlassSurface uses BackdropFilter. 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 first addPostFrameCallback, 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 existing ServicioProgramacionAlarmas.calcularProxima against the in-progress alarm draft and render a localized "Next: <date/time>" line. Reuse the Slice 5 locale-aware DateFormat.
  • Searchable station picker: replace the DropdownButtonFormField with a bottom-sheet using SearchBar over the user's favorites (and optionally recent stations). Returns the selected Emisora for both primary and fallback fields. This also surfaces emisoraFallback selection 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 native sanitizeSnoozeMinutes). 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.12.3, ringing buttons) and 2b = editor + visual redesign (2.42.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 ServicioAudioSession wrapper around package:audio_session (already in pubspec :19). Configure on app init with AudioSession.instanceconfigure(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: on begin with AudioInterruptionType.pause (phone call) → pause and remember "was playing"; on begin with duck → lower volume; on end with shouldResume → 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_ALARM and 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 static ServicioAlarmasAndroid.configurarLocalizaciones; rewire it to call the instance held by EstadoAlarmas.android. This makes the service unit-testable with a TestDefaultBinaryMessenger and 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() calls configurarLocalizaciones(l10n) every rebuild (mini_reproductor.dart:23), firing on every buffer notification. Move it to didChangeDependencies (fires when locale/inherited widgets change, not on every notifyListeners), guarded so it only re-runs when the Locale actually changes. Convert MiniReproductor to StatefulWidget if needed, or hoist the call to a top-level locale listener in app.dart that runs once per locale change.

Decision 3.4 — Inject a single cached SharedPreferences (B4)

  • Resolve SharedPreferences.getInstance() ONCE at startup (in main.dart) and inject the instance into ServicioAlarmas (constructor already accepts prefs, servicio_alarmas.dart:23-29), ServicioEcualizador, ServicioGrabacionRadio, and any service doing getInstance() (25+ sites per B4). Provide a backward-compatible default (_resolverPrefs already falls back to getInstance(), :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)

  • recalcularTodas writes SharedPreferences unconditionally every minute (estado_alarmas.dart:316-318servicio_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 _guardar when something changed. Returns the loaded config unchanged when clean (mirror the sincronizarEjecucionesNativas huboCambios pattern, :181, 219).
  • In-memory cache + single-writer mutex in ServicioAlarmas to 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 concurrent guardarAlarma/posponer/refrescar interleave and lose writes. Decision: hold an in-memory ConfiguracionAlarmas? cache, hydrate on first cargar, and serialize ALL mutations through a single Future-chain mutex (the same pattern PluriWaveAudioHandler._colaCambioFuente uses, 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 unbounded Set<String> with a bounded LRU (cap ~200) or prune entries older than a day on each _vigilarAlarmasVencidas pass (:326-348). Keys are alarmId: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.dart empty catches (:343, 346, 406, 428, 448) and app.dart:324 unawaited(radio.reproducir), replace silent swallow with at least a developer.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.42 supports AudioLoadConfiguration(androidLoadControl: AndroidLoadControl(...)), applied at AudioPlayer construction (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 maxBufferDuration worth 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.
  • 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 on play/reproducir/reanudar and false on pause/stop and on an audio-session interruption pause (Decision 3.1). A STALL is: _intencionReproducir == true AND playerStateStream reports processingState == buffering for 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 enum servicio_audio.dart:14) so mini player / player UI shows "reconnecting".
    • Re-issue the source (_player.setUrl(mediaItem.id) then play) using the existing revision-guarded _cambiarFuente machinery (: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 _gestionarErrorReproduccion with the friendly message. This preserves current error UX as the final state.
    • Cancel/reset the backoff and counter on successful ready+playing, on user stop/pause, and on source switch.
  • 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 the androidAudioSessionId changes (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 reached reproduciendo within 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 to estadoStream; reconectando must NOT be misread as reproduciendo).

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):

  1. ServicioExportImport — extract the backup/import jsonDecode/jsonEncode from pantalla_ajustes.dart (1391 lines) AND any export logic in EstadoRadio. Pure logic, highest test value (round-trip), zero UI coupling. Extract FIRST.
  2. EstadoEcualizador (ChangeNotifier) — EQ preset/active/band state currently proxied through EstadoRadio to audio/servicioEcualizador. Self-contained.
  3. EstadoGrabacion (ChangeNotifier) — recording state + _escucharGrabacion subscription (estado_radio.dart:51, :79).
  4. EstadoBusqueda (ChangeNotifier) — search results/query state.
  • Provider wiring: register the new notifiers in the existing MultiProvider (alongside EstadoRadio). Use ProxyProvider where a notifier needs ServicioAudio from EstadoRadio, OR pass the shared service instances at construction.
  • Migration strategy keeping each PR <400 lines: KEEP backward-compatible getters on EstadoRadio that delegate to the new notifiers during the transition, so screens compile unchanged. Migrate consuming screens to context.select/Consumer scopes (B11: pantalla_inicio.dart:43 root watch + 6 sites in pantalla_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 + EstadoEcualizador extraction with backward-compatible getters (no screen rewiring yet). ≈ 350 lines.
    • 4b = EstadoGrabacion + EstadoBusqueda extraction + context.select rewiring of PantallaInicio/Ajustes/Favoritos + removal of the temporary getters. ≈ 380 lines.
  • 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) with PluriWaveTokens from 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 favorite InkWell (tarjeta_emisora.dart:238-289, also enlarge tap target toward 48dp), semanticLabel on _AssetIcon and alarm images (C4, C5).
  • Reduced-motion guard — PluriAnimate extension design: a Dart extension on Widget (e.g. extension PluriAnimate on Widget) exposing pluriFadeIn(...), pluriScaleIn(...) etc. Each method reads MediaQuery.maybeDisableAnimationsOf(context) (or MediaQuery.of(context).disableAnimations); when true it returns the child UNANIMATED (or with duration zero), otherwise it applies the flutter_animate effect. Centralizes C6 so every entry animation (including the Slice 2 ringing screen) respects the OS reduced-motion setting through ONE call site. Requires a BuildContext, so it is a method taking context rather than a pure getter.
  • i18n: locale-aware _fechaCorta via intl DateFormat.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), _rounded icon variants (C12), brand notificationColor in main.dart:23 (C13).

Slice 6 — Quality gates (LOW, mostly tests)

  • Harden analysis_options.yaml (currently bare flutter_lints, :10). Add under linter.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, mirror test/helpers/fakes.dart patterns). 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 build forbidden, never run per alarm-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 alarm FGS 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_v2 resets 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 alarmFired callback is lost; reconciliation relies on the extended cold-start sync (getNativeSnoozeState). Mitigation: import active native snoozes on inicializar.
  • 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; reconectando must 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 from PluriWaveAlarmReceiver is exempt under the alarm-broadcast FGS-while-in-use allowance on API 34+ — validate on a real API 34/35 device (user build).
  • AndroidLoadControl buffer values are tunable but the effective jitter cushion depends on stream bitrate; final values may need on-device tuning.
  • MainActivity.notifyAlarmEvent invoked 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.