EstadoRadio.emisoraActual only ever reflected in-memory state
(_emisoraSeleccionada or the live audio service), so stopping playback
and reopening the app left the Escuchar hero empty even though the
user had a station selected right before closing it.
Persist the station whenever it changes (reproducir(), and the
Android-Auto out-of-band reconciliation path) and restore it as
_emisoraSeleccionada on the next cold start, only when nothing is
already selected. This never touches the audio service directly: no
playback starts and estadoStream/estaSonando stay at their stopped
default, matching how every consumer already gates "is it playing" on
the playback-status stream rather than on emisoraActual itself.
Vacaciones ranges could be created but never edited or removed --
EstadoAlarmas already had crearRangoVacaciones/eliminarRangoVacaciones
with no UI affordance reaching them, and no update path at all.
Add EstadoAlarmas.editarRangoVacaciones and wire tap-to-edit /
swipe-to-delete (with confirmation) onto every range card, mirroring
the alarm list's own Dismissible + confirm-dialog pattern exactly. This
covers the active-range hero too: a freshly created range is active
immediately and only ever renders there, never in the
scheduled/past lists, so it needed the same affordances or a user's
very first range could never be fixed.
Restyle the Ecualizador settings screen to the new visual language while
keeping the equalizer at 5 bands (spike-resolved, Engram id 2498 - band
count is device-reported via just_audio's AndroidEqualizer, not app-chosen;
the approved mockup's 7 sliders would silently no-op on typical hardware).
- Restyle EcualizadorWidget in place: strip its internal title + preset
chip row (the pushed screen's header now carries the title), add a
habilitado parameter that greys/disables every slider when EQ is off.
Widen PresetsEcualizadorWidget additively (personalizados param) so
custom presets can join the chip row without a second implementation.
- Add servicio_presets_personalizados.dart (new file, own SharedPreferences
key eq_custom_presets_v1) for custom EQ preset persistence - kept out of
servicio_ecualizador.dart, which has an empty-git-diff success criterion
for this change. preset_ecualizador.dart is unchanged: a custom preset is
just a PresetEcualizador with a user-supplied name.
- Extend EstadoEcualizador with presetsPersonalizados,
guardarPresetPersonalizado (validates non-empty name),
eliminarPresetPersonalizado. The load is a new explicit
cargarPresetsPersonalizados(), deliberately NOT folded into
cargarPersistido(): that method is exercised ~30 times by
estado_ecualizador_test.dart (protected, must stay unmodified) via Fakes
only, with no SharedPreferences awareness in that file.
- Build out the Ecualizador screen body: base-vs-per-station explainer
banner, a "Salida activa" row surfaced on the main screen (previously
Advanced-only), an "Emisoras con ajuste propio" drill-down sourced from
the existing presetsPorEmisora map, and a "Guardar como preset" action.
New coverage lives in new files rather than touching the three protected
EQ test files: ecualizador_widget_test.dart (component-level, did not
exist before this commit), servicio_presets_personalizados_test.dart, and
estado_ecualizador_presets_personalizados_test.dart. servicio_ecualizador.dart,
servicio_audio.dart and the three protected EQ test files keep an empty
git diff. Full suite: 713/713 green (2 skipped, unchanged), up from 682.
size:exception - realized 1,954 changed lines (25 files, plus this docs
update) against the 400-550 forecast: lib/ + ARB alone is ~650 lines, near
the top of the forecast band by itself since this WU also had to build out
a screen body WU3a only stubbed; the rest is 4 test files (675 lines) and
11 new ARB keys regenerating 13 lib/l10n/gen files (~546 lines) - the same
pattern every prior work unit in this branch has hit. Not splittable: WU14
reuses this unit's editor component by exact runtime type and cannot begin
until this lands as a whole.
Add the Vacaciones manager screen per design ADR-6: an active-range
hero (name, days-remaining countdown, determinate progress bar, and a
per-alarm pause-impact line), a "PROGRAMADOS" upcoming-ranges list, an
"Add range" CTA, and a "Rangos pasados" history section. This is the
real destination WU8's Alarmas-root summary row pushes to, replacing
WU8's own temporary placeholder (_PantallaVacacionesTemporal, now
deleted).
EstadoAlarmas gains 4 pure query methods (rangoVacacionesActivo,
vacacionesProximas, vacacionesPasadas, impactoDeRango) -- read-only
over _alarmas/_vacaciones, no writes, no rescheduling, no native
bridge calls. impactoDeRango mirrors ServicioProgramacionAlarmas's own
pause predicate exactly, so the screen never disagrees with the
scheduler about which alarms are paused. ImpactoVacaciones joins
RangoVacaciones in alarma_musical.dart.
The add-range form (_EditorVacacionesSheet, _PickerButton) moved
verbatim from pantalla_alarmas.dart to its one remaining consumer.
estado_alarmas.dart's scheduling/snooze paths and the ringing screen's
dismiss guard are untouched; both test files pass unmodified.
New ARB keys (en/es only; other 11 locales are WU18's job):
vacationImpact{Paused,Continues}Label, vacationUpcomingSectionTitle,
vacationPastSectionTitle, addVacationRangeCta,
vacationNoActiveRangeHint.
Extracts ServicioRadio's transport loop (server discovery, host rotation,
bounded retries, User-Agent, timeout, status check, json.decode, sticky-host
bookkeeping) out of `_get` into a new `_getJson(path, params) ->
Future<List<dynamic>>` helper, moved as one block with no logic edits. `_get`
is reimplemented on top, still owning every station-specific concern:
`lastcheckok: '1'`, `Emisora.fromApi` + the empty-uuid/url filter, and the
`_compararCalidad` quality sort. `_getJson` is deliberately sort-agnostic and
filter-agnostic so a non-station endpoint can reuse the resilience behaviour
without inheriting station-only semantics.
Non-negotiable ordering followed per design ADR-4: new
test/servicios/servicio_radio_transporte_test.dart characterises all 8
existing station calls (7 via `_get` plus `registrarClick`, which builds its
own URI) against the UNMODIFIED `_get` first - green by construction -
pinning path, lastcheckok=1, hidebroken=true, a non-empty User-Agent, exact
order/reverse/limit/offset, and the exact returned UUID sequence from a
fixture with deliberately shuffled bitrate/clickcount/votes. That last
assertion is what makes the extraction safe: a sort that silently sank into
transport would pass every other check. Re-running the same file after the
extraction is byte-identical green. test/servicios/servicio_radio_test.dart
is untouched by this work unit - its passing unmodified is itself a signal
that transport wasn't disturbed.
The 6 pre-existing `order: bitrate` occurrences (obtenerPopulares,
buscarPorNombre, buscarPorPais, buscarPorIdioma, buscarPorTag, buscar) are
untouched - a deliberate server-side quality bias deciding which stations
return within `limit`, unrelated to and never to be confused with the
user-facing "Ordenar" control, which stays entirely client-side via the
existing OrdenEmisoras (Engram reference/radio-browser-sort-order).
Behaviour delta, accepted per ADR-4, not a regression: moving
`_servidorActual` bookkeeping into `_getJson` means a successful
`/json/countries` call now warms the sticky host for subsequent station
calls too - one shared warm mirror per instance, desirable, not per-call-type
state.
Adds the Paises browser over the verified `/json/countries` contract (Engram
reference/radio-browser-countries-endpoint): new lib/modelos/pais_radio.dart
(`PaisRadio.fromApi` parses `stationcount` via `int.tryParse` since the API
returns it as a JSON string, not an int - an `as int` cast would throw),
`obtenerPaises()` sends neither `lastcheckok` nor `order` (the screen sorts
client-side by name; the API's raw byte order isn't proper collation for any
locale this app ships), and inherits `hidebroken=true` from the unchanged
`_uri` (desirable here too, since the endpoint's own default is false).
`EstadoBusqueda` gains `paises`/`cargandoPaises`/`cargarPaises()` with an
in-memory cache guard so re-entering the screen never refetches.
New PantallaPaises (lib/pantallas/pantalla_paises.dart): a "Tus idiomas"
shortlist (one representative country per the app's 13 supported locales,
matched against the fetched list - the proposal/spec name this section but
don't specify its derivation) above the full alphabetical list, each entry
showing its parsed station count. Reachable from Buscar's discovery landing
state via a new entry row, added now rather than left dangling per this
file's own forward-reference comment (and the WU15/WU15b lesson: a
fully-tested but unreachable screen is a real defect, not a follow-up).
New ARB keys (en/es only, matching this change's established precedent):
countriesScreenTitle, countriesYourLanguagesTitle, countriesAllTitle,
radioCountriesError.
Tests: 631 -> 649 (2 skipped, unchanged). flutter analyze unchanged at 1
pre-existing info. grep confirms `countrycodes` appears nowhere in lib/.
Replaces the stacked per-group panel layout with a single
chip-filtered flat list. Chips read "{name} · {count}" (new ARB keys
favoriteGroupsChipLabel/favoritesFilterAllLabel), one per group plus
an "All" chip. Rows drag-reorder via a leading handle
(ReorderableDragStartListener, buildDefaultDragHandles: false) using
the modern onReorderItem callback rather than the now-@Deprecated
onReorder (Flutter 3.44 marks it obsolete).
EstadoRadio additions: listaFavoritosManual (a new memoized getter
returning the stored order untouched by the global ordenListas
setting - listaFavoritos itself always re-sorts by
name/quality on every read, which would silently discard any
drag-to-reorder), reordenarFavorito (thin wrapper over the
already-existing ServicioFavoritos.reordenar, previously unused
outside its own service test), and ordenarFavoritos (applies an
existing OrdenEmisoras criterion via ordenarEmisoras() and persists
the result as the new manual order, so the swap_vert sort action's
result also survives a restart). listaFavoritos itself is untouched,
so Android Auto's tree and the future Escuchar grid (WU5) are
unaffected by Favoritos' own manual order.
Group management: an "Manage lists" action chip pushes the existing
PantallaAjustesGruposFavoritos screen (Settings' own screen, reused
rather than duplicated) - a second entry point to the same screen.
Custom-station CTA: a new dashed-bordered card opens the add-station
form directly; that form was renamed from private _FormularioEmisora
to public FormularioEmisoraPersonalizada in
pantalla_ajustes_emisoras_personalizadas.dart so both screens share
one implementation. New ARB keys: favoriteGroupsManage,
customStationsAddCta.
Tests: pantalla_favoritos_plural_test.dart (the file tasks.md named)
never imported PantallaFavoritos - it only covers stationCount's ARB
plural formatting, unrelated to this screen. Left it untouched and
added test/pantallas/pantalla_favoritos_test.dart instead: 3
state-layer tests for the new EstadoRadio surface plus 6 widget
scenarios (empty-state CTA, chip filter, drag-reorder persistence,
sort action, group management + chip reactivity, custom-station
CTA). 604 -> 614 tests (2 skipped, unchanged). flutter analyze
unchanged at 1 pre-existing info.
Recorded in tasks.md with the test-file correction and the
design decisions this WU had to make on its own (no ADR covers
Favoritos' manual-order persistence).
cambiarActivo persisted BEFORE telling the audio engine, so two quick taps
raced on a SharedPreferences write. When the first write resolved last, the
engine received the FIRST tap's value after the second one: the checkbox read
enabled while the sound stayed flat, and toggling again could invert it the
other way. Reported as the equalizer connecting and disconnecting at random
and the checkbox disagreeing with what is audible.
Reorder to engine first, disk last. The engine call is now issued before any
await, so overlapping taps reach it in tap order and the last tap wins. Each
subsequent step re-checks _activo, so a call that a newer tap superseded
mid-flight neither applies a preset nor persists a value the user has already
changed their mind about. Persisting last also puts what the user HEARS ahead
of what is merely stored.
The regression test drives two opposite taps through a persistence fake whose
FIRST write is the slow one — the exact ordering hazard — and asserts the
engine ends matching the state the UI shows. It fails on the previous
ordering and passes on this one.
An earlier attempt serialized every engine mutation through a shared Future
lane. It fixed this case and deadlocked four widget tests: the lane field
outlived a tester.runAsync block, so a future created in the real async zone
was later chained from the fake-async zone that never advances it. Reverted
in favour of the ordering fix, which needs no cross-zone state.
Only the enable toggle is addressed here. The other reported symptom —
equalization seeming to come and go while playing — is not explained by this
race and is still open; the handler rebuilds the whole AndroidEqualizer on
every player recreation, which is the next place to look.
A Bluetooth device only reports its own name through
AudioDeviceInfo.productName while it is enumerated as an active output, i.e.
while it is connected. Paired-but-switched-off devices therefore had no name
to fall back on, and the platform-name cache is in-memory only by design
(bt-device-identity ADR-4), so it self-heals per session ONLY for whatever
happens to be connected. Every other device showed its raw id.
Android already knows those names: BluetoothAdapter.getBondedDevices() lists
every pairing with its name and MAC, connected or not, and nothing in this
app was asking. Read it and seed the platform-name cache from it, keyed
bt_a2dp:<uppercase MAC> to match the ids the audio layer emits.
Seeded BEFORE the active-device query so a live enumeration name, being the
fresher of the two, still wins; a user's custom name outranks both. Re-read
on refrescarDispositivoActual so pairing or renaming a device in system
settings shows up as soon as the list becomes visible.
Reading the bond list is gated by BLUETOOTH_CONNECT from API 31 and by the
legacy BLUETOOTH permission below it, so declare the latter with
maxSdkVersion 30. It is a normal permission: granted at install, no runtime
prompt, no new friction. When the answer is unavailable — permission denied,
no adapter, Bluetooth off — both layers return an empty map rather than
throwing, and the row degrades to the id exactly as before.
Does not help rows persisted under a bt_a2dp:name: placeholder id: those
never had a MAC to match against.
deviceToMap handed the builtin_speaker id to EVERY output type its `when`
did not name. A car stereo on LE Audio (TYPE_BLE_HEADSET) or an automotive
bus (TYPE_BUS) therefore arrived in Dart under the phone speaker's own id,
carrying a type that maps to `desconocido` -- which slipped past the
type-only esBase guard and persisted a device entry keyed builtin_speaker.
From that moment on, every playback through the phone's own speaker matched
that entry, so the green active-output dot stayed pinned to whatever the user
had renamed it to (a car, in the reported case) whether or not anything was
connected. The dot was never wrong; the row was poisoned.
Give unnamed output types their own `other:<type>:<address>` id namespace,
and match esBase by id as well as by type so no future native regression can
re-create the collision. A guarded one-time migration purges what the
collision already persisted from all three device-keyed maps.
Fix the ranking too: builtin_speaker sat inside the priority list as a peer,
so any type absent from that list sorted BELOW the always-present speaker
and could never win. The speaker is now the explicit last resort, externally
connected outputs outrank it, and virtual or call-only sinks (earpiece,
telephony, remote submix, SCO) are ranked below it so they can never be
reported as where music is playing.
Route every AudioDeviceInfo.getAddress read through a version-guarded
helper. It is API 28 with minSdk 24, and two pre-existing unguarded calls in
this same method were latent NoSuchMethodError crashes on Android 7-8.1.
Android lint for :app goes from 8 errors to 6.
Also lets the user manage the list, which is how they recover from a bad
entry without waiting for a release: a remove action clears a device's
preset, name and matrix entries, unnamed rows show their transport and
address tail instead of a raw bt_a2dp:AA:BB:... id, and the green dot
finally carries a tooltip and a semantics label saying what it means.
Device QA pending for wired and USB outputs: no jack or adapter available to
exercise those paths. Their detection is unchanged by this commit.
Root-cause fix for the unstoppable-alarm incident (alarm rang 15 minutes,
only uninstall silenced it) plus systematic hardening of every stop path.
Native (Kotlin):
- Verified stop: stopActiveAlarm now derives its result from the real
post-teardown state (companion instance + synchronous stopEverything +
activeRingingId check) instead of reporting unconditional success.
- Atomic teardown: every stop path (stop action, notification button,
snooze, missed, onDestroy, startForeground failure) funnels through one
stopEverything() covering audio, wakelock, notification, foreground
state and firing-record cleanup; player.release() guarded.
- Bounded ringing: 10-minute auto-silence armed via AlarmManager fires a
FIRED->MISSED transition with a localized missed-alarm notification;
repeating alarms keep their native rearm, deleted alarms never produce
ghost MISSED notifications.
- Durable firing record with onStartCommand re-validation (resurrection
guard) and boot-time stale cleanup; firing records cleared on every
refuse/mismatch/cancel path.
- New notification-only dismissal channel (dismissAlarmNotificationOnly)
so UI-level dedup can never kill a live ring's audio.
Flutter (Dart):
- Stop/disable/edit/delete of a ringing alarm always attempt to silence
it; on native-query failure the stop falls back toward silence via the
id-scoped legacy stop.
- Verified-stop results surface failures: the ringing screen keeps
dismiss-by-design on success, but on a verified failure it stays up
with a persistent force-stop banner (guarded against double-dismiss)
and auto-dismisses if the ring ends externally (missed/notification).
- Missed events sync alarm bookkeeping without opening the ringing UI.
- 4 new l10n keys translated across all 13 locales (ARB guard green).
550 tests green, analyzer clean. Reviewed in 3 adversarial 4-lens rounds
(2 deterministic + 1 refuter-corroborated critical fixed); formal
gentle-ai receipt waived by maintainer authorization (correction scope
legitimately exceeded the frozen genesis paths). On-device QA checklist
in openspec/changes/alarm-system-overhaul/tasks.md pending before
archive.
- MainActivity: onListen re-emits the current active device and registers
the audio device callback idempotently, so recreated activities resync
instead of freezing the active-device id on a disconnected device.
- servicio_dispositivo_audio: resubscribir() re-opens the event channel;
estado_ecualizador exposes refrescarDispositivoActual() with an
in-flight guard, invoked on app resume and when opening advanced EQ
options, clearing stale green-dot device selections.
- navegacion_auto/servicio_audio: new 'Personalizado' browse tree in
Android Auto (5 band folders, 13 gain steps each) applied live via
setBanda; preset and gain taps persist at device level when
multi-device EQ is active and respect station/matrix overrides,
with apply-before-persist ordering and children-changed notifications.
- l10n: regenerate stale generated localizations; add rxdart as direct
dependency for the subscribeToChildren override.
The advanced EQ device list kept the green-dot selection on the last
connected Bluetooth device after it disconnected, instead of falling
back to the default preset.
- MainActivity.kt: onAudioDevicesRemoved recomputed the active output
device via AudioManager.getDevices(), which can still momentarily
report the just-removed sink (observed on Bluetooth A2DP). Removed
device ids are now excluded explicitly instead of trusting
getDevices() to already be current.
- estado_ecualizador.dart: cambiarMultiDeviceEnabled() re-seeds
dispositivoActualId from a fresh query when the toggle turns back
on, matching cargarPersistido(), so a stale id from before the
toggle flip can't leave the dot pinned to a disconnected device.
Favoritos now renders non-empty custom groups as grupo:<id>
sub-folders (hidden when empty) with ungrouped stations left as
direct leaves, reusing the existing hijos() path so the zero-groups
case stays byte-identical to today's flat list.
Expose PluriWave to Android Auto (projected) as a media app:
- Declare car media support (automotive_app_desc.xml + manifest meta-data)
so Android Auto discovers the existing MediaBrowserService.
- New navegacion_auto.dart: ConstructorArbolAuto builds the browse tree
(Favoritos / Todas las emisoras / Mis emisoras, 50-item cap, stable
emisora:<id> media ids), reproducirPorMediaId routes a car tap to the
existing playMediaItem pipeline, FuenteEmisorasAutoLocal serves the tree
cold-start-safe (local favorites/custom stations before Flutter UI runs).
- PluriWaveAudioHandler overrides getChildren/getMediaItem/playFromMediaId
as thin delegations; playback pipeline untouched.
- EstadoRadio pushes live station snapshots to the browse source and
reconciles the selected station when playback starts from the car.
- Every playable item ships title + artUri; stations without logo fall
back to a bundled default art (android.resource://).
Tests: 52/52 green (10 new navegacion_auto, 2 new estado_radio, plus
audio safety-net suites). Handler overrides and native XML are
static-review-only (no Android build env). Size exception approved for a
single reviewable commit.
posponerAlarma anchored the snooze to snoozeOrigen ?? proximaEjecucion,
but once the native fire path works, the fire-time sync records the
handled occurrence and recalculation advances proximaEjecucion to the
NEXT day before the user can even tap snooze on the still-ringing
screen. "Posponer 3" therefore armed the snooze a full day out
(captured on-device: snoozeCountdown remaining=1443 minutes). The bug
was invisible before because the broken delivery path never advanced
proximaEjecucion while ringing — each fix unmasked the next.
The anchor is now the newest occurrence that is not meaningfully in
the future (shared 90s imminence window): snoozeOrigen for re-snoozes,
proximaEjecucion on the watchdog path where it is still today's
just-due occurrence, ultimaEjecucionGestionada on the native-fire path
where the sync recorded the ringing occurrence, then now. ServicioAlarmas
exposes ahora() so the anchor uses the same injectable clock as the
rest of the scheduling math. Test fixtures that snoozed half an hour
before the ring — a state the ringing screen can never be in, since it
is posponerAlarma's only production caller — now move the clock to
ring time, preserving their original expectations.
_cargarEmisorasCustom resolved the file path outside the IO guard, so
a throw from the resolver escaped into _init()'s Future.wait and took
the sibling loads (populares, favoritos, grupos) down with it — a gap
the old catch-all used to cover. Path resolution now gets the same
IO-fail treatment as an unreadable file: degraded flag, logged skip,
siblings unaffected.
A single malformed custom-station entry (missing uuid/url) used to wipe
the ENTIRE list on next load, and an unparseable file was treated the
same as an unreadable one -- both destroyed the user's saved stations
with no way to recover the original bytes.
Custom stations now parse per-entry via the shared persistencia_tolerante
helper (survivors kept, bad entries skipped+logged); a file that reads
but fails to decode is quarantined into a `.corrupt` sidecar instead of
being dropped, clearing the live path so the next add/remove starts
fresh. A file that cannot be READ at the OS level is left untouched and
a _customDegradado flag suppresses writes for the session -- unlike
alarms, this suppression is intentionally not lifted by an explicit
add/remove, since the file may still be intact on disk.
Dart half of bt-device-identity. EstadoEcualizador now caches each
device's platform-reported name in memory so the settings screen
shows the device's own Bluetooth name instead of its raw id when no
custom rename exists, and skips auto-creating preset entries for the
composite-placeholder sentinel. Enabling multi-device EQ triggers the
Bluetooth permission request through the new channel contract. A
flag-guarded one-time migration purges only entries keyed by the
exact literal placeholder id from the three per-device preference
maps, since those collided entries cannot be attributed to a device.
Work unit 2/2 of bt-device-identity (Dart state + migration).
_onDispositivoCambiado() bootstrapped a device-level preset entry for
every never-seen device id, including the built-in speaker. That
persistent level-3 entry masked later global-preset edits (level 3
beats level 4 on every resolution), so disconnecting a BT device or
cold-starting without one could leave the EQ stuck on an outdated
copy instead of the current global preset.
The base speaker is now excluded from the first-seen bootstrap:
disconnect and cold start always resolve through the live hierarchy.
BT/wired/USB devices keep their bootstrap behavior unchanged.
posponerAlarma() and posponerProximaDesdePreaviso() called the native
scheduler with no error handling, unlike guardarAlarma(). When the
native call failed (e.g. revoked exact-alarm permission), the
exception escaped before notifyListeners() ran, leaving the alarm
list stuck on stale data with no real alarm scheduled and no snooze
countdown notification.
Both methods now mirror guardarAlarma()'s pattern: permission
pre-check, try/catch into _error, and an unconditional
notifyListeners() so the UI always reflects the outcome. Failures
surface via SnackBar in the ringing screen and in app.dart's
postpone-next handler.
The snooze button was fire-and-forget without error handling: if
posponerAlarma threw (e.g. native scheduleAlarm returns false on a
device without exact-alarm permission), _dismissScreen never ran. The
stuck modal also kept _alarmaSonandoActiva true, which made the next
ring get ignored. _posponer/_detener now dismiss in a finally and stop
audio defensively.
Add a native, AlarmManager-driven snooze countdown notification that
re-posts every minute ("Rings in N min", 3->2->1) while the engine is
dead. scheduleSpec drives scheduleSnoozeCountdown for snoozes (instead
of the 30-min pre-notice). Localized text and button labels travel
Dart->Kotlin as {minutes} templates, same pattern as preNoticeTemplate.
Notification actions: "snooze again" (snoozeAgain, anchored to now)
reports back via the existing snoozed event; "stop" (cancelSnooze)
records a handled occurrence for cold-start reconciliation and emits a
new snoozeCancelled event handled in EstadoAlarmas.
Adds snoozeCountdown/snoozeAgainAction keys across all 13 locales and a
test for the snoozeCancelled event. Kotlin changes are static-reviewed
only; no Android build environment available here.
Fix multi-device EQ auto-switching by calling obtenerDispositivoActual()
during cargarPersistido() to seed the initial device ID. Add device
management modal with rename support, EQ preset editing, and connection
status indicator. Translate device UI keys to all 13 locales.
Introduce multi-device EQ support allowing each audio output device
(built-in speaker, wired, USB, individual Bluetooth by MAC) to have
its own equalizer preset, combined with existing per-station presets
for a full station×device matrix.
- Add DispositivoAudio model and ServicioDispositivoAudio interface
- Add Android platform channel (AudioDeviceCallback) for device detection
- Add iOS AudioDevicesPlugin (AVAudioSession route tracking)
- Extend ServicioEcualizador with device and matrix persistence keys
- Implement 4-level resolution: matrix > station > device > global
- Add advanced EQ settings section with feature toggle (off by default)
- Extend export/import to v3 with backward compatibility
- 184 tests passing, zero analyzer issues
- New EstadoGrabacion owns the recording service, subscription, directory/size preferences and open-file actions
- New EstadoBusqueda owns search, nearby stations, pagination and the min-bitrate filter
- New orden_emisoras.dart with the OrdenEmisoras enum, shared sorter and list identity memoization so context.select comparisons work on derived lists
- Large screens (inicio, buscar, favoritos, ajustes, reproductor) consume scoped selects/dedicated notifiers instead of root context.watch<EstadoRadio>, so audio buffer events no longer rebuild whole screens
- Remove all 15 TODO(S4b) compat members from EstadoRadio; consumers use the dedicated providers. EstadoRadio drops from ~1121 to 753 lines, keeping playback/stations/favorites orchestration
- 8 new tests including a rebuild-scoping probe (110 total green), flutter analyze clean
- New ServicioExportImport owns the v2 backup envelope, pretty JSON encode and graceful decode; byte-compatible with existing exports, locked by a round-trip test
- pantalla_ajustes delegates backup serialization to the service (inline jsonDecode/jsonEncode removed)
- New EstadoEcualizador ChangeNotifier owns all EQ state and persistence (principal/current/per-station presets, active flag), exposed via its own provider so EQ changes no longer rebuild EstadoRadio consumers
- EstadoRadio slims down ~210 lines and keeps 15 delegating compat members marked TODO(S4b) for the next slice to remove
- Player EQ toggle rewired to the new provider to avoid going stale
- 4 new tests (103 total green), flutter analyze clean
- Integrate audio_session (new servicio_audio_session.dart): incoming calls pause the radio and resume on end, headphone unplug pauses without auto-resume, permanent focus loss never auto-resumes, duck lowers volume
- Add play-intent flag to ServicioAudio so interruption handling and future reconnect logic can distinguish user pause from system-driven stops
- Eliminate read-modify-write race in ServicioAlarmas with an in-memory cache and single-writer queue across all mutations; recalcularTodas persists only when state actually changed
- Convert ServicioAlarmasAndroid static StreamController/handler to injectable instance fields, restoring test isolation
- Inject a single cached SharedPreferences from main.dart across services and state (removes 23 inline getInstance() calls)
- Move configurarLocalizaciones out of MiniReproductor.build() (was running on every rebuild during playback)
- Bound the alarm fire-dedup set (cap 200 entries, 24h pruning)
- 12 new tests (89 total green), flutter analyze clean
- 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)