Files
pluriwave/openspec/changes/app-quality-and-native-alarms/spec.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

32 KiB

Spec: app-quality-and-native-alarms

Delta requirements for the PluriWave Android alarm reliability, UX parity, runtime robustness, architecture decomposition, design-system, quality-gate, and streaming resilience change. Every requirement states what MUST be true after the change is applied; implementation details are deferred to the design phase.

Verifiability legend:

  • [flutter test] — unit/widget test exercisable via flutter test
  • [flutter analyze] — static analysis via flutter analyze
  • [on-device] — requires a real build and manual or instrumented verification on Android hardware/emulator; flutter build is forbidden in this project

S1 — Alarm native reliability

S1-R1 — Foreground-service alarm type declared (CRITICAL)

The AndroidManifest.xml MUST declare foregroundServiceType="mediaPlayback|alarm" for PluriWaveAlarmService and MUST include the FOREGROUND_SERVICE_ALARM permission, so that Android 14+ (API 34+) does not throw ForegroundServiceTypeException when the alarm fires.

[on-device]

Scenario S1-R1-A: alarm fires on Android 14+

Given a device running Android 14+ (API 34)
  And an alarm has been scheduled with a future trigger time
When the trigger time is reached
Then PluriWaveAlarmService starts in the foreground without ForegroundServiceTypeException
  And the alarm ringing screen is shown (or the foreground notification is posted)

S1-R2 — Single fire notification per alarm event

On any alarm fire event, exactly one notification SHALL be posted. PluriWaveAlarmReceiver MUST NOT post a duplicate FSI notification when PluriWaveAlarmService is already managing the foreground notification (NOTIFICATION_ID 92841). dismissFireNotification in EstadoAlarmas MUST cancel the single canonical notification ID.

[on-device]

Scenario S1-R2-A: no duplicate notification

Given an alarm fires and PluriWaveAlarmService posts NOTIFICATION_ID 92841
When the system notification tray is inspected
Then exactly one notification for that alarm is visible

Scenario S1-R2-B: dismiss cancels the notification

Given the fire notification is visible
When the user dismisses the alarm (stop action)
Then the notification is removed from the tray
  And no orphan notification remains

S1-R3 — Channel-level alarm audio attributes

The pluriwave_alarm_fire and pluriwave_alarm_native notification channels MUST be created with setSound(uri, audioAttributes) where audioAttributes use AudioAttributes.USAGE_ALARM, so that Android 8+ honors the alarm ringer stream.

[on-device]

Scenario S1-R3-A: alarm sound plays on alarm channel

Given the fire channels are created at app first-launch
When an alarm fires and produces a notification
Then the notification sound plays on the alarm audio stream
  And respects the device's alarm volume (not media volume)

S1-R4 — Fallback station passed through MethodChannel

When AlarmaMusical.emisoraFallback is set, ServicioAlarmasAndroid.programar MUST pass fallbackStationUrl in the scheduleAlarm MethodChannel payload, and NativeAlarmSpec (Kotlin) MUST carry the field. PluriWaveAlarmService MUST attempt prepareAsync on the fallback URL when the primary station fails.

[on-device] (native path); [flutter test] (Dart MethodChannel payload assertion)

Scenario S1-R4-A: fallback attempted natively

Given an alarm has emisoraFallback set to a valid URL
  And the primary station stream fails or times out during alarm playback
When the native service handles playback
Then the fallback station URL is attempted via MediaPlayer.prepareAsync

Scenario S1-R4-B: fallback URL absent → bundled sound

Given an alarm has no emisoraFallback
  And the primary station stream fails
When the native service handles playback
Then the bundled fallback WAV is played (existing behavior preserved)

S1-R5 — Battery-optimization exemption request

_solicitarPermisosNecesariosParaAlarma MUST request REQUEST_IGNORE_BATTERY_OPTIMIZATIONS so that Doze-mode does not suppress alarm delivery on devices where the permission is not already granted.

[on-device]

Scenario S1-R5-A: exemption requested at setup

Given the user grants alarm scheduling permission for the first time
When permission setup runs
Then the system dialog for REQUEST_IGNORE_BATTERY_OPTIMIZATIONS is presented
  (unless the app is already exempt)

S1-R6 — Native fade-in matching fadeInSegundos

PluriWaveAlarmService MUST apply a volume ramp from 0 to target volume over fadeInSegundos seconds when starting alarm audio on the native path, so that fade-in works even when the ringing screen is not foregrounded.

[on-device]

Scenario S1-R6-A: native path audio fades in

Given an alarm fires while the app is backgrounded/killed
  And the alarm's fadeInSegundos is greater than 0
When the native service starts playing audio
Then audio starts at near-zero volume and ramps to the configured volume over fadeInSegundos seconds

S2 — Alarm UX parity (snooze end-to-end)

S2-R1 — Snooze buttons on PantallaAlarmaSonando

PantallaAlarmaSonando MUST display snooze buttons for 3, 5, and 10 minutes, plus one button showing the alarm's configured snoozeMinutos value when it differs from the fixed options. Tapping any snooze button MUST:

  1. Cancel the active _fadeInTimer.
  2. Stop and dispose the _fallbackPlayer.
  3. Stop the native alarm service (via the same stop path used by the dismiss button).
  4. Call EstadoAlarmas.posponerAlarma(alarma, minutes).
  5. Close PantallaAlarmaSonando.

[flutter test] (widget test for button presence and tap behavior); [on-device] (full native service stop verified manually)

Scenario S2-R1-A: snooze button appears

Given PantallaAlarmaSonando is displayed for an alarm with snoozeMinutos = 5
When the widget tree is inspected
Then buttons for 3 min, 5 min, and 10 min snooze are present
  And a single "stop" / dismiss button is present

Scenario S2-R1-B: tapping snooze stops audio and reschedules

Given PantallaAlarmaSonando is displayed and audio is playing (fadeInTimer active, fallbackPlayer active)
When the user taps the 5-minute snooze button
Then the fadeInTimer is cancelled
  And the fallbackPlayer is stopped and disposed
  And the native alarm service receives a stop command
  And EstadoAlarmas.posponerAlarma is called with minutes = 5
  And the screen is dismissed/popped

Scenario S2-R1-C: custom snoozeMinutos displayed

Given an alarm has snoozeMinutos = 7
When PantallaAlarmaSonando is displayed
Then a snooze button labeled "7 min" is present in addition to the fixed 3/5/10 options

S2-R2 — Alarm list reflects postponed next-trigger immediately

After snooze is accepted (from the Flutter screen or from the native notification), the alarm list in PantallaAlarmas MUST display the updated snoozeHasta as the next-trigger time without waiting for the 60-second periodic poll.

[flutter test] (unit test: posponerAlarma updates notifier synchronously)

Scenario S2-R2-A: alarm list updated after snooze

Given the alarm list is visible and showing an alarm's next-trigger time T
When the user snoozes the ringing alarm for 5 minutes (from PantallaAlarmaSonando)
Then within the same UI frame after posponerAlarma completes
  the alarm list shows the new next-trigger time T+5 min
  And no manual refresh or navigation is required

S2-R3 — Snooze from native notification while app killed or foregrounded

When the user taps the "Posponer" action on the fire notification while the app is backgrounded or killed, AlarmScheduler.snooze(id, minutes) MUST execute a real setAlarmClock registration for now + N minutes. When the app becomes active (foreground resume), EstadoAlarmas MUST reflect the new snoozeHasta via the existing MethodChannel event flow (not waiting for the next 60-second poll).

[on-device] (native scheduling verified); [flutter test] (Dart event handler updates EstadoAlarmas state)

Scenario S2-R3-A: notification snooze schedules real alarm

Given the app is killed (not in memory)
  And a fire notification with "Posponer" action is visible
When the user taps "Posponer"
Then AlarmScheduler.snooze executes setAlarmClock for now+snoozeMinutes
  And the alarm appears in the system status-bar alarm icon count

Scenario S2-R3-B: Flutter state syncs on resume without polling

Given snooze was triggered from the native notification while app was backgrounded
When the app is brought to the foreground
Then EstadoAlarmas.alarmas contains the snoozed alarm with snoozeHasta = snooze target time
  And the update happens before the next 60-second poll fires

S2-R4 — Snoozed alarm fires at postponed time

A snoozed alarm MUST fire again at the exact snoozeHasta time. AlarmScheduler.snooze MUST call setAlarmClock with snoozeHasta as the trigger; the resulting AlarmClockInfo MUST be verifiable through the diagnostics channel.

[on-device]

Scenario S2-R4-A: alarm re-fires after snooze

Given an alarm was snoozed for 5 minutes at time T
When time T+5 minutes is reached
Then the alarm fires again (ringing screen shown or notification posted)
  And the alarm's snoozeHasta is cleared after it fires

Scenario S2-R4-B: diagnostics confirm real setAlarmClock registration

Given an alarm has been snoozed
When the alarm diagnostics screen is opened
Then the snoozed alarm entry shows a non-null next-trigger time matching snoozeHasta
  And "exact alarm scheduled" status is shown for that alarm

S2-R5 — Stop during snooze-pending state cancels snooze

If the user stops an alarm (dismiss) before the snoozed occurrence fires, EstadoAlarmas.finalizarEjecucion (or a dedicated cancel path) MUST cancel the native setAlarmClock registration for the snooze occurrence and clear snoozeHasta on the AlarmaMusical model.

[flutter test] (unit test: finalizarEjecucion clears snoozeHasta and calls android.cancelar); [on-device] (alarm does not re-fire)

Scenario S2-R5-A: stop cancels pending snooze

Given an alarm is in snooze-pending state (snoozeHasta is set, alarm has not re-fired)
When the user opens the alarm list and disables or deletes the alarm
  Or explicitly stops the alarm from any surface
Then ServicioAlarmasAndroid.cancelar is called for that alarm
  And the alarm's snoozeHasta is null in persistent storage
  And the alarm does not re-fire at the snooze time

S2-R6 — Snooze Dart-side unit tests (strict TDD)

The following Dart-side behaviors MUST be covered by flutter test unit tests:

  • EstadoAlarmas.posponerAlarma calls android.programar with updated snoozeHasta.
  • EstadoAlarmas.posponerAlarma calls notifyListeners after state update.
  • EstadoAlarmas.finalizarEjecucion clears snoozeHasta and calls android.cancelar or android.programar without snoozeHasta.
  • ServicioAlarmas.posponerEjecucion computes snoozeHasta = ejecucion + minutos correctly.
  • The MethodChannel payload sent by ServicioAlarmasAndroid.programar for a snoozed alarm contains snoozeUntilMillis matching the alarm's snoozeHasta.

[flutter test]

Scenario S2-R6-A: posponerAlarma unit test

Given a mock PuertoAlarmasAndroid and a mock ServicioAlarmas
  And an alarm with proximaEjecucion = T
When EstadoAlarmas.posponerAlarma(alarma, 10) is called
Then servicio.posponerEjecucion is called with minutos = 10
  And android.programar is called once with the updated alarm carrying snoozeHasta = T+10min
  And notifyListeners was called

S2-R7 — Ringing screen migrated to PluriWaveScaffold with entry animation

PantallaAlarmaSonando MUST use PluriWaveScaffold instead of a raw Scaffold with a hardcoded Color(0xFF061722) background, and MUST include an entry animation on mount (fade or slide, honoring MediaQuery.disableAnimations).

[flutter test] (widget test: PluriWaveScaffold present); [on-device] (visual)

Scenario S2-R7-A: no raw Scaffold with hardcoded color

Given PantallaAlarmaSonando is mounted
When the widget tree is inspected
Then no raw Scaffold with backgroundColor = Color(0xFF061722) is found at the root
  And PluriWaveScaffold (or equivalent themed scaffold) wraps the content

S2-R8 — Next-trigger preview in alarm editor

_EditorAlarmaSheet MUST display a read-only next-trigger timestamp computed from the current editor state (schedule type, time, weekdays, one-shot date) so the user can verify when the alarm will fire before saving.

[flutter test] (widget test)

Scenario S2-R8-A: next-trigger shown in editor

Given the alarm editor is open with a recurring weekday alarm set for Monday/Wednesday at 07:00
When the widget is inspected
Then a text widget shows the next calculated trigger date/time
  And it updates when the user changes the schedule

S2-R9 — Searchable station picker

The station selection in the alarm editor MUST use a searchable bottom-sheet picker instead of a raw DropdownButtonFormField, matching the interaction pattern of the main station picker.

[flutter test] (widget test: bottom sheet opens on tap)

Scenario S2-R9-A: search bottom sheet opens

Given the alarm editor is open
When the user taps the station selection field
Then a bottom sheet with a search input and station list is presented
  And typing in the search input filters the list

S2-R10 — Configurable snooze duration

The alarm editor MUST allow the user to set a custom snooze duration (stored as AlarmaMusical.snoozeMinutos). The ringing screen snooze buttons MUST use this value as the labeled default option.

[flutter test] (widget test)

S2-R11 — Volume-slider floor lowered

The alarm volume slider MUST allow values down to 0.0 (from the current floor of 0.25).

[flutter test] (widget test: slider min value)


S3 — Audio and runtime robustness (test seams)

S3-R1 — audio_session integrated for audio focus

The audio_session package (pubspec.yaml:19) MUST be imported and configured so that phone calls and other audio-focus events (transient/permanent loss) pause or duck the radio playback. The existing audio_session declaration MUST NOT remain unused.

[on-device] (incoming call pauses radio); [flutter analyze] (import present)

Scenario S3-R1-A: phone call pauses radio

Given the radio is playing
When an incoming phone call starts
Then the radio playback is paused or ducked
  And resumes when the call ends (if it was only transient focus loss)

S3-R2 — Injectable StreamController and handler flag

The static _eventosController and _handlerInstalado fields in ServicioAlarmasAndroid (lines 117-119) MUST be converted to instance fields injectable via constructor or a test factory, making the class testable without global state side-effects.

[flutter test] (unit tests can construct isolated instances)

Scenario S3-R2-A: two instances do not share state

Given two ServicioAlarmasAndroid instances created independently in a test
When one instance receives an alarm event
Then the other instance's eventosAlarma stream does not emit that event

S3-R3 — configurarLocalizaciones removed from build()

configurarLocalizaciones(l10n) MUST NOT be called inside any build() method (current violation: mini_reproductor.dart:23). It MUST be called once from the widget lifecycle (initState, didChangeDependencies, or the provider consumer's init path).

[flutter analyze] (no-ops inside build); [flutter test] (verify call count)

Scenario S3-R3-A: localization call not in build

Given the MiniReproductor widget is mounted and rebuilt 10 times due to state changes
When the call count to configurarLocalizaciones is measured
Then it is called at most once per locale change (not once per rebuild)

S3-R4 — Single cached SharedPreferences instance

A single SharedPreferences instance MUST be initialized at app startup (e.g., in main.dart) and injected into all services that currently call SharedPreferences.getInstance() inline (25+ sites). No service MUST call SharedPreferences.getInstance() after app startup.

[flutter test] (unit tests use injected mock); [flutter analyze] (no getInstance calls in service classes after injection)

Scenario S3-R4-A: services receive injected prefs

Given the app starts
When ServicioAlarmas, ServicioEcualizador, and ServicioGrabacionRadio are constructed
Then each receives the single SharedPreferences instance (no internal getInstance call)

S3-R5 — recalcularTodas writes guarded by change flag

recalcularTodas() in EstadoAlarmas (lines 316-323) MUST compare the new schedule to the existing one before writing to SharedPreferences. If the schedule is unchanged, the write MUST be skipped.

[flutter test]

Scenario S3-R5-A: no write on unchanged schedule

Given the alarm schedule has not changed since the last write
When recalcularTodas() is called
Then SharedPreferences.setString is NOT called

Scenario S3-R5-B: write happens on schedule change

Given the alarm schedule has changed
When recalcularTodas() is called
Then SharedPreferences.setString IS called exactly once

S3-R6 — Bounded _ejecucionesEmitidas set

_ejecucionesEmitidas in EstadoAlarmas (line 32) MUST be bounded. Entries that are older than a configurable retention window (e.g., 24 hours past their scheduled time) MUST be pruned to prevent unbounded memory growth.

[flutter test]

Scenario S3-R6-A: old entries pruned

Given _ejecucionesEmitidas contains 100 entries all older than 24 hours
When the pruning logic runs (triggered on next alarm event or periodic cleanup)
Then _ejecucionesEmitidas.length is less than or equal to the max expected entries

S3-R7 — In-memory alarm cache in ServicioAlarmas

ServicioAlarmas MUST maintain an in-memory cache so that cargar() is not called before every mutation (eliminating the read-modify-write N+1 race at lines 81-108). The cache MUST be invalidated on any write operation.

[flutter test]

Scenario S3-R7-A: concurrent mutations use cache

Given ServicioAlarmas has loaded alarms into cache
When two mutations are dispatched concurrently
Then only one cargar() call is made (not two), and both mutations are applied correctly

S4 — EstadoRadio god-class split

S4-R1 — EstadoEcualizador extracted

A EstadoEcualizador extends ChangeNotifier MUST own all EQ state (preset, bands, enabled flag) previously in EstadoRadio. EstadoRadio MUST NOT expose EQ state directly.

[flutter test] (unit test: EstadoEcualizador notifies on preset change); [flutter analyze]

Scenario S4-R1-A: EQ state owned by EstadoEcualizador

Given EstadoEcualizador is registered as a provider
When aplicarPreset is called on EstadoEcualizador
Then EstadoEcualizador notifies its listeners
  And EstadoRadio listeners are NOT rebuilt

S4-R2 — EstadoGrabacion extracted

A EstadoGrabacion extends ChangeNotifier MUST own all recording state previously in EstadoRadio. ServicioGrabacionRadio MUST be managed by EstadoGrabacion.

[flutter test]

S4-R3 — EstadoBusqueda extracted

A EstadoBusqueda extends ChangeNotifier MUST own search query, results, and loading state previously in EstadoRadio.

[flutter test]

S4-R4 — ServicioExportImport extracted

A ServicioExportImport class MUST own the jsonEncode/jsonDecode backup and restore logic currently inlined in pantalla_ajustes.dart (1391 lines). PantallaAjustes MUST delegate all JSON serialization to ServicioExportImport.

[flutter test] (round-trip test: serialize then deserialize produces identical config)

Scenario S4-R4-A: export/import round-trip

Given a non-trivial app configuration (alarms, favorites, EQ presets)
When ServicioExportImport.exportar() is called and its output is passed to importar()
Then the reconstructed configuration equals the original

S4-R5 — Consuming screens use scoped rebuilds

PantallaInicio, PantallaAjustes, and PantallaFavoritos MUST NOT call context.watch<EstadoRadio>() at the root widget. Each MUST use context.select or a Consumer scoped to the specific fields it reads.

[flutter test] (widget test: changing EQ preset does not rebuild PantallaInicio)

Scenario S4-R5-A: EQ change does not rebuild inicio screen

Given PantallaInicio is mounted and displaying station info
When EstadoEcualizador notifies (preset change)
Then PantallaInicio's build method is NOT called

S5 — Design system, accessibility, i18n pass

S5-R1 — Hardcoded color literals replaced by tokens

All 14+ hardcoded Color(0x...) literals identified in the explore report (C3) MUST be replaced by PluriWaveTokens or Theme.of(context).colorScheme references. No new hardcoded color literals SHALL be introduced.

[flutter analyze] (custom lint or grep); [flutter test] (widget test: token resolves correctly)

Scenario S5-R1-A: no raw color literals in target files

Given the diff for Slice 5 is applied
When flutter analyze runs
Then no instances of Color(0x...) appear in the modified files beyond theme-extension token definitions

S5-R2 — Accessibility semantics on favorite button and alarm image

The mini favorite InkWell in TarjetaEmisora (line 238-289) MUST be wrapped in a Semantics widget with an appropriate label and button: true. It MUST have a minimum touch target of 48 dp. The alarm PNG widget MUST carry a semanticLabel.

[flutter test]

Scenario S5-R2-A: favorite button is accessible

Given TarjetaEmisora is mounted
When the accessibility tree is inspected
Then the favorite action node has a non-empty semantic label
  And its size is at least 48x48 dp

S5-R3 — Reduced-motion guard

A PluriAnimate extension (or equivalent helper) MUST check MediaQuery.disableAnimations and skip or replace animations when the user has enabled reduced motion in system settings. All animations in Slices 2 and 5 MUST use this guard.

[flutter test] (widget test: animation skipped when disableAnimations = true)

Scenario S5-R3-A: animation skipped in reduced-motion mode

Given MediaQuery.disableAnimations is true (test override)
When PantallaAlarmaSonando is mounted (entry animation present per S2-R7)
Then no animated position/opacity change is applied on mount

S5-R4 — Locale-aware date formatting

_fechaCorta in PantallaAlarmas (line 1114) MUST use intl.DateFormat with the current locale rather than a hardcoded DD/MM/YYYY format string.

[flutter test]

Scenario S5-R4-A: date formatted per locale

Given locale is 'en-US'
When _fechaCorta is called with DateTime(2026, 6, 11)
Then the result matches DateFormat.yMd('en-US').format(DateTime(2026, 6, 11))
  And does NOT return "11/06/2026"

S5-R5 — Pluralization for bare counters

PantallaFavoritos (line 138) MUST use AppLocalizations plural forms for station count strings (e.g., "1 station" vs "5 stations").

[flutter test]

S5-R6 — Rounded shimmer placeholders

Shimmer placeholders in TarjetaEmisora (lines 389-420) MUST use rounded corners matching the actual content card corners. The PantallaBuscar loading state (lines 241-245) MUST use shimmer instead of a spinner.

[flutter test] (widget test: shimmer present during loading state)

S5-R7 — Rounded icon variants consistent

Icon usage in PantallaAjustes (lines 985, 1028, 1031) MUST use _rounded Material icon variants to be consistent with the rest of the app.

[flutter analyze] (grep for non-rounded icon names at those sites)

S5-R8 — Brand notification color

The notificationColor in AudioServiceConfig (main.dart:23) MUST be set to the app's brand color token rather than the M3 default Color(0xFF6750A4).

[flutter test] (unit test: config uses brand color)


S6 — Quality gates

S6-R1 — Hardened analysis_options.yaml

analysis_options.yaml MUST enable at minimum the following lint rules: cancel_subscriptions, close_sinks, unawaited_futures, prefer_final_locals, avoid_dynamic_calls.

[flutter analyze]

Scenario S6-R1-A: new lints pass

Given the hardened analysis_options.yaml is applied
When flutter analyze runs on the full lib/ tree
Then no errors are emitted for cancel_subscriptions, close_sinks, unawaited_futures,
  prefer_final_locals, or avoid_dynamic_calls

S6-R2 — Top-5 missing unit tests written

The following test cases MUST exist and pass under flutter test:

  1. ServicioAlarmas concurrent read-modify-write: two simultaneous mutations produce a consistent final state with no lost write.
  2. Alarm fire dedup across refrescarProgramacion: calling refrescarProgramacion while an alarm is already in _ejecucionesEmitidas does not emit a duplicate event.
  3. PluriWaveAudioHandler rapid source-switch race: switching stations faster than the 12-second timeout cancels the previous load and does not emit a stale error state.
  4. Export/import round-trip (see S4-R4 scenario).
  5. ServicioGrabacionRadio error recovery: a recording error clears the recording state and does not leave open sinks.

[flutter test]

Scenario S6-R2-A: concurrent mutation test

Given two calls to ServicioAlarmas.guardar run concurrently with different alarm payloads
When both futures complete
Then the persisted state contains both alarms without either being lost

Scenario S6-R2-B: source-switch race test

Given PluriWaveAudioHandler has started loading station A
When station B is requested before station A finishes loading
Then the playback state does not transition to error for station A's load failure
  And the final state reflects station B

S7 — Streaming resilience

S7-R1 — Enlarged live-stream buffer

PluriWaveAudioHandler (ExoPlayer / just_audio) MUST configure an enlarged live-stream buffer sufficient to tolerate short network interruptions of up to approximately 15-30 seconds without audible playback interruption. The buffer size MUST be configurable (not hardcoded to a single magic constant).

[on-device] (network drop test); [flutter test] (unit test: buffer config applied to player)

Scenario S7-R1-A: buffer covers short network drop

Given the radio is playing and ExoPlayer has buffered content
When network connectivity is lost for up to 15 seconds
Then audio playback continues without interruption (buffered content plays through)
  And the UI does not show an error state during the buffer window

S7-R2 — Automatic reconnection with bounded exponential backoff

When the live stream stalls or emits a PlayerException with a network-related error code (2xxx range) while the user's intended playback state is "playing" (not user-paused or user-stopped), PluriWaveAudioHandler MUST attempt automatic reconnection using exponential backoff with a configurable maximum retry count (default: 5) and a configurable maximum delay (default: 30 seconds). After retries are exhausted, the error MUST be surfaced to the UI via the playbackState error state.

The handler MUST distinguish user-initiated stop/pause (no reconnect) from network stall (reconnect).

[flutter test] (unit test for reconnect decision logic and backoff timing)

Scenario S7-R2-A: reconnect on network stall

Given the radio is playing and userIntent = playing
When a PlayerException with code 2001 (no internet) is received
Then the handler transitions to a "buffering/reconnecting" processing state
  And schedules a retry after the backoff delay (not an immediate error state)

Scenario S7-R2-B: no reconnect on user stop

Given the radio is playing
When the user calls ServicioAudio.detener()
Then userIntent is set to stopped
  And if a PlayerException subsequently fires (from the stop race)
  Then no reconnection is attempted

Scenario S7-R2-C: retries exhausted → error surfaced

Given the handler has attempted 5 reconnections without success
When the 5th retry also fails
Then playbackState transitions to AudioProcessingState.error with errorMessage set
  And no further retries are attempted

Scenario S7-R2-D: backoff logic unit test

Given a reconnect strategy with maxRetries=5 and baseDelay=1s
When retries 1..5 are simulated
Then the delay sequence is approximately [1s, 2s, 4s, 8s, 16s] capped at maxDelay
  And after retry 5 the strategy returns "exhausted"

S7-R3 — Buffering/reconnecting state surfaced without dialog spam

While the handler is in the reconnecting/buffering phase, the UI MUST show a loading/ buffering indicator (e.g., the existing EstadoReproduccion.cargando state) and MUST NOT show an error dialog or snackbar for each retry attempt. An error MUST only be shown after retries are exhausted (S7-R2-C).

[flutter test] (widget test: no dialog shown during buffering state)

Scenario S7-R3-A: no error dialog during reconnect

Given the handler is in reconnecting state (attempt 2 of 5)
When the UI observes estadoStream
Then EstadoReproduccion.cargando is emitted (not error)
  And no AlertDialog or SnackBar with error text is visible

S7-R4 — Alarm audio path not regressed

The enlarged buffer and reconnect logic MUST apply only to the user-initiated radio playback path (PluriWaveAudioHandler). The native alarm audio path (PluriWaveAlarmService MediaPlayer) MUST be unchanged by Slice 7. The existing 15-second stream timeout before falling back to bundled WAV MUST be preserved.

[on-device] (alarm still fires with fallback); [flutter test] (alarm service not using new buffer config)

Scenario S7-R4-A: alarm fallback timing unchanged

Given an alarm fires with a station URL that never responds
When 15 seconds elapse
Then the native service falls back to the bundled WAV (behavior unchanged by S7)

S7-R5 — Recording path not regressed

ServicioGrabacionRadio uses its own HTTP stream (not PluriWaveAudioHandler). Its error handling MUST NOT be modified by Slice 7 changes.

[flutter test] (S6-R2 test #5 still passes after S7 changes)

Scenario S7-R5-A: recording error handling unchanged

Given ServicioGrabacionRadio is recording from a stream that errors
When the error occurs
Then the recording state is cleared (same behavior as before S7)
  And no backoff or reconnect logic from S7 is triggered

S7-R6 — Sleep-timer fade-out not regressed

The sleep-timer fade-out logic (gradual volume reduction → stop) MUST complete normally even if the stream enters a buffering state during the fade-out window. The reconnect logic MUST NOT restart playback after the sleep-timer issues a stop command.

[flutter test]

Scenario S7-R6-A: sleep timer stop honored during reconnect

Given the sleep timer is active and has issued a stop command
When the handler would normally schedule a reconnect attempt
Then the reconnect is suppressed because userIntent = stopped (from sleep timer stop)
  And audio stops as intended

S7-R7 — Reconnection unit tests (strict TDD)

The following MUST be covered by flutter test unit tests:

  • Backoff delay computation for retries 1 through N with cap at maxDelay.
  • userIntent transitions: reproducir() sets intent to playing; detener() and pausar() set intent to stopped/paused.
  • No reconnect scheduled when userIntent != playing.
  • Reconnect attempted when userIntent == playing and error is network-class.
  • Error state emitted after maxRetries exhausted.

[flutter test]


Cross-cutting requirements

CC-R1 — No flutter build required

No requirement in this spec SHALL necessitate running flutter build. Native correctness requirements (marked [on-device]) are validated by the user via manual device testing.

CC-R2 — Strict TDD

All Dart-side behavioral logic introduced by Slices 1-7 MUST be covered by flutter test unit tests written before or alongside the implementation code. Slices that introduce new Dart classes MUST include at least one test file per new class.

CC-R3 — No regressions to existing flutter test suite

After each slice, flutter test MUST pass with no new failures. All 12 existing test files MUST continue to pass.

[flutter test]

CC-R4 — flutter analyze clean

After each slice, flutter analyze MUST report zero errors. Warnings introduced by new lint rules (S6-R1) MUST be resolved before the slice PR is merged.

[flutter analyze]