Fixes 9 of 10 review findings (10th requires a manual Play Console
step, no code change):
1. app.dart/banner_anuncio_superior.dart: move the top SafeArea inside
BannerAnuncioSuperior so it only reserves status-bar height when an
ad actually renders, restoring edge-to-edge layout for premium and
free-unloaded users.
2. servicio_anuncios.dart: bound every interstitial await (load,
presentation, and the injected implementation itself) with
injectable timeouts so a callback that never fires can no longer
hang a caller.
3. estado_entitlement.dart/hoja_premium.dart: expose a typed
resultadoUsuario signal for purchase/restore failures and
restore-found-nothing, with dedicated localized messages
(compraError, restauracionSinCompras) across all 13 locales --
never the raw developer/exception string.
4. main.dart/servicio_consentimiento.dart: add a GDPR/UMP consent flow
(ConsentInformation/ConsentForm) that gates Mobile Ads SDK init on
canRequestAds(); premium users never see a consent form; failures
degrade to no ads instead of crashing or blocking startup.
6. servicio_anuncios.dart: track real ad presentation
(onAdShowedFullScreenContent) so a failed-to-show interstitial no
longer consumes a session cap slot.
7. banner_anuncio_superior.dart: add an explicit load-attempted guard
so repeated didChangeDependencies (e.g. entitlement notifyListeners
during a purchase) can only ever trigger one banner load attempt.
8. servicio_anuncios.dart: make esPremium a required constructor
parameter, matching the hardened contract already applied to
EstadoAlarmas/EstadoGrabacion/EstadoRadio.
9. hoja_premium.dart: add a dedicated premiumActivo localized string
instead of reusing the equalizer's equalizerActive translation,
across all 13 locales.
All fixes implemented RED-first (failing test before production
code). Full suite: 1261 passed, 2 pre-existing skips, 0 failures.
flutter analyze: 5 pre-existing issues only, 0 new.
[version set]
Adds a permanent, non-consumable premium unlock (EstadoEntitlement +
PuertoCompras/ServicioComprasPlayBilling) that removes ads and unlocks
alarm vacations, alarms past a 5-alarm free cap, recording start, and
full Android Auto browsing. The phone equalizer stays free for everyone.
- Entitlement is prefs-backed (compra_premium_v1), fail-open, and
resolvable headlessly via esPremiumPersistido() for the Android Auto
audio handler, which registers before runApp.
- Android Auto reduced mode keeps the real root folder labels for free
users; browsing into any of them (and playFromMediaId/playFromSearch/
skipToNext/skipToPrevious) is blocked at the getChildren/servicio_audio
choke points, with a locked "Función Premium" item as the backstop.
Current-station play/pause/stop stays untouched. A free -> premium
transition actively invalidates the head unit's cached browse tree.
- Ads (top banner + capped interstitial before adding a station or an
alarm) are gated behind entitlement via ServicioAnuncios, using
official Google test ad unit IDs pending AdMob provisioning.
- Alarm cap UX shows an explanatory message with a secondary unlock
action rather than a bare paywall jump; existing data is grandfathered.
- 4 new localization keys translated across all 13 supported locales.
Co-located tests use strict TDD (RED test before implementation) for
every new pure-logic unit; full existing suite passes unchanged.
Insert PantallaTutorialAyuda.mostrarSiProcede between the welcome
screen and the recurring what's-new dialog in
_mostrarFlujoPrimerLanzamiento, so the carousel shows once on every
install -- fresh AND existing installs upgrading to this version --
via its own independent one-time flag, without racing either
surface.
Item 22 / audit 3.6 (t4:184-188): the mini player was a floating
999-radius glass pill with no artwork. Replace it with a 60px opaque
bar (listSurface at .97 alpha), full-bleed edge to edge, showing the
station's square 42x42 artwork instead of the abstract playing-bars
indicator. app.dart no longer wraps the bar in the balloon nav's own
8px side margin, so it now spans the full width.
MiniReproductor.altura is re-measured (72 -> 60) now that the bar's
content height is fixed by construction; PluriLayout.bottomChromeInset
derives from it as before. Both the S3-R3 configurarLocalizaciones
guard and the altura measurement test still pass unmodified.
The prototype (t4) draws no global app bar anywhere: every root paints
a plain ~56px title row inside its own content instead (Alarmas line
325, Ajustes line 511, Explorar line 641). app.dart wrapped every tab
in PluriWaveScaffold(appBar: AppBar(title: Text(appTitle), ...)),
adding 56dp of chrome and a "PluriWave" title the prototype never
shows.
Add PluriRootHeader, a shared 56px title-row widget reused by all 5
roots. Extract app.dart's old _mostrarTimerDialog (only reachable from
the removed AppBar action) into a free function,
showPluriSleepTimerSheet, so every root's header can open the same
sheet directly and the sleep-timer feature stays reachable from every
tab with no behaviour change.
S1, Tier 1 visual-fidelity pass (audit id 2521).
Restructures PantallaInicio's top of screen: a new _EscucharHero
(square art, live/offline pill, VisualizadorAudio at barras: 30 /
altura: 26 / color: liveGreen, a 5-action transport row - favorite,
EQ toggle, stop, play/pause, sleep - plus a tool-tray entry chip
opening the full player) replaces the old PluriScreenHeader hero, and
a new "Tus emisoras" section (favorites preview, capped, "Ver todas")
follows it. Per design ADR-7, EstadoRadio stays the single source of
truth: the hero is a StatelessWidget with no cached fields, reading
emisoraActual via context.select (uuid-based equality scopes rebuilds
to real station changes) and the fast-changing playback status via
StreamBuilder, the same pattern _Controles/MiniReproductor already
use. The still-present discovery sections (_seccionCercanas onward,
including the old grid) are deliberately left in place - WU6
relocates them to Buscar and deletes them from here; removing them
now would leave that content nowhere until WU6 lands.
MiniReproductor gains a `visible` parameter (default true) and a
measured `static const double altura`. app.dart passes
`visible: indice != RaizPluriWave.escuchar.index`, hiding it visually
only (SizedBox.shrink()) while Escuchar is active, since the hero
already shows the same station - the State stays mounted so its
didChangeDependencies side effect (configurarLocalizaciones, S3-R3)
keeps running regardless of tab. altura was measured empirically
(72.0, via tester.getSize) rather than guessed, backing a new derived
PluriLayout.escucharBottomChromeInset constant now wired into
PantallaInicio's own bottom padding.
"Ver todas" switches roots via EstadoNavegacionRaiz.irA(favoritos),
verified via a NavigatorObserver asserting the push count is
unchanged (switches tabs, does not push).
Fixed a pre-existing test-infrastructure gap while writing the
anti-cache test: no test in this codebase had ever exercised
ServicioAudio.androidAudioSessionIdStream against a bare
FakeServicioAudio (pantalla_reproductor.dart has always read it but
has no test file at all) - the real getter needs registrarHandler()
(main.dart, production only) and threw otherwise. Added an empty
stream override to FakeServicioAudio, matching VisualizadorAudio's
own documented no-native-session fallback.
Tests: 614 -> 618 (2 skipped, unchanged). flutter analyze unchanged
at 1 pre-existing info. git diff empty for visualizador_audio.dart
and estado_radio.dart - this WU touches neither.
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.
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.
PantallaAlarmaSonando no longer owns any audio orchestration (fallback
player, dB ramp, native handoff confirm, media-volume override/restore):
it only calls EstadoAlarmas.finalizarEjecucion/posponerAlarma from
Stop/Snooze/back, keeping the single-exit guard, PopScope back=Stop and
dismiss semantics intact. The status line now reads directly from the
alarm's static config (station name or a neutral label) instead of a
live playback/handoff state.
PuertoAlarmasAndroid drops confirmarAudioFlutter,
forzarVolumenMediaParaAlarma and restaurarVolumenMedia, and app.dart no
longer pre-starts a station before pushing the ring screen. This is the
Dart half of moving to a single native ring-audio owner (WU1 of 2); the
Kotlin service rebuild lands next and keeps this intermediate state
shippable with no double audio.
On-device logcat from the latest test showed two defects the previous
design created. The fade-in was gated on the station reaching
`reproduciendo`, and the stream took 18.7 seconds to buffer: the ring
sat frozen at 5% the whole time and the configured fade seconds only
started counting afterwards. And the stream override was raised during
pre-start, so the ExoPlayer AudioTrack spin-up — which runs at gain 1.0
for an instant before the player gain lands — blasted at the configured
ring level, heard as "starts directly at the alarm volume".
The ramp is now anchored at alarm time: it starts when the screen
starts, buffering just joins it at the elapsed level, and the fade
duration means seconds-from-alarm. _iniciarFadeIn is single-start so
the handoff confirmation and fallback paths can no longer restart an
in-progress ramp from 5%. The stream override moved from the app-side
pre-start into the screen and is raised only when audio is actually
about to flow (first `reproduciendo`, the already-playing branch, or
right before the fallback WAV plays), so track spin-up happens under
the user's original low volume and the blast is physically impossible.
Exit teardown restores the device stream before resetting the player
gain, removing the brief exit blip seen in the capture.
The ring-scoped media override forced STREAM_MUSIC to the hardware
maximum, so the alarm's configured percentage was applied on top of a
maxed speaker: "50%" meant 50% of the phone's absolute maximum and
the fade rode against that ceiling, far louder than the device-
relative level users were used to. On-device logcat also showed the
just_audio player emitting one buffer at volume 1.0 before the 5%
pre-start took effect, a full-scale click on the maxed stream.
The stream is now capped at the alarm's configured volume (still
independent of the device's own level, so it rings at device-volume
0), and the player ramps from ~5% up to its full range under that
cap. Perceived peak is the configured fraction of the device maximum,
reached gradually; the opening click drops to the configured fraction
instead of full scale.
The live eventosAlarma stream and the one-shot obtenerEventoInicial()
both read the same native fire event on cold start, so the same alarm
id can reach _mostrarAlarmaSonando twice within the same tick. The
second, duplicate delivery correctly detected an alarm was already
active and hit the "ignored" branch — but that branch unconditionally
called ocultarNotificacionAlarma, whose native handler
(dismissAlarmNotification) unconditionally stops
PluriWaveAlarmService for that id.
Confirmed via on-device logcat: the duplicate's stop landed ~180ms
after the ring-scoped media-volume override was captured and ~2.3s
before the real native-to-Flutter handoff, so flutterOwnsRing was
still false and the teardown backstop restored the device's original
volume immediately. The Flutter/radio player kept ringing regardless
(it starts independently of the native service), now anchored to
whatever volume the device happened to be at — explaining both
"ignores the configured ramp" and "plays at the device's own volume."
The ignored branch now only hides the notification when the duplicate
carries a genuinely different alarm id than the one already ringing;
a duplicate of the SAME ring's own event is now a pure no-op.
The alarm's steady-state audio runs on the Flutter media-stream
player after the native handoff, so device volume 0 silenced it
entirely. The ring now forces STREAM_MUSIC to an audible reference:
Dart requests the override before pre-starting alarm audio (fallback
WAV included), Kotlin captures the current volume once and restores
it idempotently on every exit path (dismiss, snooze, dispose), with
a native best-effort backstop in service teardown.
The backstop is handoff-aware via PluriWaveAlarmService.flutterOwnsRing:
confirmFlutterAudio marks the handoff before triggering the native
stop, so the backstop cannot restore the volume mid-ring right as the
Flutter player takes over (that would re-silence the alarm at volume
0). The flag resets at every ring start; Flutter process death after
handoff remains a documented best-effort gap.
The alarm's perceived loudness keeps ramping 5% to the configured
volume through the player as before; normal radio playback and call
ducking never touch the override.
Work unit 2/3 of alarm-volume-ramp-restore (ring volume override).
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.
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)
- Updated Japanese, Portuguese, Russian, and Chinese localization files with new strings for search and alarm functionalities.
- Enhanced the search screen with localized titles, subtitles, and filter labels.
- Integrated localization into the alarm screen, including actions and messages related to alarm management.
- Refactored country and language lists to use localized keys for better maintainability.
- Improved user experience by providing localized hints and messages throughout the application.
- ServicioAudio: delega a PluriWaveAudioHandler (audio_service) para
mantener audio vivo en background. AudioService.init() en main.dart.
onTaskRemoved() libera player. mediaItem con nombre/artista/artwork.
- ServicioRadio: lastcheckok=1 en todas las peticiones — solo emisoras
verificadas como funcionales por Radio Browser API.
- EstadoRadio: errorStream (broadcast) para errores de reproducción y
búsqueda. App.dart suscribe y muestra SnackBar flotante 3s.
Los errores de carga de lista siguen como banner inline.
- Icono: generado con SDXL (morado, ondas radio blancas, Material You).
5 densidades Android (48-192px), ic_launcher_round añadido.