f3e9487215
- 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)
897 lines
32 KiB
Markdown
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]**
|