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

897 lines
32 KiB
Markdown

# 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]**