# 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()` 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]**