409 Commits
Author SHA1 Message Date
ShanaiaBot 8c4f15528c chore: bump version to 1.3.4+162 [ci skip] 2026-09-06 00:31:48 +02:00
FreeTLab cbc54e915b fix(eq): entregar los decibelios que pide el usuario, sin estirarlos
Build & Deploy PluriWave / Análisis de código (push) Successful in 29s
Build & Deploy PluriWave / Build APK + AAB release (push) Successful in 2m28s
La app promete decibelios en cuatro sitios y no los entregaba en ninguno. El
slider abarca un +/-12 fijo, escribe el numero con su unidad debajo de cada
banda, y `equalizerBandValue` le dice literalmente "decibelios" a TalkBack. El
modelo documenta las bandas como dB y los presets de fabrica estan escritos en
dB. just_audio documenta `setGain` en decibelios y multiplica por 1000 para
llegar a milibelios sin normalizar nada: `minDecibels`/`maxDecibels` son la
CAPACIDAD del dispositivo, no una escala a la que normalizar.

Pese a eso, la ganancia se estiraba por `maxDecibels/12`. Un +6 dB llegaba como
+10 en un movil de rango ancho.

El estiramiento nunca fue una decision de diseño

Antes de a9202c6 el codigo era `setGain(preset.bandas[i])`, decibelios
literales. Ese commit metio la normalizacion sin docstring, sin test y sin nota
de diseño, y traia un fallo: 0 dB caia en el punto medio del rango, asi que un
preset PLANO realzaba. 3449e2c corrigio exactamente eso y nada mas -- su propio
mensaje dice que el objetivo era "0 dB es siempre 0" -- heredando el
estiramiento sin discutirlo. No hay ADR, spec ni comentario que lo justifique.

Lo que de verdad rompia: la portabilidad

Lo que se persiste y se exporta son los dB del usuario, sin escalar; el escalado
ocurre solo al escribir en el efecto nativo. Asi que el mismo backup suena
distinto en cada telefono, y la interfaz informa de una restauracion perfecta
mientras el audio no lo es. Peor con el rango asimetrico habitual de Android
([-12, +19]): los realces se multiplican por 1.58 y los cortes por 1.0, de modo
que el preset no solo sube de nivel, CAMBIA DE FORMA. Jazz [3, -1, -1.5, 2, 4]
se entregaba como [4.75, -1, -1.5, 3.17, 6.33]. Con presets por dispositivo, el
mismo preset se deformaba distinto en el altavoz y en el Bluetooth.

No es un clamp a secas

`db.clamp(minDecibels, maxDecibels)` habria reintroducido el fallo de 3449e2c:
en un dispositivo que reporte [+3, +19], el cero se convierte en +3 y el preset
plano vuelve a realzar. La ventana se fuerza a contener el cero, asi que se
conservan todas las invariantes ganadas -- 0 siempre es 0, el signo nunca se
invierte, el resultado nunca escapa del rango nativo, un dispositivo sin margen
en un lado no puede realzar por ese lado -- y solo desaparece el estiramiento.

Que se oye distinto: en un movil de +/-12 dB, identico a hoy. En uno de rango
ancho los realces bajan, hasta un 40% menos en dB en uno de +/-20. Los cortes
apenas se mueven, porque la forma habitual es [-12, +N] y el lado negativo ya
iba practicamente 1:1. A cambio, los seis presets de fabrica suenan por fin
igual en cualquier telefono.

Ningun preset guardado necesita migracion: lo almacenado siempre fueron los dB
del usuario.

Suite completa: 1534 pasan, 2 omitidos. flutter analyze mantiene los 5 avisos
preexistentes.
2026-09-06 00:10:58 +02:00
FreeTLab 86dd20b184 fix: el coche recuerda la ultima emisora y deja de publicar una sesion fantasma
Tres defectos preexistentes alrededor de la reanudacion en Android Auto. Ninguno
es una regresion: el consumidor (la raiz `recent`) se añadio en septiembre y es
lo que dejo el hueco a la vista.

La ultima emisora solo la escribia el telefono

La clave `ultima_emisora_v1` tenia como unico escritor a
`EstadoRadio._persistirUltimaEmisora`, y `EstadoRadio` solo existe si hay arbol
de widgets. El motor que arranca Android Auto es headless de verdad, asi que una
sesion que ocurriera solo en el coche jamas actualizaba la clave y al reconectar
se ofrecia la emisora de la ultima vez que se uso el movil.

El handler recibe ahora sus puertos de lectura y escritura, con la misma forma
que los del ecualizador y el contexto de salto, y escribe desde `_cambiarFuente`:
el cuello de botella por el que pasan todas las rutas -- telefono, toque en el
coche, voz, saltos, avance de cola y la propia reanudacion.

Se ELIMINA el escritor del telefono en vez de sumar un segundo. Dos escritores
independientes de la misma clave acaban divergiendo siempre; es exactamente lo
que ya costo varias rondas con el flag del ecualizador.

Las pistas locales quedan excluidas: un `content://` guardado como ultima
emisora seria una fila de reanudacion que no resuelve a nada.

play() sin fuente levantaba un servicio en primer plano vacio

just_audio publica `playing:true` antes de comprobar si hay fuente, asi que un
`play()` en frio no tocaba la plataforma pero si emitia ese estado sobre
`processingState: idle`. audio_service entraba en estado de reproduccion
mientras el estado nativo seguia en NONE: notificacion con boton de pausa, cero
audio, sin titulo ni caratula, y un Future que no se completaba nunca. El coche
enruta su tecla de play directamente ahi.

Ahora `play()` sin fuente abierta restaura la ultima emisora por la ruta normal,
y si no hay nada que restaurar no toca el reproductor ni publica nada.

En frio no habia metadatos que enseñar

El unico `mediaItem.add` util vivia dentro de `_cambiarFuente`, asi que en un
motor recien arrancado el lado nativo nunca recibia metadatos. Se siembra el
`mediaItem` de la emisora persistida sin cargar ni reproducir nada, con guarda
antes y despues de la lectura de disco para no pisar una emisora ya sonando.

`getMediaItem` resolvia solo contra el universo completo -- vacio en el motor del
coche -- mientras `porUuid` si caia en las destacadas. El coche podia navegar una
emisora destacada y luego no resolver su ficha. Ambos usan ahora la misma ruta.

Suite completa: 1529 pasan, 2 omitidos. flutter analyze mantiene los 5 avisos
preexistentes.
2026-09-06 00:10:58 +02:00
FreeTLab 6b91ad88e8 fix: el ecualizador del coche aplica el preset real y en el orden correcto
Reportado desde el uso real: desde el movil el ecualizador va bien, pero el
boton de Android Auto a veces no hace nada y a veces suena como si se aplicara
una doble ecualizacion.

El preset del handler nunca se sembraba desde disco

`_presetActual` arrancaba en `flat` a fuego. registrarHandler sembraba el flag
de encendido pero no el preset, asi que en un motor donde la interfaz del
telefono nunca corrio -- el que arranca Android Auto -- el toggle del coche
aplicaba `flat`, o lo que hubiera quedado, en vez del preset del usuario. Es la
misma clase de fallo que ya se corrigio para el flag: aquel recibio un puerto
headless y el preset se quedo fuera. Ahora tiene el suyo, con la misma forma:
opcional, el fallo se traza y cae al valor por defecto, nunca propaga.

La siembra respeta un preset ya elegido por EstadoEcualizador, que es mas rico
que la clave principal, para que la lectura de disco en vuelo no lo pise.

El efecto se habilitaba antes de escribir las ganancias

La ruta era setEnabled -> setEnabled -> ganancias: `aplicarPreset` volvia a
llamar a setEnabled por su cuenta. Entre la habilitacion y la escritura sonaban
las ganancias anteriores, y ese hueco es lo que se percibia como doble
ecualizacion. Ahora una funcion pura devuelve los pasos en orden y ambas rutas
la recorren: ganancias primero, habilitacion despues.

Las ganancias NO se resetean al apagar, y es deliberado: setEnabled(false)
puentea el efecto sin liberarlo ni limpiar sus niveles, y la ruta de encendido
los reescribe enteros antes de habilitar, asi que no queda ninguna ventana de
ganancia rancia que un reset pudiera cerrar.

El boton desaparecia en cada cambio de emisora

`_recrearPlayer` bajaba `_eqDisponible` sin republicar controles, asi que cada
cambio de emisora emitia al menos un estado sin la accion de EQ. Peor: las
llamadas nativas estan detras de ese flag, de modo que un toggle en esa ventana
cambiaba el icono sin tocar el audio. Ahora el unico que lo escribe es
`_activarEcualizador`.

Mantenerlo optimista exigia quitar de la ruta del toggle el `await
_eq.parameters`, que es un Completer que solo se completa cuando el reproductor
se engancha: esperarlo dejaba el boton pendiente durante toda la carga, y para
siempre si la carga fallaba. Se cachean los parametros al activarse.

Los fallos nativos dejan de ser mudos

El `catch (_) {}` ocultaba que la llamada nativa habia fallado y dejaba el icono
afirmando un estado que el audio no tenia. Ahora se traza, y un fallo al
habilitar revierte el flag, republica los controles y no persiste.

EstadoEcualizador adopta lo que el motor acepto en vez de asumir que su peticion
prospero: sin eso, el telefono escribia en disco un valor que el handler acababa
de rechazar, reabriendo la divergencia que el dueño unico habia cerrado.

Suite completa: 1515 pasan, 2 omitidos. flutter analyze mantiene los 5 avisos
preexistentes.
2026-09-06 00:10:58 +02:00
ShanaiaBot 05f70af7f1 chore: bump version to 1.3.3+161 [ci skip] 2026-09-04 13:46:34 +02:00
FreeTLab c30bbacbbc ci: no fallar la compilacion cuando falta el secreto de Google Play [version set]
Build & Deploy PluriWave / Análisis de código (push) Successful in 26s
Build & Deploy PluriWave / Build APK + AAB release (push) Successful in 2m13s
El paso que prepara las credenciales hacia `exit 1` si no encontraba
GOOGLE_PLAY_SERVICE_ACCOUNT_JSON, y ese secreto no se ha configurado nunca: TODA
compilacion de PRO terminaba en rojo por una automatizacion que jamas llego a
activarse, mientras los AAB se subian a Play a mano. Un rojo permanente entrena
a ignorar los rojos, y entonces el dia que falle algo de verdad tampoco se mira.

Ahora el paso se omite con un aviso y expone `disponible`, del que dependen los
dos pasos siguientes. No se pierde nada: el AAB ya esta compilado, firmado y
subido a ftl-builds antes de llegar aqui. El dia que se configure el secreto,
los tres pasos se reactivan solos sin tocar el workflow.

El aviso de Telegram deja de afirmar "Publicado en Google Play" cuando la subida
se ha omitido. Un mensaje que dice que se publico algo que no se publico es peor
que no mandar mensaje.
2026-09-04 13:45:41 +02:00
ShanaiaBot 192a3aca0e chore: bump version to 1.3.3+160 [ci skip] 2026-09-04 13:33:58 +02:00
FreeTLab ab3554b746 chore(release): alinear PRO con la version probada 1.3.3 [version set]
Build & Deploy PluriWave / Análisis de código (push) Successful in 30s
Build & Deploy PluriWave / Build APK + AAB release (push) Failing after 3m12s
PRO venia arrastrando su propia linea de version (1.3.1) mientras main iba por
1.3.3, asi que el mismo codigo tenia dos numeros segun la rama. Lo que se ha
probado en el coche es 1.3.3+160, y ese es el numero que deben ver los testers:
cuando alguien reporte un fallo, la version que diga tiene que coincidir con la
que se valido.

Se fija 1.3.3+159 porque el CI incrementa el numero de build ANTES de compilar,
de modo que el artefacto publicado sale como 1.3.3+160. El marcador
[version set] impide que el paso de bump suba tambien el patch, que es el
comportamiento por defecto en PRO.

El codigo de version 160 esta libre en Play: lo mas alto subido alli es 157, y
los 158, 159 y 160 de main nunca salieron del portal de builds.
2026-09-04 13:33:05 +02:00
FreeTLab 8a71bc237f feat: restaurar los grupos de favoritos al importar y recordar la lista del coche
Dos fallos reportados desde el uso real en el coche.

Los grupos de favoritos no volvian al importar

La exportacion nunca estuvo rota: los grupos viajaban desde siempre y la
asignacion de cada emisora va dentro de cada favorito como grupo_id. El fallo
estaba solo al importar. La restauracion reutilizaba ServicioFavoritos.agregar,
que fuerza "sin asignar" a proposito, porque una emisora recien marcada como
favorita no tiene grupo. Correcto para esa ruta, destructivo como primitiva de
restauracion: los grupos volvian vacios y todo aterrizaba en Sin asignar. Se
separa la ruta de restaurar, que respeta el grupo_id del backup y su orden.

Ningun test lo detectaba porque el doble de pruebas era infiel: el fake
conservaba el grupo que la implementacion real destruia, y no tenia
restaurarGrupo, asi que esa ruta estaba sin cubrir. Los tests existentes lo
esquivaban pasando siempre una lista de grupos vacia. Se corrige el doble.

No hace falta subir la version del formato: el envoltorio ya llevaba todo.

El coche perdia la lista al reconectar

El contexto de reproduccion vivia solo en memoria, y el motor que arranca
Android Auto es un proceso nuevo sin interfaz ni EstadoRadio, asi que al
reconectar se reproducia la ultima emisora sin saber a que lista pertenecia y
siguiente/anterior no hacian nada hasta entrar a favoritos a mano. Ahora se
persiste el TIPO de contexto y, cuando aplica, el id del grupo, y se resuelve
contra las listas vivas en cada salto: si el grupo cambia de contenido entre
sesiones, el coche ve lo actual y no una foto vieja.

Cadena de repliegue, decidida por el propietario:
  grupo vivo con la emisora dentro  -> se recorre el grupo
  grupo vivo sin la emisora         -> se permanece en el grupo, primera
  grupo vivo pero vacio             -> se ensancha a favoritos
  grupo borrado                     -> favoritos
  sin favoritos                     -> comportamiento actual

Honrar un grupo de una sola emisora exigia levantar dos barreras, no una:
el resolutor y el propio _saltarEmisora, que se negaba a nombrar un grupo con
menos de dos miembros. emisoraVecina queda intacta: su contrato de no saltar a
ciegas es deliberado y se usa desde mas sitios, asi que el caso de "la emisora
se salio del grupo" se trata en el flujo del salto.

La puerta de entitlement no cambia: un conductor sin premium sigue recorriendo
solo el conjunto gratuito, y un contexto congelado mientras pagaba se descarta
en vez de recorrerse.

Suite completa: 1501 pasan, 2 omitidos. flutter analyze mantiene los 5 avisos
preexistentes.
2026-09-04 13:26:00 +02:00
FreeTLab 8e155cc0ac fix: cumplir las guias de calidad de Android Auto y localizar el arbol del coche
Google Play devolvio "Approved with Issues" en el codigo 157: "clicking on
stop button makes the entire app useless", citado contra las Android for Cars
App Quality Guidelines. La causa no era el boton de parar.

Maquina de estados del transporte

_cambiarFuente publicaba mediaItem y loading ANTES de su primer await y solo
comprobaba su revision despues de que _recrearPlayer retornase. Los cambios de
fuente se encolan incrementando la revision al encolar, no al ejecutar, asi
que tocar una emisora, tocar otra antes de que cargue y pulsar Stop dejaba que
las entradas obsoletas reescribieran loading sobre el idle que stop() acababa
de publicar. Estado final: loading para siempre sobre una sesion que
audio_service ya habia desactivado. Ahora la guarda de revision es la primera
sentencia del metodo.

pause() no invalidaba una carga en vuelo, asi que la emisora arrancaba igual
despues de pulsar pausa; se revalida la intencion antes de llamar a play().
Se anade un suelo de estado que cierra cualquier loading o buffering sin carga
viva, exento cuando el reproductor ya entrego audio y solo esta rebufferando,
para no convertir un tunel en un error. El presupuesto hasta el primer mensaje
baja a menos de diez segundos y los reintentos ya no borran el mensaje visible.

Tier gratuito en el coche

El arbol devolvia una unica fila no reproducible para cualquier carpeta cuando
no habia premium, y un revisor con instalacion limpia siempre es tier
gratuito. Ademas skipToNext, skipToPrevious, playFromSearch y playFromMediaId
retornaban en silencio. La raiz gratuita pasa a ofrecer una sola carpeta con
emisoras reales y reproducibles, compiladas en el binario para que existan en
frio, y la puerta de entitlement acota contenido en vez de bloquear acciones.
Se elimina la fila "Funcion Premium". Una consulta de voz vacia arranca la
ultima emisora, que fallaba tambien a los clientes de pago.

Localizacion

El locale del handler solo lo fijaba un widget que el motor headless nunca
construye, asi que todo error del coche salia en castellano. Se resuelve desde
el locale de plataforma. Se traducen las once etiquetas del arbol que estaban
a fuego y se retira la convencion que lo justificaba. Un test nuevo falla si
vuelve a aparecer texto visible fuera del sistema de traduccion.

Suite completa: 1455 pasan, 2 omitidos. Los mecanismos se verificaron por
mutacion: borrar cada uno pone la suite en rojo. flutter analyze mantiene los
5 avisos preexistentes.
2026-09-04 13:26:00 +02:00
FreeTLab 575ba793ae fix: corregir ecualizador desincronizado, musica local en Android Auto y bloqueo del paywall
Tres fallos reportados en uso real, con sus causas raiz verificadas en codigo.

1. Ecualizador: el estado no tenia dueño unico

El handler arrancaba con `_ecualizadorActivo = true` a fuego. El valor
persistido solo llegaba por EstadoEcualizador.cargarPersistido(), alcanzable
unicamente desde el arbol de widgets, que un arranque headless de Android Auto
nunca construye. Resultado: el coche reproducia con el EQ forzado a ON mientras
disco e interfaz decian OFF.

Ahora registrarHandler siembra el flag desde disco en todos los motores y
setEcualizadorActivo persiste por su cuenta, asi que un toggle desde el coche o
la notificacion sobrevive sin EstadoEcualizador. _resincronizarConHandler pasa
a ser adopcion pura de interfaz.

Ademas mapearGananciaNativa enviaba 0 dB al punto MEDIO del rango nativo. Con
un getBandLevelRange() asimetrico, un preset plano metia varios dB de boost
real: la causa del "suena muy alto con el boton apagado". Reescrito para
escalar cada lado contra su propio extremo, de modo que 0 dB es siempre 0.

El dispatch de customAction no tenia ningun test. Se extrae decidirToggleEq y
se cubre contra el handler real. El efecto nativo se re-asierta al reactivarse
el reproductor, porque AudioEffect.setEnabled de just_audio es un no-op
mientras la plataforma esta desacoplada.

2. Musica Local no aparecia en el arbol de Android Auto

hayCarpetaConfigurada() consultaba MethodChannel('pluriwave/file_actions'),
registrado solo en MainActivity.configureFlutterEngine. Sin Activity no hay
handler, invokeMethod lanza MissingPluginException y el catch la confundia con
"permiso revocado", omitiendo el nodo. No dependia del entitlement.

La logica SAF sale a packages/pluriwave_file_actions, un paquete plugin local.
El motor headless que crea audio_service ejecuta GeneratedPluginRegistrant en
su constructor, asi que el canal queda registrado en ambos motores. Repuntar el
manifest a una subclase de AudioService no era viable: AudioServicePlugin
enlaza por ComponentName explicito y la app perderia el audio.

EstadoCarpetaLocal de tres valores separa "sin carpeta" de "canal no
disponible"; la raiz decide por la URI persistida y el subarbol muestra un item
explicativo en vez de una carpeta vacia. La invalidacion del arbol cacheado se
dispara al reanudar con el coche ya suscrito; el guardia anterior miraba
View.maybeOf, que bajo runApp siempre existe, por lo que se gastaba en el
arranque headless y no volvia a dispararse.

3. El paywall bloqueaba las compras

restorePurchases() de in_app_purchase_android emite siempre, y con lista vacia
si no hay nada que restaurar. El `if (compras.isEmpty) return;` se la tragaba,
noEncontrada nunca se emitia y la rama que limpia _compraEnCurso estaba muerta
en produccion. Como comprar y restaurar comparten ese flag, un usuario sin
compras que pulsaba restaurar se quedaba sin poder comprar.

Suite completa: 1366 pasan, 2 omitidos. 17 tests nuevos, todos nacidos rojos y
verificados por mutacion. flutter analyze mantiene los 5 avisos preexistentes.
2026-09-04 13:26:00 +02:00
ShanaiaBot a5572d2cbd chore: bump version to 1.3.1+157 [ci skip] 2026-08-28 23:53:03 +02:00
FreeTLab 98b24d84cd Merge branch 'PRO' of https://git.freetimelab.es/FreeTLab/pluriwave into PRO
Build & Deploy PluriWave / Análisis de código (push) Successful in 25s
Build & Deploy PluriWave / Build APK + AAB release (push) Failing after 19s
2026-08-28 23:52:27 +02:00
FreeTLab 72c5777508 ci: name build artifacts by branch and version code [version set]
Every build of a given semver was published as `pluriwave-v1.3.0.aab` into
the same folder, so main and PRO overwrote each other and three different
builds became indistinguishable once downloaded — the browser saves them as
"(1)", "(2)" and the version code is only visible by unzipping the bundle.

That cost two rejected uploads to Play Console for reusing a version code.
Artifacts are now `pluriwave-<branch>-v<semver>+<build>.<ext>", which
identifies itself weeks later and outside this repo.
2026-08-28 23:52:16 +02:00
ShanaiaBot b69041f32a chore: bump version to 1.3.0+156 [ci skip] 2026-08-28 23:40:45 +02:00
FreeTLab 9681a47e83 merge: alarm-import recovery, dismissible paywall and complete config export [version set]
Build & Deploy PluriWave / Análisis de código (push) Successful in 25s
Build & Deploy PluriWave / Build APK + AAB release (push) Failing after 2m1s
Same three fixes already merged to main (e57f7bb, a2bed18, 4ca2813):
alarms actually come back after a backup import and get re-scheduled
natively, the premium sheet can be dismissed, and the equalizer on/off
toggle finally travels with the backup.

[version set] keeps the 1.3.0 release name; CI advances the build number.

# Conflicts:
#	pubspec.yaml
2026-08-28 23:38:15 +02:00
FreeTLab 4ca2813267 feat(ecualizador): include equalizer on/off toggle in export/import
The backup envelope carried favorites, EQ presets, alarms and the
multi-device toggle but not EstadoEcualizador's own on/off flag, so
restoring a backup on another device silently kept that device's
existing toggle state instead of the source device's.

Bumps the backup schema to v4 (additive over v3): the flag is only
written when explicitly provided, so old exports stay at v2/v3.
Importing an old backup without the field leaves the current toggle
untouched rather than defaulting it. Applying the imported value
reuses EstadoEcualizador.cambiarActivo so it persists and pushes to
the live audio engine exactly like a manual toggle.
2026-08-28 23:07:01 +02:00
FreeTLab a2bed18937 fix(paywall): add dismiss controls and honest premium copy
The premium sheet had no close affordance or way to defer, and its
copy only said "Función Premium" without stating what it unlocks.
Adds a header close (X) button and a "not now" secondary action so
dismissal is never harder than purchasing, and replaces the bare
title with a concrete, honest breakdown of the 5 things premium
unlocks (no ads, Android Auto, station recording, alarm vacation
ranges, unlimited alarms) plus the one-time-purchase framing. The
phone equalizer is never listed, since it stays free for everyone.
New l10n keys added to all 13 locales.
2026-08-28 22:47:53 +02:00
FreeTLab e57f7bb17b fix(alarmas): reload and re-sync alarms after backup import
Importing a backup wrote the alarm/vacation/exception block straight to
SharedPreferences but never told EstadoAlarmas about it, so the UI kept
showing the pre-import alarms, a later edit could persist that stale
state back over the imported one, and imported alarms were never
(re)scheduled with the Android native layer. The backup screen now
calls EstadoAlarmas.cargarPersistidasSinRecalcular() followed by
refrescarProgramacion() after a successful import, extracted into a
directly-testable aplicarImportacionConfig() function.
2026-08-28 22:47:38 +02:00
ShanaiaBot fdddd95199 chore: bump version to 1.3.2+155 [ci skip] 2026-08-28 20:07:12 +02:00
FreeTLab 1bfd5a2348 Merge branch 'main' of https://git.freetimelab.es/FreeTLab/pluriwave
Build & Deploy PluriWave / Análisis de código (push) Successful in 25s
Build & Deploy PluriWave / Build APK + AAB release (push) Successful in 2m54s
2026-08-28 20:06:20 +02:00
FreeTLab 4ea5d2056c test(alarmas): anchor the vacation pill test on a relative future range
The test hardcoded 4-18 August 2026, which was in the future when it was
written and is now in the past. The pill only renders for the active or
next range, so the assertion started failing purely because the calendar
moved on — the production code was never wrong.

Anchor the range on next month (days 4-18, so it never straddles a month
boundary) and assert against rangoFechasCorto, the same pure formatter the
widget uses, so the test checks that the pill is rendered rather than
restating the formatter's own output.
2026-08-28 20:06:10 +02:00
ShanaiaBot b5940b2758 chore: bump version to 1.3.1+154 [ci skip] 2026-08-28 20:02:32 +02:00
ShanaiaBot 524b8f0035 chore: bump version to 1.3.0+154 [ci skip] 2026-08-28 19:59:13 +02:00
FreeTLab 9efa6d8937 merge: bring the freemium/IAP release line into main
Build & Deploy PluriWave / Análisis de código (push) Successful in 25s
Build & Deploy PluriWave / Build APK + AAB release (push) Successful in 2m13s
main had drifted behind PRO by the whole 1.3.0 line: the freemium/IAP
feature, the code-review fixes, the real AdMob ids, the equalizer
cross-surface resync and the closed-testing ad switch all shipped through
PRO only. This reconciles main so day-to-day work no longer branches from
a stale base.

# Conflicts:
#	pubspec.yaml
2026-08-28 19:59:12 +02:00
FreeTLab 55fe50d07d merge: equalizer cross-surface sync + test ads for the closed-testing phase [version set]
Build & Deploy PluriWave / Análisis de código (push) Successful in 24s
Build & Deploy PluriWave / Build APK + AAB release (push) Failing after 3m9s
Brings in two independent fixes that both need to reach testers:
- e9f47d4 resyncs EstadoEcualizador with car/notification-initiated changes
  and closes the persistence gap that lost them on restart.
- 2e15d05 forces Google test ad units in release builds while
  usarAnunciosDePruebaEnRelease is true, so no tester can generate invalid
  traffic against the AdMob account during closed testing.

[version set] keeps the 1.3.0 name; CI advances the build number.
2026-08-28 19:56:26 +02:00
FreeTLab 2e15d05431 fix(ads): force test ad units in release during closed testing
Closed-testing human testers cannot be registered as AdMob test
devices, so release builds serving real ad units risked invalid
traffic against an AdMob account that currently earns essentially
nothing. Add usarAnunciosDePruebaEnRelease, a single boolean switch
defaulted to true, that keeps bannerAdUnitId/interstitialAdUnitId on
Google's official test ids even in kReleaseMode. Flipping it to false
is the only change needed to go live. AndroidManifest's AdMob
application id is untouched, as it only initializes the SDK.
2026-08-28 19:49:12 +02:00
FreeTLab e9f47d47c2 fix(eq): resync EstadoEcualizador with car/notification-initiated changes
A toggle from the Android Auto notification or a preset picked from the
car's EQ folder mutated PluriWaveAudioHandler state directly, leaving
EstadoEcualizador (and therefore the phone UI) unaware and never
persisting the change, so it was lost on the next app restart.

Forward the handler's ecualizadorActivo flag through ServicioAudio and,
mirroring EstadoRadio's existing playFromMediaId resync, diff it plus
presetActual against the cached values on every estadoStream tick,
adopting and persisting a divergence via ServicioEcualizador.
2026-08-28 17:48:52 +02:00
ShanaiaBot 080d342de0 chore: bump version to 1.3.0+153 [ci skip] 2026-08-28 15:41:41 +02:00
FreeTLab 689f3e8123 chore(release): rebuild to get a fresh version code [version set]
Build & Deploy PluriWave / Análisis de código (push) Successful in 27s
Build & Deploy PluriWave / Build APK + AAB release (push) Failing after 4m4s
Play Console already holds version code 152 (name 1.3.0, uploaded 16 Aug),
so the bundle this pipeline produced was rejected as a duplicate code.

This empty commit re-triggers the build. The bump step advances the code to
153 (the first free one) while [version set] keeps the 1.3.0 version name,
which is the release name this freemium/IAP work is shipping under.
2026-08-28 15:23:13 +02:00
ShanaiaBot 70ee13d540 chore: bump version to 1.3.0+152 [ci skip] 2026-08-16 00:13:12 +02:00
FreeTLab 9cfa5ac17d fix(iap): address code review defects in freemium/IAP change
Build & Deploy PluriWave / Análisis de código (push) Successful in 42s
Build & Deploy PluriWave / Build APK + AAB release (push) Failing after 4m23s
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]
2026-08-12 16:10:46 +02:00
FreeTLab 94f354a7c1 feat(iap): wire real AdMob app id, banner and interstitial units
App id always uses the real value (SDK init only, no ad-serving risk).
Banner/interstitial pick the real unit id in release builds and Google's
test unit id everywhere else, so debug/profile builds can never serve
(or accidentally tap) a real ad.
2026-08-12 12:53:34 +02:00
FreeTLab d81fabbe27 refactor(iap): make esPremium a required constructor parameter
EstadoAlarmas, EstadoGrabacion and EstadoRadio defaulted `esPremium` to
`() => true`, so any construction site that forgot to wire entitlement
compiled fine and silently ran ungated — failing OPEN to premium and
disabling the paywall with no test able to catch it.

The parameter is now required with no default. Production wiring in
app.dart was already correct and is unchanged; the 184 pre-existing test
call sites now pass `() => true` explicitly, which is exactly the old
implicit default, so every assertion is untouched.

EstadoRadio has no gate of its own but constructs EstadoGrabacion, so it
inherits the same contract.

The one test that existed to pin the old default is renamed to describe
what it still covers (the premium path through iniciar() with no
duracion); its assertions are unchanged.
2026-08-10 22:06:36 +02:00
FreeTLab aa0b242374 feat(iap): add freemium unlock via one-time in-app purchase
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.
2026-08-10 20:37:07 +02:00
ShanaiaBot 186ff45105 chore: bump version to 1.2.29+151 [ci skip] 2026-08-07 17:18:26 +02:00
FreeTLab f4a1fac45a merge: incorporate the CI version bump
Build & Deploy PluriWave / Análisis de código (push) Successful in 25s
Build & Deploy PluriWave / Build APK + AAB release (push) Successful in 2m30s
2026-08-07 17:17:49 +02:00
FreeTLab ea005434d2 merge: local skips stay local, failed stations keep their metadata 2026-08-07 17:17:48 +02:00
FreeTLab d754e28ddf fix(audio): keep local skips local, and stop a failed station blanking Auto
Three reported suspicions. Two confirmed by reading, one not.

1. CONFIRMED, self-inflicted. Playing a song from the phone and pressing
NEXT jumped to a radio station.

3398d02 taught skipToNext/skipToPrevious to fall back to station skipping
when there is no local queue, so the car's buttons would not be dead for
radio. But queue-less does not mean radio: tapping ONE track goes through
reproducirPistaLocal, which never builds a queue -- only folder playback
sets _colaLocal. That is exactly why the report said "at least the first
time".

emisoraActual cannot tell them apart either: _cambiarFuente fills it in for
every source, so a local MP3 arrives as an Emisora whose url is its
content:// document URI. The media id's scheme is the real discriminator,
the same test that already keeps the recorder off local files. A local
track now skips nowhere, which is the correct behaviour for a single item.

2. CONFIRMED mechanism. A failed station made the app disappear from the
Android Auto pane.

The error path published STATE_ERROR and then cleared everything:
`emisoraActual = null; mediaItem.add(null)`. That leaves the session in an
error state with no metadata at all, and Auto drops a session with nothing
to show -- reported as "if a station fails it seems to crash, and going to
1/3 it fails".

Both are kept now. Nothing outside servicio_audio.dart consumes mediaItem
(verified), so the phone is unaffected, and the car gains two things: the
screen can still name the station that failed instead of going blank, and
previous/next stay usable, so a driver can skip out of a dead station
instead of being stranded -- _saltarEmisora needs emisoraActual to know
where it is in the list. The error state itself is unchanged.

3. NOT CONFIRMED. A local track occasionally jumping to another one mid-play.

An advance requires a genuine `completed` from just_audio, so either the
player reports the end early -- plausible for a content:// SAF source,
whose duration is not always exact -- or something else moved the track.
Reading the code cannot separate those, so nothing was changed on a guess.
The advance now logs the decision with the processing state, position and
duration that caused it, so the next occurrence arrives with its reason
attached.

Tests: 1192 -> 1195.
2026-08-07 17:17:48 +02:00
ShanaiaBot 1da417fdf5 chore: bump version to 1.2.28+150 [ci skip] 2026-08-07 13:06:41 +02:00
FreeTLab 950c9fda58 merge: incorporate the CI version bump
Build & Deploy PluriWave / Análisis de código (push) Successful in 28s
Build & Deploy PluriWave / Build APK + AAB release (push) Successful in 1m41s
2026-08-07 13:06:01 +02:00
FreeTLab 0949525859 merge: ship the onboarding and release-notes content 2026-08-07 13:06:00 +02:00
FreeTLab 0ef6ce35b4 fix(assets): declare the content subdirectories so onboarding ships
Audit of the same failure family as the shrunk drawables: references by
NAME that nothing validates at compile time.

The whole onboarding and release-notes feature had never shipped. Reading
the installed APK: ZERO entries under assets/content/, while
assets/icons/alarmas/* was present. pubspec declared `assets/content/`, and
Flutter does not recurse -- naming a directory includes the files sitting
directly in it, never its subdirectories. Every content file lives in one
(onboarding/, updates/<locale>/), so none of them were packaged.

On the device that surfaced on every single launch:

  Unable to load asset: "assets/content/onboarding/en.md"

with the file plainly present on disk. That is why it never looked like a
packaging problem. The tell was already in the pubspec: assets/icons/alarmas/
is listed explicitly, so the rule was known once and not applied here.

All 14 content directories are now declared: onboarding/ plus updates/ for
each of the 13 locales.

The guard is a test that loads every file under assets/content/ through
rootBundle, because that is the only thing that proves an asset is declared
and will ship. A test asserting File.existsSync would have stayed green
through all of this -- the files were never missing, only unpackaged. Run
against the unfixed pubspec it fails 26 of 27; with the fix it passes.

Tests: 1165 -> 1192.
2026-08-07 13:05:59 +02:00
ShanaiaBot dc62ef6adc chore: bump version to 1.2.27+149 [ci skip] 2026-08-07 12:53:35 +02:00
FreeTLab 28f47d6340 merge: incorporate the CI version bump
Build & Deploy PluriWave / Análisis de código (push) Successful in 26s
Build & Deploy PluriWave / Build APK + AAB release (push) Successful in 1m39s
2026-08-07 12:52:56 +02:00
FreeTLab ea0c6c8a9c merge: keep Dart-named drawables from the resource shrinker 2026-08-07 12:52:55 +02:00
FreeTLab c3cc4120c0 fix(android): stop the resource shrinker from deleting Dart-named drawables
Root cause found, and it is not the stale build cache I claimed earlier.
flutter clean was good hygiene and changed nothing here, because nothing
was cached: the resources were being deliberately removed.

Flutter's own Gradle plugin enables shrinking on every release build --
FlutterPlugin.kt, `releaseBuildType.isMinifyEnabled = true` and
`isShrinkResources = true` -- no matter what app/build.gradle.kts says. The
shrinker keeps what it can see referenced, and it cannot see
`MediaControl.custom(androidIcon: 'drawable/ic_auto_eq_on')`: that is a
string inside Dart, resolved at runtime through getIdentifier. So both
equalizer icons were stripped from every release APK ever built.

The evidence that pins it, from the APK pulled off the device:

  ic_stat_pluriwave   present   <- referenced as R.drawable from Kotlin,
                                   4 call sites in the alarm notifications
  ic_auto_eq_on       absent    <- named only in a Dart string
  ic_auto_eq_off      absent    <- named only in a Dart string

Same folder, same file shape, same commit range. The only difference is
whether a real R.drawable reference exists, which is exactly what the
shrinker looks for.

The consequence was never a blank button. getResourceId returns 0 for an
unresolvable name, PlaybackStateCompat.CustomAction.Builder throws on a 0
icon, and that throw aborts AudioService.setState before the media session
is activated -- so Android Auto held a frozen, inactive session. Dead
playback screen, play that never became pause, the app losing its pane to
any app with a live session, audio playing "as if it were not the app".
One shrunk file, four symptoms, since 31 July (2540556).

Two protections, because they fail differently:
- res/raw/keep.xml with tools:keep is the official mechanism for
  dynamically resolved resources and is what actually binds the shrinker;
- RecursosResueltosPorNombre.kt gives them genuine R.drawable references,
  the same thing that kept ic_stat_pluriwave alive all along.

station_art_* are kept too. They are reached the same way, through
android.resource:// URIs built in Dart, and survived only by luck.

Tests: 1165, unchanged -- this is a build-configuration fix, and no Dart
test can see it. The CI resource guard is what verifies it now.
2026-08-07 12:52:55 +02:00
ShanaiaBot 7a29026992 chore: bump version to 1.2.26+148 [ci skip] 2026-08-07 12:41:03 +02:00
FreeTLab 9914aced92 merge: incorporate the CI version bump
Build & Deploy PluriWave / Análisis de código (push) Successful in 27s
Build & Deploy PluriWave / Build APK + AAB release (push) Failing after 1m19s
2026-08-07 12:40:26 +02:00
FreeTLab 4cc42af9d1 merge: resource guard inspects the resource table and self-checks 2026-08-07 12:40:25 +02:00
FreeTLab e0fa2d695a fix(ci): inspect the resource table, not zip paths, and self-check first
The previous guard reported "Drawables en el APK: (ninguno)" for a 105MB
release APK. Zero drawables is impossible -- AndroidX alone contributes
dozens -- so the check was wrong, not the build. Release APKs shorten and
rename resource file paths, so `res/drawable/...` simply is not how they
are stored there. The 45MB base.apk taken off the device kept readable
paths because it came from an AAB through bundletool; the CI builds a fat
APK through a different pipeline. Same app, different layout.

Resource NAMES survive in resources.arsc regardless of path shortening, so
that is what gets inspected now.

And the guard checks itself before judging. It looks for a sentinel
resource known to be present (station_art_nova); if the sentinel is not
found, the inspection method is unreliable and the step says so instead of
declaring anything absent. This guard has already lied once, reporting
ic_stat_pluriwave missing when it was verified present, and that lie was
about to send us hunting a build problem that did not exist. A check with
no way to detect its own failure has no business failing a build.

Verified before pushing, all three extracted verbatim from the parsed YAML
and run against real inputs:
  1. real 45MB base.apk    -> sentinel found, ic_stat_pluriwave OK,
                              ic_auto_eq_on/off missing, exit 1
  2. APK absent            -> reports the path and lists what is there,
                              exit 1, no resource accusations
  3. zip without arsc      -> "inspection impossible", exit 1 (checked
                              without a pipe, so the code is the script's)

Scenario 3 is the one the old guard got wrong: it turned an inspection
failure into three false "FALTA" lines.
2026-08-07 12:40:25 +02:00
ShanaiaBot 9d8f426fc8 chore: bump version to 1.2.25+147 [ci skip] 2026-08-07 12:35:43 +02:00
FreeTLab ca5f243524 merge: repair the workflow YAML
Build & Deploy PluriWave / Análisis de código (push) Successful in 26s
Build & Deploy PluriWave / Build APK + AAB release (push) Failing after 1m29s
2026-08-07 12:35:02 +02:00
FreeTLab 1e97a94602 fix(ci): repair the workflow YAML broken by an unindented heredoc
The previous commit made build.yml unparseable and no job ran at all:

  yaml: line 183: could not find expected ':'

A `run: |` block is a YAML literal scalar, so every line has to keep the
block's indentation. The python3 fallback I added used a heredoc whose
body sat at column 0, which terminates the scalar -- YAML then tried to
read `import zipfile, sys` as a mapping and gave up. Worse than a broken
check: a broken pipeline.

The fallback is gone rather than re-indented. unzip is present on this
runner, a second code path existed only to guard against a case that was
never observed, and its only contribution was an escaping hazard inside
YAML inside shell.

Verified before pushing this time, which is the actual lesson:
- build.yml now parses (yaml.safe_load), 6 + 15 steps;
- the guard's `run` script was extracted from the parsed YAML and executed
  verbatim against the real 45MB base.apk pulled off the device. It prints
  the drawable inventory, reports ic_stat_pluriwave OK and ic_auto_eq_on /
  ic_auto_eq_off missing, and exits 1 -- matching an independent zipfile
  inspection of the same file.

Two commits in a row shipped a CI change that had never been run. Both
were caught by the user rather than by me.
2026-08-07 12:35:02 +02:00
FreeTLab 107739caa3 merge: incorporate the CI version bump 2026-08-07 12:27:41 +02:00
FreeTLab b3bd71be84 merge: make the APK resource guard trustworthy 2026-08-07 12:27:40 +02:00
FreeTLab 0a47c327f1 fix(ci): stop the resource guard from lying when it cannot inspect the APK
The guard added in the previous commit reported all three drawables as
missing on its first run, including ic_stat_pluriwave -- which is
verifiably present: it was read out of the base.apk pulled off the device
byte by byte. The step also finished in 0s, so it never opened the file at
all. Either the APK is not at the assumed path on this runner or unzip is
unavailable, and the failing pipeline silently produced an empty listing
that every grep then "failed" against.

A guard that lies is worse than no guard: it sends you hunting ghosts,
which is exactly the failure mode this whole episode has been about.

It now verifies its own preconditions before judging anything:
- the APK must exist, and if it does not the step prints where the APKs
  actually are (find over build/app/outputs) instead of guessing;
- it needs unzip or python3, and says so plainly if neither is there;
- an empty listing is treated as "inspection unreliable", not as
  "everything is missing";
- it dumps the real res/drawable inventory before the verdict, so a
  future failure is readable without another round trip.

Matching is now exact (grep -qx) rather than substring.

The logic was run locally against the real 45MB base.apk taken off the
device: ic_stat_pluriwave OK, ic_auto_eq_on and ic_auto_eq_off missing --
which is precisely what an independent zipfile inspection of the same APK
reported yesterday. The check agrees with reality before shipping.
2026-08-07 12:27:40 +02:00
ShanaiaBot 53126bdbe7 chore: bump version to 1.2.24+146 [ci skip] 2026-08-07 11:39:06 +02:00
FreeTLab ec6ccb2db8 merge: incorporate the CI version bump
Build & Deploy PluriWave / Análisis de código (push) Successful in 25s
Build & Deploy PluriWave / Build APK + AAB release (push) Failing after 2m0s
2026-08-07 11:38:27 +02:00
FreeTLab 8e00dc0c7c merge: resource guard in CI and group-aware station skipping 2026-08-07 11:38:27 +02:00
FreeTLab f01c0911f7 fix(auto): guard shipped resources, walk favourite groups when skipping
History review requested by the owner: when and why did the Android Auto
UI stop working.

ANSWER: 31 July, commit 2540556, "give the equalizer actions distinct,
state-aware icons".

  9eff760 (31-07)  androidIcon: 'drawable/ic_stat_pluriwave'  -> in the APK
  2540556 (31-07)  androidIcon: 'drawable/ic_auto_eq_on'      -> NEVER in it

That commit swapped a drawable that shipped for two that the stale CI
resource cache never included. From that moment getResourceId returned 0,
PlaybackStateCompat.CustomAction.Builder threw, and the throw aborted
AudioService.setState before the session was published -- so every Android
Auto symptom chased since is one line of that commit. The bitter part is
that 2540556 was itself a fix for a report about two identical icons.

Three changes.

1. CI guard. The build now unzips the release APK and fails if a drawable
resolved by NAME at runtime is missing. Resolution by name cannot fail at
compile time -- it fails in the car, silently, with id 0. This class of
bug shipped undetected for a week; it cannot ship again.

2. Skipping stations now walks the favourites GROUP first, as requested:
group -> all favourites -> my stations -> catalogue. Two deliberate
exclusions, both tested: `sinAsignarId` is the ABSENCE of a group, not a
group, so those walk all favourites; and a one-member group falls through
too, or both buttons would be dead ends. The group is read from the
FAVOURITE record, never from the playing station -- that one is rebuilt by
emisoraDesdeMediaItem, which carries no group id and would always report
"unfiled".

3. Diagnostics on the station skip. It was reported as doing nothing for
radio, and every early return in that method is silent: an empty list and
a single-entry list look identical from outside. The log now names which
one fired, so the next capture answers it instead of another hypothesis.

Tests: 1161 -> 1165.
2026-08-07 11:38:27 +02:00
ShanaiaBot 62f7804d6d chore: bump version to 1.2.23+145 [ci skip] 2026-08-07 00:25:00 +02:00
FreeTLab 57f89c130f merge: incorporate the CI version bump
Build & Deploy PluriWave / Análisis de código (push) Successful in 26s
Build & Deploy PluriWave / Build APK + AAB release (push) Successful in 1m39s
2026-08-07 00:24:18 +02:00
FreeTLab 02cfd48992 merge: clean the CI build so new Android resources ship 2026-08-07 00:24:18 +02:00
FreeTLab 72a291d0c0 fix(ci): clean before building so new Android resources reach the APK
The equalizer drawables were never in the shipped binary. Verified by
pulling base.apk off the device and reading it:

  res/drawable/ic_stat_pluriwave.xml   PRESENT  (added 02-07)
  res/drawable/ic_auto_eq_on.xml       ABSENT   (added 31-07, 2540556)
  res/drawable/ic_auto_eq_off.xml      ABSENT

Neither as a zip entry nor as a name in resources.arsc. Both files are in
git with content and on disk; the older sibling in the same folder is in
the APK. The difference is when they were added.

This runner is self-hosted and the workflow never cleaned, so build/
survives between runs and Gradle's incremental resource merge went stale:
resources present when the cache was built kept working, resources added
afterwards silently never made it in.

The cost was weeks of wrong diagnosis. getResourceId returned 0 for that
icon, PlaybackStateCompat.CustomAction.Builder throws on a 0 icon, and
that throw aborts AudioService.setState BEFORE mediaSession.setActive --
so Android Auto held a frozen, inactive session. On the device that
surfaced as a dead playback screen, a play button that never became
pause, PluriWave losing its pane to whichever app did have an active
session, and audio that played "as if it were not the app". One cause,
four symptoms.

Dart changes always shipped because Dart is recompiled every build, which
is exactly why this hid for so long: every fix appeared to land and
nothing behaved differently.

flutter clean costs build time. It buys the guarantee that what is in git
is what is in the binary, which this project just spent weeks not having.
2026-08-07 00:24:18 +02:00
ShanaiaBot adb2a1d1bc chore: bump version to 1.2.22+144 [ci skip] 2026-08-07 00:19:27 +02:00
FreeTLab 346cd2b6b9 merge: incorporate the CI version bump
Build & Deploy PluriWave / Análisis de código (push) Successful in 26s
Build & Deploy PluriWave / Build APK + AAB release (push) Successful in 1m37s
2026-08-07 00:18:49 +02:00
FreeTLab e337f6166c merge: reject a local track as a recording source 2026-08-07 00:18:48 +02:00
FreeTLab 54d87190fe fix(grabacion): reject a local track as a recording source
Reported, with this on screen:

  No se pudo iniciar la grabación: Invalid argument(s): Unsupported scheme
  'content' in URI content://com.android.externalstorage.documents/tree/
  primary%3AMusic/document/primary%3AMusic%2F...%2FNew Limit - Smile.mp3

The URI in that message is a local MP3, not a station.
PluriWaveAudioHandler._cambiarFuente sets `emisoraActual` for EVERY source
it plays, so a local track surfaces as an Emisora whose `url` is the SAF
content:// document URI it was opened from. EstadoGrabacion.iniciar only
checked for null, handed that straight to the recorder, and the HTTP
client failed with a message no user can act on.

"It used to work" is exactly right: before local music playback existed,
whatever was playing was always a real station, so the case could not
arise. The recorder never changed.

iniciar() now also requires a real network stream (esEmisoraGrabable) and
falls back to the existing "select a station first" message, which is the
correct guidance here -- recording a local file makes no sense anyway,
it is already on the device. No new l10n key, so no 13-locale churn for a
message that already says the right thing.

Tests: 1158 -> 1161.
2026-08-07 00:18:48 +02:00
ShanaiaBot 968377f1c7 chore: bump version to 1.2.21+143 [ci skip] 2026-08-06 21:48:59 +02:00
FreeTLab e3638ea4a7 merge: incorporate the CI version bump
Build & Deploy PluriWave / Análisis de código (push) Successful in 29s
Build & Deploy PluriWave / Build APK + AAB release (push) Successful in 1m38s
2026-08-06 21:48:21 +02:00
FreeTLab a93e5b192b merge: make audio diagnostics visible in release builds 2026-08-06 21:48:20 +02:00
FreeTLab 1d5332453f fix(audio): make the audio diagnostics visible in release builds
Every diagnostic line in the audio path used `dart:developer`'s `log()`.
That function writes to the VM service, which a RELEASE build does not
have — so in the only build that ever runs in a car, all eleven of them
went nowhere. `debugPrint`/`print` do reach logcat in release; `log()`
does not.

That includes the two channels built specifically to end the guessing:
- `registrarErrorAudioService`, which subscribes to
  `AudioService.asyncError` so the plugin's swallowed platform exceptions
  stop vanishing (b0271fa). It moved them from a dropped PublishSubject
  to a dropped log call.
- `_trazarEstadoPublicado`, the published-state trace added in 7054a4c to
  settle why the car's play button never becomes pause.

So "no evidence" was never a quiet app. It was an app writing its
evidence somewhere release builds discard. Several rounds of hypotheses
were argued without data that the app was already producing.

All eleven now use `debugPrint` with a `[PluriWave][Tag]` prefix, so one
filter catches the audio path and the existing alarm lines together:

  adb logcat | grep PluriWave

No behaviour changes. Tests: 1158, unchanged.
2026-08-06 21:48:20 +02:00
ShanaiaBot 42bb2b4a54 chore: bump version to 1.2.20+142 [ci skip] 2026-08-06 19:50:46 +02:00
FreeTLab 490ae29bd4 merge: incorporate the CI version bump
Build & Deploy PluriWave / Análisis de código (push) Successful in 27s
Build & Deploy PluriWave / Build APK + AAB release (push) Successful in 1m47s
2026-08-06 19:50:07 +02:00
FreeTLab 7d86ddfae9 merge: duck instead of pause, keep the service foreground, restore local music 2026-08-06 19:50:07 +02:00
FreeTLab 3398d02a43 fix(auto): keep the service alive through interruptions, restore local music
Four car reports, two root causes.

1. Local music vanished from the Android Auto menu. Self-inflicted, by
c1afe72 yesterday.

That commit moved registrarFuenteNavegacion above every await to keep a
headless engine from dying before it ran -- but left
registrarFuenteMusicaLocal below `await SharedPreferences.getInstance()`.
The root menu decides whether to offer "Música Local" with
`fuenteLocal != null && await fuenteLocal.hayCarpetaConfigurada()`, so
the car could now get a root response in the window between the two
registrations, find a null source, and be told there is no local music.
Android Auto caches the browse root, so it stayed missing for the whole
session. Before the reorder both registrations sat together after the
await and the window did not exist.

FuenteMusicaLocalAutoImpl never needed prefs to be CONSTRUCTED -- it
resolves them lazily per call, the same convention ServicioAlarmas uses
-- so it now registers beside the station source, above every await, and
the window is gone rather than narrowed.

2. PluriWave disappeared from the Auto pane mid-drive, the playback
screen sat frozen, and the equalizer was lost on every navigation
prompt. One cause for all three.

androidWillPauseWhenDucked: true made audio_session translate Android's
AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK into a full PAUSE. In a car that
fires constantly: every navigation instruction, every speed-camera
warning, every voice assistant. And a pause publishes playing:false,
which AudioService.setState turns into exitPlayingState() and, with
androidStopForegroundOnPause: true, into stopForeground(...). The
plugin's own doc for that flag says what follows: "while in this lower
priority state, the operating system will also be able to kill your
service at any time to reclaim resources". A killed service is a media
session that vanishes from the car pane -- and another media app takes
the slot.

Now the app ducks instead of pausing, so playing stays true and session,
notification and pane all survive an interruption; and the service stays
foreground even on a real pause, so a genuine one is not a death
sentence either. androidNotificationOngoing goes to false because the
plugin asserts it implies stopForegroundOnPause, and nothing is lost: a
foreground service already forces the notification to be ongoing.

A real, non-duckable focus loss (a phone call) still pauses and still
auto-resumes -- asserted, so the duck change cannot silently turn a call
into a station playing over it.

3. Previous/next on the car playback screen, for stations too.

skipToPrevious/skipToNext are now advertised unconditionally, since
Android Auto only draws those buttons when the app declares support.
They are no longer inert without a local queue: they walk the narrowest
list the current station belongs to -- favourites, then my stations,
then the catalogue -- wrapping at both ends, because a button that goes
dead at the end of a list reads as broken on a screen with no visible
list position. Matching is by uuid so a refreshed snapshot still
resolves, and a station in no list leaves playback untouched.

The equalizer toggle still fits alongside them: prev/next take their two
reserved slots and the equalizer claims the remaining custom-action room
because construirControlesTransporte places it before MediaControl.stop.

The phone notification is deliberately untouched: `controls` still gates
skip on an active queue, so nativeActions and
androidCompactActionIndices are byte-identical. Only systemActions
changed, and only the car reads those.

Tests: 1146 -> 1158.
2026-08-06 19:49:57 +02:00
ShanaiaBot 2480c57bfc chore: bump version to 1.2.19+141 [ci skip] 2026-08-06 17:19:36 +02:00
FreeTLab 1d8c9c57bc merge: incorporate the CI version bump
Build & Deploy PluriWave / Análisis de código (push) Successful in 29s
Build & Deploy PluriWave / Build APK + AAB release (push) Successful in 2m31s
2026-08-06 17:18:54 +02:00
FreeTLab c1afe72aec fix(arranque): stop a headless engine from dying before runApp
Reported: with Android Auto connected, the car screen sometimes came up
completely BLACK, and opening the app on the phone then showed a
completely WHITE screen until the app was force-killed and reopened.
Never without Android Auto.

The user guessed portrait-only plus a landscape phone made the app "go a
bit crazy". Right file and right trigger, different mechanism -- a broken
layout renders overflow stripes or a red error box, never white. White
means nothing was ever built, so runApp had not run.

Verified in the plugin source: AudioServiceActivity.provideFlutterEngine
returns AudioServicePlugin.getFlutterEngine(context), which CREATES the
engine and executes the Dart entrypoint the first time it is asked. When
the car binds the MediaBrowserService before the app is opened, that
first ask is the service -- so main() runs HEADLESS, with no Activity.

SystemChrome.setPreferredOrientations travels the flutter/platform
channel, whose handler (PlatformPlugin) is installed by the Activity.
Headless there is nobody to answer it, so the call throws
MissingPluginException or never settles. It was the FIRST await in
main(), which made it fatal twice over: registrarFuenteNavegacion sits
below it and never ran, leaving getChildren with no source (black car
screen), and runApp was never reached. Opening the app then reused that
same cached, already-dead engine -- white screen. Only a force-kill,
which disposes the cached engine, recovered it. That is exactly the
workaround that was reported, and it is what makes the diagnosis fit
every detail rather than most of them.

Three changes, smallest first:
- The Android Auto browse registration moves above every await. It
  depends on nothing, and anything before it is a place to get stuck.
- The orientation call is no longer awaited. It is a display preference,
  never a prerequisite for runApp, and _OrientacionResponsiveApp already
  re-applies it in didChangeDependencies -- the only moment it can take
  effect anyway.
- aplicarPoliticaOrientacion swallows everything and logs, so the
  headless failure can never propagate again.

The policy itself is unchanged and now pure and tested
(orientacionesPara): phones portrait, >=600dp everything.

Tests: 1141 -> 1146.
2026-08-06 17:18:53 +02:00
ShanaiaBot 809b4c6eb4 chore: bump version to 1.2.18+140 [ci skip] 2026-08-06 01:29:21 +02:00
FreeTLab 14985f6417 merge: incorporate the CI version bump
Build & Deploy PluriWave / Análisis de código (push) Successful in 26s
Build & Deploy PluriWave / Build APK + AAB release (push) Successful in 1m48s
2026-08-06 01:28:43 +02:00
FreeTLab a6cdf0e72c fix(auto): advertise the transport actions Android for Cars requires
Reported: on the Android Auto playback screen the play/pause button stays
on PLAY while audio is audibly playing, and "it used to work, in the
latest versions it doesn't".

Previous rounds looked for a regression in this repo's audio commits and
found none: every playbackState.add site publishes playing:true with a
ready processingState, and AudioService.getPlaybackState maps that to
STATE_PLAYING. That search was aimed at the wrong thing.

The Android for Cars guide ("Enable playback control") is explicit:
"Android Auto and AAOS display playback controls based on the actions
that are enabled in the PlaybackStateCompat object. By default, your app
must support the following actions: ACTION_PLAY, ACTION_PAUSE,
ACTION_STOP, ACTION_PLAY_FROM_MEDIA_ID, ACTION_PLAY_FROM_SEARCH."

systemActions has carried only `seek` + `stop` since e9d1f67, the first
commit of the project -- git log -S confirms it was never once edited. So
the required actions have never been advertised, and no audio commit can
explain a change in behaviour. Android Auto ships as its own app and
updates itself, which is how a working screen breaks with a clean repo
history. That fits the report better than any commit here does.

The phone notification was never affected: it builds its play/pause
button from `controls`, not from these bits, which is exactly why the
symptom is car-only.

Skip actions stay conditional on an active queue on purpose -- the same
guide notes Auto reserves the prev/next slots for them and gives the
space to custom actions when the app does not support them, and that is
the space the equalizer toggle needs.

ACTION_PLAY_FROM_SEARCH is now implemented rather than merely claimed:
advertising it unimplemented would have the car's assistant accept "play
Radio X" and silently do nothing. emisoraParaBusqueda ranks exact name,
then prefix, then substring, then country, accent- and case-insensitive
because voice transcription rarely gets diacritics right; favourites are
searched first so they win a name tie, and a miss plays nothing rather
than something arbitrary.

Still a hypothesis for the play/pause symptom, not a confirmed fix -- it
is documentation-backed and cheap, but only a head unit can confirm it.

Tests: 1132 -> 1141.
2026-08-06 01:28:42 +02:00
ShanaiaBot 924a5cab21 chore: bump version to 1.2.17+139 [ci skip] 2026-08-05 23:08:06 +02:00
FreeTLab 1e7c0daa90 merge: incorporate the CI version bump
Build & Deploy PluriWave / Análisis de código (push) Successful in 30s
Build & Deploy PluriWave / Build APK + AAB release (push) Successful in 2m30s
2026-08-05 23:07:20 +02:00
FreeTLab bca0a9bbb7 merge: dart-side snooze anchor guard, Auto progress bar, drop EQ folder 2026-08-05 23:07:19 +02:00
FreeTLab 80538900db fix(alarmas,auto): guard the last unguarded snooze path, surface car progress
Continuation of 7054a4c: the native anchor guard alone did not fix the
reported ~1444-minute snooze, because Dart runs AFTERWARDS on the
pre-notice path and had no guard at all.

1. Snooze from the pre-notice notification, root cause.

app.dart dispatches AFTER the receiver's postponeNext already ran and
after startActivity, and EstadoAlarmas.posponerProximaDesdePreaviso took
whatever occurrence it was handed on faith, then persisted and
rescheduled from it -- the last snooze path in the codebase with no
occurrence guard. The occurrence itself is not trustworthy either:
app.dart falls back to alarma.proximaEjecucion when the native event
carries none, and that field can already point at tomorrow.

_ocurrenciaSonando is generalized into _ocurrenciaValida with a caller-
supplied forward allowance and an externally-proposed occurrence that
still has to survive the same check. The pre-notice path gets a
ventanaPreaviso (30 min, matching AlarmScheduler.PRE_NOTICE_MILLIS) --
unlike the ringing-screen guard, this occurrence legitimately has not
happened yet, which is exactly why the existing helper could not just be
reused here.

Also heals state already poisoned by the missing guard: a snoozeHasta
parked past a 3-hour ceiling (posponerEjecucion clamps to 120 minutes,
so anything beyond that is corruption, not a long real snooze) is
dropped on recalculation. Without it, an alarm poisoned on a build
before this fix keeps reporting tomorrow after updating, and the user
reasonably concludes nothing changed.

2. Android Auto: no progress bar or time labels on a local track.

updatePosition was never set anywhere in the handler, so it sat at its
Duration.zero default while copyWith refreshed updateTime to now on every
push -- the car was told "position 0, as of right now" on every event, a
bar pinned at the start regardless of what was actually playing. Now set
from _player.position on both the player-state and buffered-position
listeners (the latter ticks ~2/s, which is what keeps the car's bar
smooth between player-state events). Also stream the MediaItem's
duration once the source reports it -- Auto draws no bar at all without
one, and radio streams correctly keep reporting none (live audio has no
length).

3. Android Auto: drop the Ecualizador browsable folder.

Owner decision after driving with it: a browsable six-preset list is
more interaction than a driver wants, and on/off from all three player
views (already fixed in 7054a4c to win the custom-action slot) is the
only equalizer control that belongs in the car. Preset selection stays on
the phone. This lands back on the redesign mockup's original rule ("sin
carpeta de ecualizador"), now for a road-tested reason. getChildren keeps
answering the folder's id transitionally, since a head unit can have the
old tree cached for a session or two.

The two "raiz always includes/ends with Ecualizador" tests are replaced,
not regressed -- same move the codebase already made once in the other
direction for the same folder.

Tests: 1127 -> 1132.
2026-08-05 23:07:10 +02:00
ShanaiaBot 61c78b5497 chore: bump version to 1.2.16+138 [ci skip] 2026-08-05 11:56:12 +02:00
ShanaiaBot 41d637b890 chore: bump version to 1.2.15+137 [ci skip] 2026-08-05 10:18:35 +02:00
FreeTLab 2bafc7e5ac merge: incorporate the CI version bump
Build & Deploy PluriWave / Análisis de código (push) Successful in 26s
Build & Deploy PluriWave / Build APK + AAB release (push) Successful in 1m44s
2026-08-05 10:17:50 +02:00
FreeTLab c4a39ee8ed merge: native snooze anchor guard and Android Auto EQ slot 2026-08-05 10:17:48 +02:00
FreeTLab 7054a4c871 fix(alarmas,auto): guard the native snooze anchor, surface the EQ in the car
Three reported issues, two fixed and one instrumented.

1. Posponer left the alarm snoozed ~1444 minutes (24h04m).

Traced end to end in Kotlin. onAlarmFired runs from the receiver BEFORE the
ringing notification exists, and persists snoozeOriginMillis = null plus a
triggerAtMillis already advanced to TOMORROW by computeNextTriggerMillis.
snooze() then anchored on `spec.snoozeOriginMillis ?: spec.triggerAtMillis`
and picked up tomorrow. The existing clamp could not catch it: it only
rescues anchors in the PAST, so an anchor +24h out sails through. The
countdown text is honest -- ceilMinutes(snoozeUntil - now) over Dart's own
template -- the corrupt value is snoozeUntil. With N=5 and a tap at T+1min
the arithmetic lands on 1444 exactly.

This is the defect a9da855 fixed on the Dart side with
EstadoAlarmas._ocurrenciaSonando, after 9c7cf4e had fixed only one of two
adjacent callers. The native lane never got that guard. Now it has
anchorOccurrenceMillis, with a per-surface forward allowance: none for
snooze() (the ringing notification closes an occurrence that has arrived)
and a full PRE_NOTICE_MILLIS for postponeNext() (the pre-notice
notification's occurrence legitimately has not).

No Kotlin test source set exists in this project, so CI cannot verify this
and no Dart test sees it (all use FakePuertoAlarmasAndroid). Verified by
reading; needs an on-device pass.

2. The equalizer toggle stayed invisible on the Android Auto playback
screen even on v1.2.14+136, which does contain it.

On Android 13+ createCustomAction (AudioService.java:466-469) turns
MediaControl.stop into a custom action too, so the car receives TWO in list
order and stop was first -- a head unit exposing one custom-action slot
shows that and buries the rest in an overflow menu. The equalizer now
precedes stop and wins the slot; it is the better occupant, since the car
has its own path to stop playback while the equalizer is reachable no other
way from that screen.

No platform detection needed, and the phone notification is untouched on
every API level: nativeActions comes out [prev?, play/pause, stop, next?]
below 13 and [prev?, play/pause, next?] on 13+, exactly as before. Both are
now asserted.

The list also moves to a public construirControlesTransporte. The guard
test used to re-declare its own copy of the shape, so it stayed green while
asserting a list lib/ no longer produced. It calls the real builder now.

3. Android Auto shows PLAY while a station is audibly playing: NOT fixed,
deliberately.

The car takes that icon from PlaybackStateCompat.getState()
(AudioService.java:601-611), not from controls -- so none of the recent
controls work can be the cause. All eight playbackState.add sites were
audited and none publishes playing:false while audio runs, which leaves no
traced input to fix. A proposed resync off bufferedPositionStream was
rejected: it can publish a spurious idle, which AudioService.java:565-567
turns into stop() and tears down the foreground service -- the exact
regression abc6b47 fixed, on the highest-frequency listener in the handler.

Added instead a change-gated trace of the state actually published, with
eqDisponible alongside it (that flag gates the equalizer action and is
otherwise unobservable). One car session with `adb logcat -s ServicioAudio`
settles both this and issue 2.

Tests: 1124 -> 1127.
2026-08-05 10:17:41 +02:00
ShanaiaBot ddb15623a0 chore: bump version to 1.2.14+136 [ci skip] 2026-08-03 22:05:22 +02:00
FreeTLab 3a4dc3b4b1 merge: incorporate the CI version bump
Build & Deploy PluriWave / Análisis de código (push) Successful in 25s
Build & Deploy PluriWave / Build APK + AAB release (push) Successful in 1m37s
2026-08-03 22:04:45 +02:00
FreeTLab 04300592e0 fix(alarmas): heal alarms already poisoned by the old Detener anchor
The anchor fix stops NEW damage, but devices that ran the buggy build
still carry a future occurrence in ultimaEjecucionGestionada in
SharedPreferences. _esValida rejects any candidate matching it, so the
affected alarm would keep skipping that day with nothing in the UI to
explain it -- which reads as "still broken" rather than "fixed".

_recalcular now drops an ultimaEjecucionGestionada that is meaningfully
in the future. An occurrence cannot have been handled before it happens,
so such a value is corrupt by definition, and dropping it can only ever
restore a real future ring: the double-fire guard it also feeds needs a
PAST occurrence to do its job, and those are untouched.

Placed in the recalculation that every load and every mutation already
funnels through, so an affected alarm heals on the next app open with no
user action -- no delete-and-recreate.

Tests: 1122 -> 1124, including one proving a genuine past occurrence is
still preserved.
2026-08-03 22:04:37 +02:00
ShanaiaBot cb76f09257 chore: bump version to 1.2.13+135 [ci skip] 2026-08-03 22:00:09 +02:00
FreeTLab df9252a621 merge: incorporate the CI version bump
Build & Deploy PluriWave / Análisis de código (push) Successful in 26s
Build & Deploy PluriWave / Build APK + AAB release (push) Successful in 1m45s
2026-08-03 21:59:30 +02:00
FreeTLab ae3f96c0d1 merge: fix Detener consuming a future alarm occurrence 2026-08-03 21:59:30 +02:00
FreeTLab a9da855601 fix(alarmas): stop Detener from consuming an occurrence that never rang
Reported: an alarm set for Monday 16:20 never rang, and the "next alarm"
banner showed a different alarm (the next morning's) instead. No
vacation range involved, both alarms active.

finalizarEjecucion anchored the completed occurrence to proximaEjecucion
with no check that it was the one actually ringing. On the native-fire
path the fire-time sync advances proximaEjecucion to the NEXT occurrence
before the user can reach the ring screen, so tapping Detener recorded a
FUTURE occurrence in ultimaEjecucionGestionada.
ServicioProgramacionAlarmas._esValida then rejects that occurrence for
real: a Monday-only alarm stopped today simply never rings next Monday,
and every sibling outranks it in the banner because its own
proximaEjecucion is a week out.

Reproduced at its purest in the second test: with nothing ringing at
09:01 on Monday, Detener pushed a 16:20 alarm to the FOLLOWING Monday.

posponerAlarma already had exactly this guard -- 9c7cf4e, "anchor snooze
to the ringing occurrence, never a future one", written after the same
failure showed up as a snooze armed a day out. It was applied to the
snooze path and never to the stop path, which sat ten lines below it in
the same file with the identical hazard.

Both paths now share one _ocurrenciaSonando helper so they cannot drift
apart again, and the reason lives in its doc comment rather than in a
comment on one of the two callers.

Also drops snoozeHasta from the stop path's candidate chain: a pending
snooze target is in the future by definition, and snoozeOrigen already
covers a ring that follows a snooze.

Tests: 1120 -> 1122.
2026-08-03 21:59:19 +02:00
ShanaiaBot 1c2b8e0e15 chore: bump version to 1.2.12+134 [ci skip] 2026-08-03 21:34:48 +02:00
FreeTLab 34d04bbc1e merge: incorporate the CI version bump
Build & Deploy PluriWave / Análisis de código (push) Successful in 31s
Build & Deploy PluriWave / Build APK + AAB release (push) Successful in 2m24s
2026-08-03 21:34:09 +02:00
FreeTLab e962b8ac58 merge: restore the Android Auto equalizer toggle and add user presets 2026-08-03 21:33:55 +02:00
FreeTLab f2f706b342 fix(auto): restore the equalizer toggle and list the user's own presets
Two Android Auto regressions reported from the car.

1. The on/off equalizer action disappeared from the playback screen.

That was self-inflicted: commit cacd3ec removed it on the theory that a
custom action in `controls` aborts `AudioService.setState` and kills the
media notification. Reading the plugin source refutes it. setState
(AudioService.java:513-520) SPLITS the list -- a control carrying a
customAction goes to `customActions` (PlaybackStateCompat, i.e. the car),
everything else becomes a NotificationCompat.Action in `nativeActions`
(the phone notification). The two never mix. And the throw the theory
depended on cannot happen here: ic_auto_eq_on/ic_auto_eq_off both exist
under res/drawable, and the labels are non-empty in all 13 locales.

The notification outage was already fixed by abc6b47 (transient idle on
a source change, which setState turns into a full stop() at :557).

The action is back, with both state-aware icons. The real invariant --
a custom action's icon must resolve and its label must be non-empty --
is now a test that reads res/drawable and fails on a missing file,
instead of a comment claiming custom actions are forbidden outright.

2. The Ecualizador folder never listed the user's saved presets.

itemsEcualizadorAuto iterated PresetEcualizador.presets, so only the six
factory presets appeared -- the user's own were unreachable from the
car, the surface where a preset picker matters most. They now arrive
through a registered read function (same seam as stations and local
music, re-read per browse so a preset saved on the phone shows up
without an app restart).

presetsEcualizadorAuto is the single source of truth for the ordered
universe, used to BUILD the items and to RESOLVE a tap, so the folder
cannot show an item that resolution then refuses -- which is what the
factory-only default in seleccionarPresetEqPorMediaId would have caused.
A custom preset whose name collides with a factory one is dropped: the
media id is the raw name, so it could only ever resolve to the factory
entry, and an item that applies a preset other than the one it names is
worse than an absent one.

Tests: 1108 -> 1120.
2026-08-03 21:32:09 +02:00
ShanaiaBot 9581ec97d7 chore: bump version to 1.2.11+133 [ci skip] 2026-08-01 20:37:43 +02:00
FreeTLab d3999a20fb fix(audio): restore the media notification by keeping custom actions out of controls
Build & Deploy PluriWave / Análisis de código (push) Successful in 26s
Build & Deploy PluriWave / Build APK + AAB release (push) Successful in 1m43s
Reported: the media notification vanished entirely -- no shade widget, no
lock-screen controls, not even the small icon beside the clock -- while
audio kept playing and nothing was logged. Working three days earlier.

The equalizer toggle added on 30-31 July was appended to the transport
controls list. That list feeds BOTH the phone notification and the car
playback screen, and AudioService.setState walks every control through
createCustomAction (AudioService.java:513-520) BEFORE it reaches
mediaSession.setPlaybackState (:552) and enterPlayingState (:559), which
is the only place the notification is ever posted.

createCustomAction resolves the icon by name through getIdentifier
(:415-420) -- 0 on a miss -- and hands it to
PlaybackStateCompat.CustomAction.Builder, which throws on a 0 icon or an
empty label. A throw there aborts setState before the session is ever
published. ExoPlayer is independent, so audio continues; and until
asyncError got its first subscriber the exception was dropped silently.
That accounts for every detail of the report.

The car keeps its equalizer: the Ecualizador browse folder already lists
Desactivar plus every preset by name.

Tests: 1103 -> 1108.
2026-08-01 20:33:51 +02:00
FreeTLab f2d7e98813 merge: incorporate the CI version bump 2026-08-01 20:33:51 +02:00
FreeTLab cacd3ece57 fix(audio): keep custom actions out of the media notification controls
The equalizer toggle appended to the transport controls was aborting the
whole notification. controls feeds BOTH the phone notification and the
car playback screen, and AudioService.setState walks every control
through createCustomAction (AudioService.java:513-520) BEFORE reaching
mediaSession.setPlaybackState (:552) and enterPlayingState (:559) -- the
only place the notification is ever posted.

createCustomAction resolves the icon by name via getIdentifier (:415-420),
which returns 0 on a miss, and passes it to
PlaybackStateCompat.CustomAction.Builder, which throws on a 0 icon or an
empty label. That throw aborts setState, so the media session is never
published: no shade widget, no lock-screen controls, not even the small
status-bar icon. ExoPlayer runs independently so audio keeps playing, and
until asyncError got a subscriber the exception was dropped silently.

Nothing is lost in the car: the Ecualizador browse folder already lists
Desactivar plus every preset by name, which is Auto's own idiom for
choosing among options.
2026-08-01 20:33:34 +02:00
ShanaiaBot eac4528141 chore: bump version to 1.2.10+132 [ci skip] 2026-08-01 19:28:38 +02:00
FreeTLab abc6b47ffb fix(audio): stop tearing down the media notification on every station change
Build & Deploy PluriWave / Análisis de código (push) Successful in 25s
Build & Deploy PluriWave / Build APK + AAB release (push) Successful in 2m16s
The app was cancelling its own foreground service each time the source
changed. _recrearPlayer builds a fresh AudioPlayer, which emits idle
first; audio_service treats any non-idle to idle transition as a stop
and cancels the notification. Recovery then depends on
startForegroundService, which throws on API 31+ when the process is not
foreground -- screen off, lock screen, or an Android Auto start.

- Suppress the transient idle only while a source change is in flight,
  via a pure mapearEstadoProceso seam so both directions are unit-tested
- Publish idle explicitly from stop(): just_audio's playerStateStream is
  .distinct() over a value-equal PlayerState, so stopping an
  already-idle player emits nothing, which would have left the state
  stuck at loading and the notification unkillable
- Subscribe to AudioService.asyncError, which had zero listeners and was
  silently swallowing the exception that identifies this class of failure

This removes a real self-inflicted teardown on every API level. It does
NOT prove the reported symptom is fixed: the audio path is byte-identical
across the releases where the symptom appeared, so the trigger is
environmental and still unidentified.

Tests: 1084 -> 1103.
2026-08-01 19:24:48 +02:00
FreeTLab dca19cd1ab merge: incorporate the CI version bump 2026-08-01 19:20:24 +02:00
FreeTLab 1b126d5147 fix(audio): publish idle from stop() instead of trusting the player
just_audio's playerStateStream is .distinct() over a value-equal
PlayerState, so stopping an already-idle player emits nothing. Paired
with the source-change mask -- which writes loading into playbackState
rather than filtering at read time -- a stop landing before native init
completed would leave the state at loading forever.

audio_service only tears the foreground service down on a non-idle to
idle transition, so that window produced an unkillable notification
stuck on "cargando" with a dead Stop button: strictly worse than the
teardown this branch removes.

Additive and idempotent -- when the player does emit its own idle, this
just lands first.
2026-08-01 19:19:30 +02:00
FreeTLab 6da3e69f7e fix(audio): stop emitting a transient idle during a source change
Root cause of the disappearing media notification, and it is self-inflicted
on EVERY Android version — no plugin patch involved.

`audio_service`'s `_observePlaybackState` (audio_service.dart:1131-1136) calls
`AudioService._stop()` on ANY transition into `idle` from a non-idle state.
That reaches `stopService()` -> `deactivateMediaSession()` ->
`notificationManager.cancel(NOTIFICATION_ID)`. The notification is re-posted
at exactly one place, `internalStartForeground()`, reachable only from the
`!wasPlaying && playing` edge in `setState()`, and its FIRST statement is
`ContextCompat.startForegroundService(...)` — which on API 31+ throws
`ForegroundServiceStartNotAllowedException` whenever the process is not in a
foreground state.

Every station change walked straight into that. `_cambiarFuente` pushes
`loading`, then `_recrearPlayer` disposes the old `AudioPlayer` and builds a
FRESH one; a fresh player's first `playerStateStream` event is always `idle`,
and the listener forwarded it verbatim. So `loading -> idle` tore the
foreground service down mid-source-change, and recovery depended on the
following `playing: true` edge restarting it. Screen off, lock screen, or an
Android Auto / Bluetooth-initiated start is precisely where the platform
refuses that restart: audio keeps playing, the notification never returns.
That is exactly what the user reports.

The mapping decision moves out of the private `_mapProcState` into a pure
top-level `mapearEstadoProceso(proc, {required bool cambiandoFuente})`, so the
one line that decides whether the foreground service dies is unit-testable
without instantiating the handler (which needs MethodChannels). It is
byte-for-byte identical to the old switch in every case except `idle` while a
source change is in flight, which now maps to `loading`. The test asserts the
full ProcessingState x cambiandoFuente matrix against a literal transcription
of the previous mapping, and asserts both directions explicitly: a real stop
still yields `idle`, a source-change idle yields `loading`, and `idle` is the
only case where the two branches differ at all.

The only risk this introduces is a `_cambiandoFuente` stuck at `true`: a real
user stop would be masked away from `idle`, the service would never stop, and
the notification would become unkillable. So the flag is cleared by four
independent mechanisms rather than one audited path:

  - a `finally` around the whole body of `_cambiarFuente`, which covers normal
    completion, BOTH `revision != _revisionFuente` early returns, every
    `rethrow` out of a catch clause, and any non-`Exception` `Error` that none
    of the three clauses matches;
  - eagerly at the top of each of the three catch clauses — needed on top of
    the `finally` because `_gestionarErrorReproduccion` calls `_player.stop()`
    WITHOUT awaiting it, so that `idle` could otherwise land while the mask
    was still up;
  - right after `setUrl` resolves, before anything below can await, since the
    fresh player's transient `idle` is already behind us at that point;
  - at the start of `stop()` — before `_player.stop()` — and at the start of
    `_gestionarErrorReproduccion`, which makes the invariant total: the flag
    is `false` before every single `_player.stop()` call in this class.

`stop()` matters most: `BaseAudioHandler.stop()` is empty, so the handler
never pushes `idle` itself — teardown is driven entirely by the player's
emission. A stop landing while a station change was still in flight would
otherwise be masked and the notification would survive the stop.

Audited: two `_player.stop()` call sites exist and both are preceded by a
clear; `_recrearPlayer` has exactly one caller and it is guarded; the old
player cannot emit during `_recrearPlayer` because its subscriptions are
cancelled first.
2026-08-01 19:15:53 +02:00
FreeTLab b0271fa953 feat(audio): log AudioService.asyncError instead of swallowing it
`AudioService.asyncError` had ZERO subscribers app-wide. The plugin funnels
every asynchronous failure of its own observers into that stream and nowhere
else — `_observePlaybackState`, `_observeMediaItem` and `_observeQueue` each
wrap their whole body in `catch (e) { _asyncError.add(e); }`, and the artwork
path uses `.catchError(_asyncError.add)` — and a `PublishSubject` with no
listeners simply drops what it is given. The platform-side exception behind
"the media playback notification disappeared" was therefore being discarded
without a single log line, which is why that report arrives with no evidence
attached.

`observarErroresAudio` is a pure, injectable seam in `arranque_audio.dart`
(stream in, logger callback out), matching the seam convention this codebase
already uses for `esperarArranqueAudio`, `decidirAvanceCola` and
`debeReaplicarEcualizador`: the unit tests exercise the wiring with a plain
`StreamController`, never the real plugin. The default logger emits one
`[PluriWave]`-prefixed `developer.log` line at `level: 900`, the same level
and prefix `servicio_audio.dart` already uses, so one logcat filter catches
both.

Wired from `lib/main.dart`, not from `arranque_audio.dart`: main.dart is the
module that genuinely owns handler lifecycle — it is the only caller of
`AudioService.init`, `registrarHandler` and `ServicioAudioSession`, and both
the on-time and the degraded/timeout startup branches converge on its
`conectarHandler` closure. `arranque_audio.dart` owns only the timeout race
and the degraded loading shell; it never creates or registers a handler
(`alListo` is injected into it from main.dart), so it has no lifecycle to
hang a subscription on. Subscribing happens before `AudioService.init` — the
getter only touches a static subject — so nothing reported during the
MediaBrowser handshake is missed, and one subscription covers both paths.

The subscription is cancellable and its `cancel` is registered into the
handler via `registrarLimpiezaArranque`, mirroring the existing
`registrarHandler` / `registrarFuenteNavegacion` / `registrarFuenteMusicaLocal`
registration convention. `onTaskRemoved` — the only handler teardown in this
app — runs it, so the subscription cannot outlive what it instruments. The
dependency points bootstrap -> service, so `servicio_audio.dart` never has to
import the bootstrap module or the plugin's static stream.

Zero behaviour change: nothing but log output is added.
2026-08-01 19:15:53 +02:00
ShanaiaBot 6bb16da449 chore: bump version to 1.2.9+131 [ci skip] 2026-08-01 13:04:25 +02:00
FreeTLab 61c97858a1 merge: incorporate the CI version bump
Build & Deploy PluriWave / Análisis de código (push) Successful in 26s
Build & Deploy PluriWave / Build APK + AAB release (push) Successful in 1m50s
2026-08-01 13:00:26 +02:00
FreeTLab 802b62f578 feat(tutorial): add 9-screen help/tutorial carousel
Reusable first-launch onboarding and manual reference under
Ajustes > Información > Ayuda y tutorial.

- 9-page PageView walking favorites/groups, the two-level equalizer,
  live recording, adaptive alarms, Android Auto, auto-reconnect, snooze
  duration and custom stations, closing with where to find it again.
- Shown once via a plain persisted flag, so it fires on a fresh install
  AND on the first launch after an existing install updates to this
  version -- inserted between the welcome screen and the unrelated
  what's-new dialog in the boot sequence.
- The existing 'Ayuda y tutorial' Settings tile now opens this carousel
  instead of the what's-new dialog, which loses its only manual entry
  point but keeps its own auto-show cadence unchanged.

Monetization-free, matching the welcome screen's binding constraint.

Tests: 1064 -> 1084.
2026-08-01 12:57:07 +02:00
ShanaiaBot 6ecd503aae chore: bump version to 1.2.8+130 [ci skip] 2026-08-01 12:55:19 +02:00
FreeTLab b09d644a2c merge: incorporate main's safearea/auto-order/vacaciones fixes 2026-08-01 12:54:53 +02:00
FreeTLab 344af83e16 fix(ui,auto,alarmas): safe area, Android Auto order, vacation delete
Build & Deploy PluriWave / Análisis de código (push) Successful in 25s
Build & Deploy PluriWave / Build APK + AAB release (push) Successful in 2m23s
Three unrelated reports fixed together.

- PluriRootHeader ignored the top system inset, so every root screen's
  own title row sat under the status bar / notch. Now pads for
  MediaQuery top inset without touching app.dart's deliberate edge-to-edge
  SafeArea(top:false) background bleed.
- Android Auto pushed the global-sort-derived favorites list instead of
  the phone's own manual order, and the tree builder then force-sorted
  everything by a hardcoded criterion regardless of what arrived --
  incoming order is now preserved, and Todas/Mis emisoras follow the
  same ordenListas setting the phone itself uses.
- The vacation range edit sheet could save but not delete; it now offers
  both, reusing the existing confirm dialog and delete path.

Tests: 1051 -> 1064.
2026-08-01 12:52:15 +02:00
FreeTLab 257f1bbc68 merge: incorporate the CI version bump 2026-08-01 12:49:51 +02:00
FreeTLab f4f9e87970 docs(alarmas): fix helper name typo in vacation delete comment 2026-08-01 12:08:14 +02:00
FreeTLab 597701f497 fix(alarmas): add a delete action to the vacation range edit sheet
The vacation edit sheet could save changes to an existing range but had
no way to remove it, forcing users back to the swipe-to-delete gesture
on the list. When editing (not creating) a range, the sheet now shows
an outlined delete action next to Save; it reuses the existing
confirmation dialog and EstadoAlarmas.eliminarRangoVacaciones exactly
as the swipe gesture already does, then pops on success.
2026-08-01 12:06:00 +02:00
FreeTLab 4d54908be6 fix(ui): add top-inset awareness to PluriRootHeader
PluriRootHeader rendered its 56px title/actions row flush at y=0 on
every device, since app.dart's root SafeArea(top: false) deliberately
excludes the top inset (so each root's full-bleed background paints
edge-to-edge behind the status bar) but the header itself never added
MediaQuery.paddingOf(context).top anywhere. The header now wraps its
existing 56px content row in an outer top padding equal to that inset,
so total rendered height is height + topInset while `height` keeps
meaning the content row's own height (verified no call site did
total-height math against the old fixed constant).
2026-08-01 12:00:48 +02:00
FreeTLab a949b4503d feat(tutorial): repoint Ajustes "Ayuda y tutorial" to the carousel
Point the existing Info tile at PantallaTutorialAyuda (with
primerArranque: false, so its last page reads "Close") instead of
PluriOnboardingDialog's "what's new" modal.

Trade-off: PluriOnboardingDialog loses its only manual entry point --
it keeps auto-showing on its own existing cadence from app.dart, but
is no longer reachable by tapping this tile. This matches the mockup's
Info screen, which has no separate "what's new" row.
2026-08-01 11:49:11 +02:00
FreeTLab e297413145 feat(tutorial): wire tutorial carousel into the first-launch flow
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.
2026-08-01 11:44:25 +02:00
FreeTLab 015a20a823 feat(tutorial): add 9-screen help/tutorial carousel
Add PantallaTutorialAyuda, a PageView-based carousel covering saved
stations/groups, per-station equalizer, recording, adaptive alarms,
Android Auto favorites, auto-reconnect, snooze duration, custom
stations, and a closing summary with a "watch it again" reminder.

ServicioTutorialAyuda persists a one-time seen flag so the carousel
shows once via mostrarSiProcede, independent of entry point; the
final page's CTA label depends on the primerArranque constructor
parameter ("Empezar a escuchar" vs "Cerrar").

Translate the new copy into all 13 supported locales and update
helpSubtitle to describe the new entry point.
2026-08-01 11:34:53 +02:00
FreeTLab cfd8bc9e6a fix(auto): preserve phone-chosen station order in Android Auto folders
Android Auto's Favoritos/Todas/Mis emisoras folders always re-sorted by
a hardcoded quality criterion in ConstructorArbolAuto.hijos/hijosGrupo,
discarding whatever order the caller passed in. EstadoRadio now pushes
already-ordered snapshots (listaFavoritosManual for Favoritos, and the
ordenListas-sorted populares/emisorasCustom getters for Todas/Mis
emisoras, re-pushed immediately on cambiarOrdenListas), and hijos/
hijosGrupo stop re-sorting so that order survives into the car.
2026-08-01 11:26:24 +02:00
ShanaiaBot 445e4518f7 chore: bump version to 1.2.7+129 [ci skip] 2026-07-31 23:30:53 +02:00
FreeTLab d945e1a313 fix(alarmas): stop scheduling failures from being silent
Build & Deploy PluriWave / Análisis de código (push) Successful in 28s
Build & Deploy PluriWave / Build APK + AAB release (push) Successful in 2m24s
Reported: on a Redmi C55 the alarm never rang, no full-screen window, no
pre-notice -- "as if there were no alarm at all". Same build works on a
Poco X7 Pro.

Not device-specific. EstadoAlarmas already recorded per-alarm scheduling
failures and exposed ultimaExcepcionPara, but no screen ever called it,
so a failed alarm rendered identically to a working one.

- Scheduling failures now mark their own card
- The three native paths that only logged -- an unarmed pre-notice, a
  refused foreground-service start, a per-alarm reschedule failing after
  boot -- report to Dart and become per-alarm exceptions
- After a save, the native pending-alarm count is cross-checked, so an
  alarm that never reached the OS is caught immediately

Additive throughout: successful scheduling behaves identically and no
logic branches on manufacturer.

Tests: 1029 -> 1051.
2026-07-31 23:27:58 +02:00
FreeTLab c507218462 merge: incorporate the CI version bump 2026-07-31 23:27:57 +02:00
FreeTLab f2528c930b fix(alarmas): decode native failures with the real channel key names
The first pass read 'alarmaId'/'tipo' from the channel payload while the
native side sends 'alarmId'/'type'/'atMillis' (AlarmScheduler.kt:1389).
Every entry would have been dropped silently in production.

The tests passed because the fake was seeded with the same guessed keys,
so they confirmed the mistake instead of catching it. Decoding now goes
through FalloProgramacionNativo.fromMap -- the single place native key
names appear -- and the fixtures build through that same constructor.
2026-07-31 23:27:45 +02:00
FreeTLab a8dca83cd9 feat(alarmas): surface the three native scheduling failures in Dart
Completes the bridge the native side already exposed. AlarmScheduler and
PluriWaveAlarmService record a pre-notice that could not be armed, a
refused foreground-service start, and a per-alarm reschedule that failed
after a reboot -- but nothing read them, so all three still ended at
logcat.

EstadoAlarmas now drains them at startup and turns each into a per-alarm
exception, which the card already knows how to mark. An alarm that never
reached the OS stops looking identical to one that did.

The read is deliberately tolerant: a failure to read is logged and
swallowed, never surfaced as an alarm error, so a diagnostics gap cannot
masquerade as a scheduling problem.
2026-07-31 23:24:01 +02:00
FreeTLab 7722f204ca feat(alarmas): verify native registration after a successful save
android.programar() returning without throwing was treated as proof
the OS registered the alarm -- this is exactly the gap the reported
case fell through. guardarAlarma now cross-checks a fresh native
pending-alarm count against how many alarms Dart believes are
active-with-a-next-run right after a successful schedule call, and
records a failure for the just-saved alarm when the native count
falls short.

FakePuertoAlarmasAndroid.alarmasNativasPendientes now defaults to a
count derived from programar()/cancelar() calls (mirroring the real
native scheduler's own registry) instead of a frozen 0, while any
test that explicitly assigns the field keeps getting exactly that
value regardless of what programar/cancelar do afterward -- verified
against the full suite, no regressions.
2026-07-31 21:26:41 +02:00
FreeTLab c107c0e18a feat(alarmas): surface scheduling failures on the alarm card
Wires EstadoAlarmas.ultimaExcepcionPara into PantallaAlarmas: an
alarm with an outstanding scheduling-failure exception now shows a
calm warning line (distinguishing a pre-notice-only failure from the
alarm itself not being registered) with a tap target into the
reliability diagnostics screen. The warning is its own small tap
target nested inside the existing card InkWell, so tap-to-edit,
swipe-to-delete and the hero "Saltar" chip are untouched.

Adds alarmCardSchedulingFailedMessage/alarmCardPreNoticeFailedMessage
to all 13 ARB locales with real per-language translations (verified
against arb_parity_test and arb_anti_copy_test).
2026-07-31 21:19:57 +02:00
FreeTLab fd1b91fe9e fix(alarmas): wire scheduling failures into per-alarm exceptions
guardarAlarma/posponerAlarma/posponerProximaDesdePreaviso now record
a scheduling failure via ServicioAlarmas.registrarFalloProgramacion
on catch and clear it on a successful (re)schedule, in addition to
the existing transient EstadoAlarmas.error string. This makes the
failure visible per-alarm via ultimaExcepcionPara instead of only a
generic app-wide message.

Also fixes _sincronizarTodas: a single alarm's android.programar
throw used to abort the whole loop, silently skipping every sibling
alarm scheduled AFTER it on that pass (including on every app launch,
via inicializar). Each alarm's outcome is now independent.
2026-07-31 21:08:21 +02:00
FreeTLab 47d0b8a053 feat(alarmas): record and clear per-alarm scheduling failures
Adds ServicioAlarmas.registrarFalloProgramacion/limpiarFalloProgramacion,
persisting a scheduling-reliability failure through the same
ExcepcionAlarma model saltarProxima already uses. Only one failure
record is kept per alarm (latest attempt wins) and skipNext entries
for any alarm are never touched. EstadoAlarmas wiring follows next.
2026-07-31 21:00:51 +02:00
FreeTLab 88bd251eba fix(alarmas): scope schedule-skip exceptions to skipNext only
ExcepcionAlarma._esValida matched ANY exception tipo against an
occurrence, treating it as a user skip. Only the 'skipNext' tipo
existed until now, but the next commits reuse the same model to
record scheduling-reliability failures per alarm (so the alarms list
can surface them via ultimaExcepcionPara) -- without this guard, a
recorded failure would be silently treated as if the user asked to
skip that occurrence, corrupting scheduling. Adds tipo constants to
ExcepcionAlarma for the upcoming failure kinds.
2026-07-31 20:59:02 +02:00
ShanaiaBot cd7f73056e chore: bump version to 1.2.6+128 [ci skip] 2026-07-31 19:38:01 +02:00
FreeTLab 3f80291e78 feat(auto): equalizer folder in the browse tree, one toggle on playback
Build & Deploy PluriWave / Análisis de código (push) Successful in 26s
Build & Deploy PluriWave / Build APK + AAB release (push) Successful in 2m14s
On-device feedback: two identical icons on the car's now-playing screen,
one of which looked dead. It worked -- but head units render custom
actions icon-first, so cycling six presets behind one static glyph was
invisible.

A monochrome icon cannot encode which of six presets is active. Android
Auto separates the idioms deliberately: custom actions for stateless
toggles, browsable lists for choosing among options.

- Playback screen keeps one action: equalizer on/off, state-aware icons
- New Ecualizador folder lists Desactivar plus the six presets by name,
  active one marked
- The preset-cycling action and its drawable are removed

Supersedes the redesign's no-equalizer-folder rule, which predated
knowing custom actions do not surface state in a car.

# Conflicts:
#	lib/l10n/app_ar.arb
#	lib/l10n/app_bn.arb
#	lib/l10n/app_de.arb
#	lib/l10n/app_en.arb
#	lib/l10n/app_es.arb
#	lib/l10n/app_fr.arb
#	lib/l10n/app_hi.arb
#	lib/l10n/app_id.arb
#	lib/l10n/app_it.arb
#	lib/l10n/app_ja.arb
#	lib/l10n/app_pt.arb
#	lib/l10n/app_ru.arb
#	lib/l10n/app_zh.arb
2026-07-31 19:37:14 +02:00
FreeTLab 88818cf88c feat(alarmas): full Android reliability diagnostics with actionable fixes
Reported: on a Redmi C55 the alarm never rang and the pre-notice never
appeared, while the same build works on a Poco X7 Pro.

The app was never device-specific -- every permission is declared. The
gap was visibility: six diagnostic signals were collected and only three
shown. Battery-optimisation exemption and the count of alarms actually
registered with Android, the two most diagnostic for this failure, were
gathered and discarded.

- Full diagnostics screen, one row per signal, each with a Fix button
  wired to the right system settings intent and guarded by SDK level
- Manufacturer guidance for Xiaomi/Huawei/Oppo/Vivo/OnePlus/Samsung
  explaining Autostart must be enabled by hand -- there is no API for it
- Unresolvable intents surface a message instead of a dead tap

Tests: 993 -> 1014.
2026-07-31 19:30:36 +02:00
FreeTLab f19666508d fix(auto): remove the preset-cycling custom action, superseded by folder
The equalizer's preset-cycling custom action (eq_preset_siguiente) and
its ic_auto_eq_preset drawable are no longer needed now that the
"Ecualizador" folder lists all six presets directly: the folder replaces
what the cycle action did, and this frees a scarce Android Auto custom
action slot.

The on/off toggle is now the equalizer's only custom action.
2026-07-31 19:15:31 +02:00
FreeTLab 8423ccdd0c feat(auto): add an Ecualizador browsable folder with preset selection
On-device feedback showed the equalizer's preset-cycling custom action
looked dead: many head units render custom actions icon-first, and a
monochrome icon cannot legibly encode "which of six presets" the way a
browsable list's text rows can.

This adds an "Ecualizador" folder to the car's browse tree, listing
"Desactivar" first, then the six factory presets by name, with the
currently-active one marked. Selecting a preset routes through the same
playFromMediaId seam every other browse-tree leaf already uses; picking
a preset while the equalizer is off turns it on and applies that preset.

Supersedes the earlier "no equalizer folder" rule (commit 2403da3),
which predated this feedback -- see decision auto/ecualizador-diseno.

The preset-cycling custom action still coexists with the folder in this
commit; it is removed in the next one.
2026-07-31 19:11:01 +02:00
FreeTLab 049ab78acb feat(alarmas): replace one-line reliability button with full diagnostics screen
Surface all six DiagnosticoAlarmasAndroid fields instead of three: the
battery-optimization exemption and native pending-alarm count were
already collected but silently dropped by the old widget. Each failing
signal now offers a "Fix this" action that opens the right system
settings screen (exact alarms, notifications, full-screen intent,
battery optimization), guarded by SDK level and never crashing when a
ROM lacks that screen. Manufacturers known for aggressive background
killing (Xiaomi/Redmi/POCO, Huawei, Oppo, Vivo, OnePlus, Samsung) get
an honest explanation that Autostart must be enabled manually, since
there is no API to detect or grant it.

Notifications now deep-links straight to
ACTION_APP_NOTIFICATION_SETTINGS via a new openNotificationSettings
native method, instead of reusing the runtime permission popup meant
for first-time alarm creation.

New copy is added to all 13 ARB locales with real per-language
translations (not Spanish copies), verified by the ARB parity and
anti-copy tests plus the corruption scanner.
2026-07-31 19:10:52 +02:00
FreeTLab ef9705a30e feat(alarmas): add pure diagnostic mapping and autostart-guidance logic
DiagnosticoAlarmasAndroid already collected six raw reliability fields
but only three ever reached the UI. Add a pure-Dart mapping that turns
the raw snapshot into five ordered signals with a clear ok/needs-
attention state (exact alarms, notifications, full-screen intent,
battery-optimization exemption, native pending-alarm count), plus a
manufacturer check for vendors known to require manually enabling
Autostart (Xiaomi/Redmi/POCO, Huawei, Oppo, Vivo, OnePlus, Samsung),
since there is no public API to detect or grant that setting.
2026-07-31 18:32:42 +02:00
FreeTLab 25405564ee fix(auto): give the equalizer actions distinct, state-aware icons
On a car head unit the custom actions render icon-first, so two actions
sharing ic_stat_pluriwave were indistinguishable and the toggle gave no
sign of whether the equalizer was on.

Each action now has its own drawable, and the toggle swaps between
ic_auto_eq_on and ic_auto_eq_off so its state is legible at a glance.
2026-07-31 18:11:57 +02:00
ShanaiaBot dd463cf2bb chore: bump version to 1.2.5+127 [ci skip] 2026-07-31 01:14:44 +02:00
FreeTLab c8b2c4d2d6 feat(auto,eq,alarmas): address the second round of on-device feedback
Build & Deploy PluriWave / Análisis de código (push) Successful in 24s
Build & Deploy PluriWave / Build APK + AAB release (push) Successful in 1m37s
- Local-music subfolders sort before files, so they no longer fall past
  the 50-item page boundary and vanish from the car
- Playing a folder now plays its subfolders too, bounded at depth 4 and
  500 tracks to cap native SAF round-trips
- Stations and tracks with no artwork fall back to on-brand art instead
  of an empty tile
- Equalizer on/off and preset cycling are reachable from the car's
  now-playing screen as two custom actions
- The equalizer is re-applied after an audio-focus interruption, not
  only when the audio session id changes -- a nav-app prompt keeps the
  same session, which is why the earlier fix missed this case
- The alarm list shows which days an alarm actually fires on

Tests: 933 -> 991.
2026-07-31 01:12:13 +02:00
FreeTLab eba4eba397 merge: incorporate the CI version bump 2026-07-31 01:12:13 +02:00
FreeTLab 4168dc5019 fix(alarmas): show which days a weekday alarm actually fires on
The alarms list showed a generic "Días" label for a diasSemana alarm
instead of its actual configured days. Render the real recurrence (e.g.
"Lun, Mié, Vie") by reusing the SAME per-day abbreviation the editor's own
day-picker circles already use -- no new formatting scheme, no new ARB
keys for the days themselves.

Also surface fade/volume/vacation-pause state on the card, each only when
it is a genuinely useful deviation from the common case: a fade badge when
fadeInSegundos > 0 (reusing the existing alarmFadeInLabel key), a volume
percentage when it differs from the 85% default, and a vacation-paused
badge when the alarm is both configured to pause and a vacation range is
currently active (mirrors the exact predicate ServicioProgramacionAlarmas
already uses). One compact line, not a badge per field.

Fixes a text-collision regression in pantalla_alarmas_editor_test.dart:
opening the editor for an alarm whose own day now renders on its card
(e.g. "Lun") made a bare find.text(weekday) ambiguous against the editor's
day-picker circle with the same label -- scoped that finder to the
BottomSheet subtree.
2026-07-31 01:05:46 +02:00
FreeTLab 491585ad12 fix(eq): re-apply the equalizer after an audio-focus interruption
The equalizer stopped applying after another app interrupted audio (e.g. a
navigation app's voice prompt): play a station with EQ working, let the
prompt speak, resume -- the audio sounds flat until the station is
re-tapped.

debeReaplicarEcualizador only re-attaches the equalizer when the native
player session id actually changes. A short transient interruption keeps
the SAME session (no id rotation), so that trigger never fires, while
Android's AudioEffect framework can let a higher-priority client silently
disable this app's effect instance in the meantime.

Add reaplicarEcualizador() to ObjetivoAudioInterrumpible, implemented as a
thin delegate to the existing _activarEcualizador() (already the correct
idempotent setEnabled + re-push-gains path). ServicioAudioSession calls it
on resume-from-pause (after reanudar()) and on un-duck (after
setAtenuado(false)) -- additive to the existing session-id trigger, not a
replacement. The method takes no argument, so it can only re-assert
whatever enabled/disabled state the handler already holds -- an
interruption cycle with the equalizer OFF stays OFF.
2026-07-31 00:56:37 +02:00
FreeTLab 9eff760462 feat(auto): equalizer enable/disable and preset cycling from the car
Expose the equalizer's on/off toggle and preset choice as PlaybackStateCompat
custom actions on the now-playing screen. The redesign's removal of the
in-car equalizer FOLDER from the browse tree stays as-is (2403da3) -- this
is a different surface (playback screen custom actions, not a browse
folder) and does not reintroduce it.

Deliberately just 2 actions -- an on/off toggle plus a cycling preset
action, not one action per preset -- since Android Auto only surfaces a
limited number of custom actions. Both reuse the existing
setEcualizadorActivo/aplicarPreset entry points (the same ones
EstadoEcualizador's phone settings screen uses), so a car tap and a phone
tap behave identically and both keep the action labels in sync. Reuses the
bundled ic_stat_pluriwave drawable (the notification's own equalizer-bars
icon) -- zero new native assets. The 5-band constraint is untouched.

New pure, unit-tested functions in servicio_audio.dart: presetSiguiente,
nombrePresetVisible, controlesEcualizadorPersonalizados. New ARB keys
(eqCustomActionEnableLabel/DisableLabel/PresetLabel) across all 13 locales,
regenerated via flutter gen-l10n.
2026-07-31 00:54:05 +02:00
FreeTLab 1b0bea5492 fix(auto): fall back to on-brand artwork when a station or track has none
Stations and tracks with no artwork showed empty tiles in the car. The
browse tree's itemEmisora/_itemLocal already fell back to the rotating
station_art_* drawable via artUriPara/artUriLocal, but the "now playing"
MediaItem built when actually playing something (car tap, phone-initiated
play, folder-queue advance, direct local-track tap) did not, so the car's
now-playing screen still went blank.

Reuse the SAME artUriPara/artUriLocal fallback (already the project's one
selection scheme, mirroring PluriStationArtFallback) at every "now playing"
construction site: reproducirPorMediaId, ServicioAudio.reproducir (now via
the extracted, unit-tested mediaItemParaEmisora), construirMediaItemColaLocal
and reproducirPistaLocal.

Guard the reverse direction too: emisoraDesdeMediaItem (extracted from the
handler's private method, now unit-tested) only reflects artUri back into
Emisora.favicon when it passes faviconUsable, so the phone UI's
CachedNetworkImage widgets never attempt a doomed fetch of the car's
android.resource:// fallback URI -- they keep falling back to
PluriStationArtFallback exactly as before.
2026-07-31 00:47:26 +02:00
FreeTLab 6822432a51 feat(auto): play a local-music folder's subfolders recursively too
totalPistas counted only DIRECT audio children, so "Reproducir carpeta"/
"Aleatorio" were hidden for a folder that contains only subfolders, and
playing a folder queued only its direct tracks.

Add a bounded recursive walk (pistasRecursivas) that collects every track
beneath a folder, depth-first, sorted by name at each level. Bounded on
two independent axes to keep a single tap's native SAF round-trips and
in-memory list size predictable on a deep or wide library:
- depth: 4 levels below the tapped folder (profundidadMaximaRecursivaLocal)
- count: 500 tracks total (limitePistasRecursivasLocal)

The folder-play/shuffle actions are now offered whenever the recursive
count is > 0, and "Reproducir carpeta"/"Aleatorio" queue everything found,
not just direct children.
2026-07-31 00:43:20 +02:00
FreeTLab eea8ec31e6 fix(auto): sort local-music subfolders before files
itemsLocales sorted a folder's children by name only, mixing directories
and files. A subfolder whose name sorted after enough tracks (e.g. "Live"
behind 80 numbered tracks) landed on a later "Más..." page, making it
unreachable without paging through every track first.

Sort directories before files, then by name within each group -- the
standard file-browser convention. Subfolders now always land on page 0.
2026-07-31 00:38:39 +02:00
ShanaiaBot 55636f7c74 chore: bump version to 1.2.4+126 [ci skip] 2026-07-30 22:25:08 +02:00
FreeTLab 4b89c9af07 fix(espaciados): complete the spacing review across every screen
Build & Deploy PluriWave / Análisis de código (push) Successful in 25s
Build & Deploy PluriWave / Build APK + AAB release (push) Successful in 1m43s
Finishes the review the user asked for after on-device testing; Buscar
was fixed earlier in fdb7eb1.

- Favoritos' header sat 36px from the edge instead of 20 -- the list's
  own padding stacked on top of PluriRootHeader's inset, and it was the
  one root that did not match Alarmas and Ajustes
- Rows were card-tier (16) where the prototype uses row-tier (12)
- Settings group-to-group gap 12 -> 16; Grabaciones storage card -> rows
  12 -> 16; Paises language -> country list 16 -> 14
- Alarma sonando's snooze block had a non-uniform 10/14 gap pair
- The date line moved below the hero time, where the prototype puts it

Escuchar, Alarmas, Vacaciones, the 12 settings detail screens,
Reproductor and Bienvenida were checked and were already correct.

Tests: 926 -> 933.
2026-07-30 22:23:04 +02:00
FreeTLab f2b02c3ce2 merge: incorporate the CI version bump 2026-07-30 22:23:03 +02:00
FreeTLab db6f4a3a11 fix(alarma-sonando): put the date line below the hero time
The prototype's order is pill (t4:415-416), then 7:30 at 88px (t4:417),
then "Lunes, 3 de agosto" at 14px (t4:419). An earlier pass rendered the
date between the pill and the time and cited "t4 line 419" as its
justification -- but that line number is where the date SITS in the
source, which is exactly why it comes last.

Both the code and the test encoded the same misreading, so the test
passed while the screen was wrong.
2026-07-30 22:22:50 +02:00
FreeTLab 3bb92c0536 fix(grabaciones): correct the gap between the storage card and rows
Issue 3 (feedback-pruebas): t4:617 draws a 16px gap between the
storage usage card and the recordings list below it, not 12.
2026-07-30 22:14:51 +02:00
FreeTLab 9a75027d57 fix(paises): correct the gap between the language and country lists
Issue 3 (feedback-pruebas): t4:260 draws a 14px gap between "Tus
idiomas" and "Todos", not 16.
2026-07-30 22:13:43 +02:00
FreeTLab 93b7ec2af9 fix(alarma-sonando): make the snooze block's vertical gaps uniform
Issue 3 (feedback-pruebas): t4:427 wraps the POSPONER eyebrow, the
snooze tiles and the Stop button in a single flex column with a
uniform gap:12 -- this screen carried a 10/14 pair instead, matching
neither the prototype nor each other.

The dismiss-guard test (protected, untouched) only asserts behaviour
via find.text/find.byType, so this pure value change is safe against
it -- re-verified empty diff after this commit.
2026-07-30 22:12:24 +02:00
FreeTLab 5bbf750b63 fix(ajustes): correct the gap between stacked settings groups
Issue 3 (feedback-pruebas): the prototype (t4:523/534/541) draws a
16px gap between the AUDIO/STATIONS/RECORDINGS/APPLICATION cards, not
12 -- a plain unwired literal that happened to collide with the
sectionGap/panelGap tokens' own value without actually citing the
prototype.
2026-07-30 22:08:40 +02:00
FreeTLab 7faf56900f fix(favoritos): stop the header padding from doubling up
Issue 3 (feedback-pruebas): ReorderableListView.padding wrapped
header/rows/footer with a single horizontal value (16), which doubled
up on top of PluriRootHeader's own internal inset -- landing the
title at 36px instead of the 20px every other root uses -- while also
applying card-tier padding to the flat FilaEmisoraPlana rows (row
tier, matching the same widget's fix on Buscar) and leaving the
populated-state top gap at an unwired 4 that didn't match this same
screen's own empty state (0) or the footer CTA's prototype value (8).

Zeroes the list-level padding and gives the header, chip strip, rows,
and footer CTA their own correctly-tiered insets instead.
2026-07-30 22:06:36 +02:00
ShanaiaBot ec93e45310 chore: bump version to 1.2.3+125 [ci skip] 2026-07-30 21:48:14 +02:00
FreeTLab fdb7eb1d51 fix: address the issues found in on-device testing
Build & Deploy PluriWave / Análisis de código (push) Successful in 28s
Build & Deploy PluriWave / Build APK + AAB release (push) Successful in 2m18s
Seven of the eight points reported after the first real build.

- Favourites overflow menu was clipped to one letter per item by a
  constraints property that sizes the popup, not the button
- Bottom bar painted a square ink splash over the icon, and its lift,
  dim, icon size and label snapped while the balloon slid
- Station artwork fallback is now shared by every surface instead of the
  flat rows painting a plain coloured square
- Vacation ranges can be edited and deleted
- Settings row titles no longer wrap into cut lines
- Sleep timer sheet shows the live countdown
- The last-played station survives a restart, shown stopped

Spacing review is done for Buscar only; the rest of the app is still
outstanding.

Tests: 903 -> 926.
2026-07-30 21:47:13 +02:00
FreeTLab 431f13063d merge: incorporate the CI version bump to 1.2.2+124 2026-07-30 21:45:42 +02:00
FreeTLab fc866d7ec9 fix(buscar): correct the gaps between the filter row and the results
Issue 3 (partial): the results area had no top gap against the filter
row in one state and reused the horizontal constant for a vertical axis
in another. Applies the 3-tier scale properly -- row tier for
background-less placeholders, card tier for card states.

The rest of the app's spacing review is still outstanding.
2026-07-30 20:18:59 +02:00
FreeTLab 727e18737a fix(radio): persist the last-played station across restarts
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.
2026-07-30 19:18:14 +02:00
FreeTLab c6ab295c54 fix(timer): show the live countdown in the sleep timer sheet
showPluriSleepTimerSheet already had a working countdown branch
(ServicioTimer.tiempoRestanteStream), but every preset and the custom
duration flow popped the sheet immediately after starting the timer --
so the countdown never rendered in the primary flow, only if the user
happened to reopen the sheet afterwards.

Stop popping the sheet on start; the existing Consumer<EstadoRadio>
already reacts to iniciarTimerDuracion's notifyListeners and swaps to
the countdown view live. Also make the sheet scroll-controlled: at a
realistic phone width the countdown's title + description + headline-
sized remaining-time text overflowed the default half-screen cap that
never mattered while the sheet always closed before that view could
render.
2026-07-30 19:14:15 +02:00
FreeTLab e75f010b98 fix(ajustes): stop settings row titles from wrapping and cutting off
FilaAjuste's title Text had no maxLines/overflow, and neither did its
trailing current-value Text. An unbounded value (e.g. a real station
name in "Emisora preferida") let the trailing Row claim unbounded
width, squeezing the title down until it wrapped across several lines
that the row's fixed height then cut short.

Constrain the title to a single ellipsized line and cap the trailing
value's width the same way. FilaAjuste backs all 12 settings rows, so
every row is protected, not just the one that happened to expose it.
2026-07-30 19:08:06 +02:00
FreeTLab c7e1a212ca fix(vacaciones): edit and delete vacation ranges
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.
2026-07-30 19:05:28 +02:00
FreeTLab d1a911e587 fix(widgets): share station-art fallback across every surface
TarjetaEmisora had the only good fallback for a station with no artwork --
a deterministic pick from 4 bundled illustrations with a gradient/glyph
last resort. FilaEmisoraPlana's flat rows, the Escuchar hero, the "Tus
emisoras" grid cell, the mini player and the full player each had their
own, separate, flat primaryContainer square instead.

Extract the good fallback into PluriStationArtFallback and use it from
every one of those call sites. The selection formula (asset order,
codeUnits-sum modulo) is preserved exactly, since navegacion_auto.dart
mirrors the same formula independently for Android Auto's own drawable
rotation.
2026-07-30 18:54:31 +02:00
FreeTLab 4be2156e58 fix(nav,favoritos): unclip the overflow menu and smooth the tab transition
Two user-reported bugs from on-device testing.

The favourites overflow menu carried `constraints: tightFor(38x42)`,
which sizes the POPUP rather than the button -- every item was clipped to
its first letter, so users saw "M" and "E" instead of the labels. The
existing test passed throughout because find.text matches a Text widget
whether or not it is visually clipped; the new guard measures the laid-out
width instead.

The bottom bar's ink splash had no shape, painting a hard square over the
icon, and the active tab's lift, dim, icon size and label all changed
instantly while the balloon slid -- the balloon glided and its contents
teleported. All four now share the balloon's duration and curve.
2026-07-30 18:26:25 +02:00
ShanaiaBot b365035e10 chore: bump version to 1.2.2+124 [ci skip] 2026-07-30 17:56:28 +02:00
FreeTLab f24be19e4f feat: visual fidelity pass against the approved prototype
Build & Deploy PluriWave / Análisis de código (push) Successful in 28s
Build & Deploy PluriWave / Build APK + AAB release (push) Successful in 2m22s
Closes the gap between the functional redesign and the design handoff.
An audit found 126 deviations; this pass fixes the large majority and
records the rest as deliberate rejections with reasons.

- Bottom bar rebuilt as the prototype's balloon bar (tabs and icons kept)
- Surfaces are opaque #102532 by default; glass is now opt-in and used
  only on the chrome and the active card, per the prototype's own rule
- Global AppBar dropped; each root draws its own header
- Settings rows show their current value; 3-tier padding scale
- Equalizer, player, alarms, vacations, countries, search, recordings
  and welcome all brought to spec, each with a dimension/colour guard

Tests: 748 -> 903. Analyzer unchanged at 4 infos, 0 errors.
2026-07-30 17:55:44 +02:00
FreeTLab 7343071fca merge: incorporate the CI version bump to 1.2.1+123 2026-07-30 17:53:47 +02:00
FreeTLab 42d35a2541 fix(buscar): filter pills, Idioma chip, results eyebrow, states screen
Audit 6.2 (t4:292-293): active filter pills are brand-teal tinted with
a 15px close glyph and radius 10 -- was Material's own Chip theming.

Audit 6.3 (t4:294-295): an "Idioma" entry chip is now always reachable
once a search is active -- there was no standalone entry point for
language filtering before (only bundled inside "Filtros"). Opens the
same existing filter sheet rather than a new idioma-only picker.

Audit 6.4 (t4:299): the results-count line now uses eyebrowLabel
styling -- was labelLarge (14/w800).

Audit 13.3/13.4 (t4:649-651): the search-results loading skeleton
gains the missing "BUSCANDO EMISORAS..." eyebrow, and its row gap is
now 4px, not 10.

Audit 13.5/13.6 (t4:657-668): a new purpose-built _TarjetaSinResultados
replaces PluriEmptyState ONLY in the search-no-results branch (that
shared widget is left untouched for its other unrelated call sites --
favorites empty state, the discovery grid). The title now quotes the
typed query ("Sin resultados para <<jazzz>>"), and the clear-filters
pill sits INSIDE the card. New ARB key searchNoResultsForQueryTitle.

Item 6.1 (compact 60px active-search header replacing the persistent
glass pill) is NOT implemented: existing tests confirmed the quality
(bitrate) filter has NO entry point anywhere in the app besides the
header's "Filtros" pill (added first via _abrirYSeleccionarCalidad
while a country filter is ALREADY active, i.e. mid active-search).
Hiding PluriRootHeader during active search would make quality
filtering unreachable -- a real capability regression the task's own
"presentation changes, capability does not" principle forbids.
Re-plumbing where that filter lives is a bigger change than this
visual-fidelity pass should make unilaterally.
2026-07-30 17:04:44 +02:00
FreeTLab f61b0b9163 fix(vacaciones): header Add action, info banner, dashed CTA, collapse
Audit 9b.1 (t4:446): a solid brand-teal "Add" header action -- the
prototype's own mid-page CTA (audit 9b.6, still present, now dashed)
is a SECOND, additional entry point, not a replacement.

Audit 9b.2 (t4:448): the teal explanatory banner ("alarms marked
pause-during-vacations won't ring...") is now ALWAYS visible -- never
rendered before. New ARB key vacationExplainerBanner, all 13 locales.

Audit 9b.6 (t4:487): the bottom CTA is now dashed-border with a
date_range glyph -- was a solid OutlinedButton with an add glyph.
Reuses the dashed-painter shape already established in
pantalla_favoritos.dart's custom-station CTA (audit 4.5), duplicated
rather than shared.

Audit 9b.7 (t4:489): "Rangos pasados" is now a collapsible row --
icon, title, count, chevron -- COLLAPSED by default, expanding on tap.
Was always fully expanded inline. Updated the pre-existing widget test
to tap-then-assert instead of asserting immediate visibility.

Item 9b.3 (the eyebrow literally reading "EN CURSO") is NOT
implemented: the eyebrow STYLING is already correct (audit 9b.4), and
the residual copy gap is a shared ARB string
(vacationSummaryActiveCountdown) also used compactly in
pantalla_alarmas.dart's vacation summary row -- diverging its wording
just for this screen's eyebrow, or forcing a shoutier tone into that
other compact usage, is not worth it for a trivial-rated copy nuance.
2026-07-30 16:53:04 +02:00
FreeTLab 36d7d5f692 fix(alarma-sonando): date line, additive snooze qualifier
Audit 9.4 (t4:419): "Lunes, 3 de agosto" now renders between the
schedule pill and the hero time -- purely additive, a new sibling
Text touching neither element. New formato_fechas.dart helper
fechaLargaConDiaSemana (locale-aware via DateFormat.MMMMEEEEd).

Audit 9.10 (t4:433): the highlighted snooze tile gains a "usual"
qualifier (new ARB key alarmSnoozeUsualLabel) alongside the original
flat label -- resolved DIFFERENTLY than its sibling 9.9 (permanently
rejected, id 2525): instead of splitting the flat
alarmSnoozeOptionLabel string into two differently-sized Text nodes
(which would make it vanish from the render tree the protected
dismiss-guard test locates via find.text), a SEPARATE small Text is
added alongside it. The original label stays a single, untouched Text
node, still inside the same FilledButton the guard taps.

Verified against the full ringing-screen test surface (34 tests
across 5 files, including the protected dismiss-guard file) -- all
green, dismiss-guard file reconfirmed byte-identical to main.
2026-07-30 16:45:29 +02:00
FreeTLab dcb415b3f8 fix(alarmas-editor): REPETIR eyebrow, grouped card, volume row
Audit 8.5 (t4:381): the "REPETIR" eyebrow above the weekday circles --
never rendered before. New ARB key alarmRepeatSectionLabel.

Audit 8.6 (t4:392-401): the station picker, volume, fade-in and
vacation toggle now share ONE bordered card with sangred divider lines
-- were four separately-spaced widgets. Restyled _CampoSelectorEmisora
as a flat nav row (icon/bold label/muted value/chevron, matching
FilaAjuste's own convention) so it fits inside the card instead of
drawing its own outlined InputDecoration chrome -- used by BOTH the
primary and fallback (Advanced) station fields. Snooze duration, "use
current station", and the Advanced section stay OUTSIDE the card,
exactly where they were (audit 8.8's own documented deliberate
extras). A transparent Material sits inside the card's coloured box so
the fade-in ListTile and the vacation SwitchListTile still paint their
ink/background correctly (the same class of constraint already
documented for PluriGlassSurface elsewhere in this codebase).

Audit 8.7 (t4:396): the volume row is now a compact 112px track with a
live percentage label -- was a bare full-width Slider with no visible
value. New ArtB key alarmVolumeLabel.

Item 8.8 (extra name/type-selector/snooze-selector/"use current
station"/Advanced fields) stays exactly as documented: deliberate,
required by the hard constraint that the editor keep its date field,
fallback-station picker and sound dropdown.
2026-07-30 16:42:04 +02:00
FreeTLab c7d137c82e fix(alarmas): New pill, banner layout, vacation pill, card recurrence
Audit 7.1 (t4:325): the "New" action is a solid brand-teal pill with a
plain add glyph -- was a tonal button with auto_awesome.

Audit 7.2 (t4:326-330): the next-alarm banner is warmCoral-tinted with
the Skip chip BESIDE the text on the same row -- was an opaque default
card with the skip action stacked below as an OutlinedButton.

Audit 7.3 (t4:332): the vacation row gains a trailing "d-d MON"
date-range pill for the active-or-next range, built from the existing
EstadoAlarmas.rangoVacacionesActivo/vacacionesProximas() accessors --
new formato_fechas.dart helper, no new state.

Audit 7.4 (t4:337-345): the alarm card now shows a recurrence label
next to the giant time (reusing the existing oneTimeOption/
dailyOption/weekdaysOption strings) and a themed station-icon slot
next to the station name. The real per-station favicon is NOT
rendered here -- same network-image hazard already documented for the
ringing screen's audit 9.2 (Emisora.favicon is a network URL;
Image.network hangs widget tests without a mocked HttpClient). The
custom switch shape (52x32/26px thumb) is also left as Switch.adaptive
-- a disclosed sub-gap, not a silent drop.

Item 7.6 (_AccesoDiagnostico, an Android-reliability debug row) is a
pre-approved addition not in the prototype -- informational only, no
action needed.
2026-07-30 16:30:20 +02:00
FreeTLab dcd8488874 fix(favoritos): header actions row, chip colours
Audit 4.1 (t4:216): the manage-groups (create_new_folder) and sort
(swap_vert) actions now live in PluriRootHeader's own actions slot as
two icon buttons -- were an ActionChip inside the chip strip plus a
PopupMenuButton sharing a Row with it. The prototype's own back arrow
stays absent (binding decision: this root keeps its bottom tab bar,
unlike the prototype's pushed-with-back-arrow shape) -- this closes
the remaining gap in what was a partial fix.

Audit 4.2 (t4:219-221): group filter chips are solid brand teal with
dark text when active, listSurface + a faint border when not -- was
Material's own ChoiceChip theming (electricMagenta@24% selected).

Updated pantalla_favoritos_test.dart's two "Manage lists" text finders
to locate the relocated action by key instead (the action is now an
icon-only IconButton with a tooltip, not a labelled chip).
2026-07-30 16:22:12 +02:00
FreeTLab 94b1e901d1 fix(paises): eyebrows outside the card, country count, header search
Audit 5.4 (t4:254/260): "Tus idiomas" and "Todos" eyebrows now sit
OUTSIDE any card at title-tier (20px) padding, styled with
eyebrowLabel -- were titleMedium w900 inside a PluriGlassSurface.
"Todos" also gains its missing total count ("{title} · {count}").

Audit 5.7 (t4:252): the header gained a real `search` action -- toggles
an inline TextField that filters the country list by name or ISO
code, not a decorative no-op button. New ARB key countriesSearchHint.

Item 5.6 (station count as a subtitle line, not a trailing widget) was
already fixed as an undocumented side effect of Tier3's row rebuild
(3303bd3) -- reconfirmed by direct read, no change needed here.
2026-07-30 16:18:44 +02:00
FreeTLab 533896b9fa fix(ecualizador): header switch, banner radius, preset chips, glow thumb
Audit 11.1 (t4:566): the master enable switch moves to
PluriPushScaffold's header actions -- was the body's first
SwitchListTile row. Its realtime/pending explainer subtitle stays
behind as a plain caption so no information is lost.

Audit 11.2 (t4:571): explainer banner radius is 16 (a local one-off,
matching neither of the 3 named tokens), not radiusSm's 14.

Audit 11.3 (t4:574-577): preset chips are solid brand-teal with dark
text when active, listSurface + a faint border when not -- was
Material's own ChoiceChip theming (primaryContainer/grey).

Audit 11.5 (t4:585): band sliders use a new 20x20 _GlowSliderThumbShape
(brand-teal blurred glow + solid thumb), replacing the Material
default round thumb.

Audit 11.7 (t4:584): the dB label is brand teal at 90% alpha, not
liveGreen -- a leftover wrong colour family 11.6's slider-only fix
never touched.

Updated 2 pre-existing tests (pantalla_ajustes_ecualizador_test.dart,
pantalla_ajustes_test.dart) to locate the enable switch by key instead
of by the "Enable equalizer" text it no longer renders next to.
2026-07-30 16:14:11 +02:00
FreeTLab b5b9829faa fix(ajustes): group eyebrow outside the card, per-row accent icons
Audit 10.2 (t4:511): GrupoAjustes's eyebrow now sits OUTSIDE the
PluriGlassSurface, at title-tier (20px) padding -- it used to share
the card's own 16px content padding.

Audit 10.5 (t4:514/526): FilaAjuste gains an optional iconColor.
Wired on the two rows the prototype actually accents: AUDIO's
Ecualizador (brand cyan) and STATIONS' Grupos de favoritos (warmCoral).

Item 10.10 (two extra AUDIO rows -- "Calidad de streaming" and
"Reproduccion sin interrupciones") is NOT implemented: neither concept
has any backing state or service anywhere in this app today. Building
them for real means inventing two new persisted settings, and a
genuine streaming-quality preference would plausibly need to touch
the protected servicio_audio.dart to actually affect playback --
out of scope for a visual-fidelity pass. Decorative rows that open
nothing would be dead UI, which is worse than leaving the gap
disclosed.
2026-07-30 16:05:28 +02:00
FreeTLab e036f99a61 fix(bienvenida,grabaciones): headline size, CTA radius, storage caption
Screen 14 / audit 14.3+14.7: welcome headline now 34px/ls-1.2 (was
headlineMedium's 28/ls-1.0), CTA restyled to a radius-18 rounded
rectangle instead of Material 3's default StadiumBorder (t4:696,715).

Screen 12 / audit 12.2+12.3: the recordings storage card now shows the
bold "X of Y used" headline ABOVE a 6px/radius-3 bar, followed by a
real folder-path + purge-policy caption below it -- was the bar first
with the "used" string as its only (small, generic) caption (t4:613).
New ARB key recordingsLibraryStorageFolderCaption, translated to all
13 locales.

Item 12.5 (folder/max-size settings living behind the header action
instead of inline on this screen) is a deliberate structural split
from WU15b; the audit itself scopes it out of this pass.
2026-07-30 16:00:30 +02:00
FreeTLab 01615f9751 fix(escuchar,reproductor): add the section-heading token and screen 1-3 polish
Audit S7 plus the open items on screens 1-3. Escuchar gains the
prototype's own 52px "now listening" eyebrow header (t4:53), which is
structurally different from the 56px title row every other root uses
(t4:325) -- the earlier wiring test asserted PluriRootHeader on all five
roots, an over-broad premise now corrected to guard what actually holds:
no AppBar, and the sleep-timer action still reachable.
2026-07-30 14:48:06 +02:00
FreeTLab 7ff156852f fix(bienvenida): match body/bullet type sizes and clip the logo mark
Tier 4 visual fidelity, audit 14.4/14.5/14.6/14.8 (t4 lines 693-701):
body copy is 14.5px (was bodyMedium's 14), bullet titles are
13.5px/w800 and subtitles 11.5px (was 14/w800 and 12), the gap between
bullets tightens from 16 to 12, and the 76x76 logo mark is now clipped
to a 20px rounded rect instead of painted square.
2026-07-30 13:02:40 +02:00
FreeTLab 038ec7f3b8 fix(alarma-sonando): pulsing halo, hero-time metrics, art size, snooze icon
Tier 4 visual fidelity, audit 9.1/9.5/9.6/9.8 (t4 lines 411-428):

- 9.1: new _HaloPulsante renders the prototype's 420px amber radial
  gradient behind the hero content. Deliberately BOUNDED (one
  grow-and-settle cycle), not the prototype's literal `infinite` CSS
  animation: this screen's protected dismiss-guard test calls
  pumpAndSettle() after every mount/interaction, and a genuinely
  infinite AnimationController.repeat() would hang it forever with no
  way to fix it. Skips its Animate wrapper under reduced motion, same
  as every other entry animation in this app.
- 9.5: the hero time gets a local letterSpacing/height override
  (-4/0.95) instead of the shared heroTime token's -2.0/1.0 — the
  alarm editor's hour block is the token's other consumer and still
  wants height:1, so the shared style is untouched.
- 9.6: ringing-screen art grows from 168/radiusLg(30) to 180/36 (t4
  line 421), a local constant like the existing stop-button radius.
- 9.8: the POSPONER eyebrow regains its 19px warmCoral snooze icon.

All 5 protected files (including the dismiss-guard test) remain
empty-diff against main.
2026-07-30 12:58:19 +02:00
FreeTLab 8fcb734ab0 fix(alarmas): tighten the gap between stacked alarm cards to 10px
Tier 4 visual fidelity, audit 7.5 (t4 line 334): the prototype stacks
alarm cards with a 10px gap; the build used 12. Each gap is now keyed
per alarm id so a guard test can pin the exact value between two known
cards.
2026-07-30 12:45:41 +02:00
FreeTLab ca2400f0d4 fix(favoritos): restyle the dashed custom-station CTA to spec
Tier 4 visual fidelity, audit 4.5 (t4 line 235): the border and the
label/icon now use the prototype's two DIFFERENT opacities
(rgba(255,255,255,.16) stroke vs rgba(242,247,250,.6) text/icon,
previously one shared 50% colour for both), padding is a uniform 14
instead of symmetric(18,16), and the icon is a plain add glyph at 20px
instead of add_circle_outline_rounded.
2026-07-30 12:40:59 +02:00
FreeTLab 163241d4a1 fix(inicio): add the favourites count badge and plain-text Ver todas
Tier 4 visual fidelity, audit 1.11/1.12 (t4 line 80): a pill badge next
to "Tus emisoras" now shows the TOTAL favorite count (not the 8-capped
grid size), and "Ver todas" is plain 12px/w800 brand-teal text instead
of a Material TextButton with its own padding and splash.

Item 1.4 (hero art radius) was already fixed as a side effect of an
earlier commit — no change needed here.
2026-07-30 12:36:27 +02:00
FreeTLab 56123ea51b fix(ajustes): inset row dividers and shrink icon/title/chevron
Tier 4 visual fidelity, audit S10/10.6/10.8/10.9 (t4 lines 514-516):
GrupoAjustes' row divider now indents 47px instead of running
full-bleed, FilaAjuste's leading icon drops from Material's 24px
default to 21px, the row title is a local 14px override of cardTitle
(14.5), and the chevron shrinks to 19px at 40% opacity instead of the
24px full-opacity default.
2026-07-30 12:32:10 +02:00
FreeTLab 8ecfc928b3 fix(reproductor): move the quality row last and size the play button at 78
Audit 2.4 and 2.6: the quality row sat right after the subtitle, four
positions before where the prototype puts it (t4:114-140), and the play
button was 72 with a 40 icon instead of 78/42 (t4:127).
2026-07-30 12:14:51 +02:00
FreeTLab 3303bd387c fix(paises,buscar): tappable ISO rows and a reconnect card
Item 24 / audit 5.1-5.3, 5.5 (t4:255-269): Paises' "Tus idiomas" was
a Wrap of non-interactive Chips, and the full list was a plain
ListTile with no ISO column and no onTap. Both now share one tappable
row (ISO code, name, station count, chevron); the first "Tus idiomas"
row gets the prototype's teal-tinted highlight. The one production
call site wires the tap to filter Buscar by that country's code and
pop back -- EstadoBusqueda.buscar(pais: ...) already accepts any ISO
alpha-2 code, not just the ~10 presets in the filter sheet.

Item 25 / audit 13.2 (t4:643-646): a reconnect card (rotating ring,
station name, "Reconectando...", a stop affordance) replaces the
complete absence of any reconnect signal outside a word in the mini
player. Ships WITHOUT the prototype's attempt counter: the only live
ControladorReconexion instance is a private field of
PluriWaveAudioHandler inside servicio_audio.dart, a file this task
requires stay byte-identical to main, and nothing else re-exposes it.
Reconstructing a count from estadoStream's reconectando emissions
would not be faithful (the stream can emit it many times per actual
backoff attempt), so that was deliberately not attempted.
2026-07-30 11:48:25 +02:00
FreeTLab 955682271c fix(favoritos,grabaciones): replace glass cards with flat rows
Item 23 / audit 4.3, 12.4 (t4:226-232, 616-619): Favoritos and
Grabaciones rows were full glass cards / ListTiles with two stacked
buttons and no artwork slot. Replace with flat, background-less rows
via a new shared FilaEmisoraPlana widget (square art, name+meta, a
circular play affordance) plus a bespoke Grabaciones row (44x12
placeholder art -- recordings carry no per-station favicon, so this
is a themed fallback, not invented artwork).

Favoritos keeps "Move to list" / "Remove from favorites" behind an
overflow menu (same underlying methods, unchanged) instead of two
always-visible buttons, since dropping either would be a functional
regression the prototype's own row doesn't have to solve for.

Also 12.1 (t4:610): the Grabaciones header action is folder_open, not
a generic gear.
2026-07-30 11:46:55 +02:00
FreeTLab 4537497e83 fix(chrome): rebuild MiniReproductor as a full-bleed opaque bar with art
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.
2026-07-30 10:38:59 +02:00
FreeTLab 2b28c4daed fix(vacaciones): replace the progress bar with the prototype's date pair
Item 21 / audit 9b.4-9b.5 (t4:451-462, 469-479): the active vacation
range showed a LinearProgressIndicator the prototype never draws.
Replace it with the screen's real signature element -- a start/end
date pair (day+month, weekday, connector rule) -- reused with a flat
connector for the "programados"/"pasados" rows, replacing their plain
ListTiles with proper cards.
2026-07-30 10:25:07 +02:00
FreeTLab 2255374291 fix(alarmas): restyle the editor sheet as the prototype draws it
Audit 8.1-8.4: opaque bottom-anchored sheet with a grab handle, a framed
time card and 7 circular day buttons.

The date field, fallback-station picker and sound dropdown all stay
reachable -- the prototype omits them, but presentation changes never
remove capability.
2026-07-30 10:08:54 +02:00
FreeTLab 653848899f fix(eq): split the equalizer body into 4 cards, add Restablecer a plano
Audit 11.10/11.8 (t4 lines 570-602): the prototype draws the explainer
banner, the band sliders, "Salida activa + Guardar como preset +
Restablecer a plano" and "Emisoras con ajuste propio" as 4 separate
cards. The build nested everything in one outer PluriGlassSurface, so
it read as a single merged block, and had no reset-to-flat action at
all.

Removes the outer wrapper from _CuerpoEcualizador. EcualizadorWidget
already draws its own card (design ADR-5, unchanged); the explainer
banner already has its own tinted background (unchanged); "Emisoras
con ajuste propio" now wraps itself in its own PluriGlassSurface; a
new _TarjetaSalidaYAcciones groups Salida activa with two tappable
rows — "Guardar como preset" (relocated from a floating end-aligned
button) and the new "Restablecer a plano", which applies
PresetEcualizador.flat via the existing EstadoEcualizador.cambiarPreset
— the same call the "Plano" preset chip already makes, not a new
capability.

The 5-slider band count, servicio_ecualizador.dart and
preset_ecualizador.dart are untouched.
2026-07-30 00:57:29 +02:00
FreeTLab e5d461af53 fix(buscar): build the 2x2 "Explorar por" grid, add Novedades
Audit 3.2 (t4 lines 162-171): the prototype groups Países, Géneros,
Tendencias and the entirely-missing Novedades into one 2x2 entry-point
grid. The build had them as three unrelated always-visible widgets
(a Países ListTile, a Géneros FilterChip Wrap, a Tendencias ActionChip
strip) and no Novedades entry at all.

Replaces all three with a single grid section, capability-preserving:
Países still pushes PantallaPaises; Géneros and Tendencias now open
their exact existing content in a picker sheet instead of always-on-
screen (Géneros auto-closes on selection, matching this screen's other
single-choice filter sheets; Tendencias stays open, a browse list, not
a filter). Novedades re-triggers the existing discovery refresh, since
no distinct "new stations" feed exists anywhere in the domain.

New ARB key set (exploreByTitle/exploreTrendingTitle/
exploreTrendingSubtitle/exploreNewTitle/exploreNewSubtitle) translated
across all 13 locales with zero anti-copy allowlist entries needed.

Also fixes a pre-existing PluriEmptyState overflow this restructure
exposed (unrelated to the grid itself, confirmed via isolated repro):
wraps its Column in a SingleChildScrollView so a too-tall title/
subtitle scrolls instead of throwing a hard RenderFlex overflow.
2026-07-30 00:43:27 +02:00
FreeTLab a49b2b6519 fix(inicio): render Tus emisoras as a 2-column grid of 8
Audit 1.10 (t4 lines 82-88): the prototype shows a 2x2+ grid of 8
favorite stations; the build was a 260px-wide horizontal strip capped
at 6.

Raises the cap to 8 and replaces the horizontal ListView with a
GridView.builder (2 columns, gap 10). Adds a dedicated _CeldaTusEmisoras
cell (44px square art radius 11, name + genre) rather than reusing
TarjetaEmisora(esCompacta: true), since that widget always renders a
favorite button and a live badge that this prototype cell never draws.
2026-07-30 00:10:11 +02:00
FreeTLab fcba592352 fix(visualizador): add a discrete-bar rendering mode
Audit 1.7/2.5 (t4 lines 66-68, 120-122): the prototype draws 30
discrete bottom-anchored bars (radius 2, vertical gradient); the
build's VisualizadorAudio painted a single continuous oscilloscope
stroke, so its `barras: 30` parameter never produced bars.

Adds an opt-in `barrasDiscretas` mode (default false, byte-identical
continuous rendering preserved for any other caller) plus a
`gradienteFinAlpha` knob for the two screens' differing gradient
end-alpha (.3 vs .45). Wires it into the Escuchar hero and the full
player, correcting the player's bar count/height to match the
prototype (26->30 bars, 46->40px) at the same time.
2026-07-30 00:04:03 +02:00
FreeTLab e94e64faca fix(bienvenida): bottom-anchor content and add the blurred backdrop
Audit 14.2 (t4 lines 693/706): content was top-anchored in a plain
SingleChildScrollView; the prototype anchors it to the bottom with two
flexible spacers, so the CTA sits at the screen edge. Restructured as
a scrollable Expanded region (safe on short screens / large text
scale) with the CTA pinned below it, always at the bottom.

Audit 14.1 (t4 lines 687-689): added the full-bleed blurred banner
backdrop, reusing aurora_wave_banner.png (the same asset the deleted
PluriScreenHeader used) since this app has no bundled equivalent of
the prototype's mockup-only banner.jpg.

No monetization content touched (PRO pill, "14 días", pricing card,
free-version link all remain absent per the binding no-monetization
decision).
2026-07-29 23:41:35 +02:00
FreeTLab 684e8e8ff9 fix(buscar): rebuild the offline banner with offlineAccent
Audit 13.1 (t4 line 641): the banner rendered wifi_off in
colorScheme.error (red) inside a generic glass card. Rebuilt using
offlineAccent (#E8879A) — a token that existed since Tier 1 with no
consumer — for the tint, border and icon, plus the prototype's title/
detail layout and a restyled Retry chip. New offlineBannerTitle key
translated to all 13 locales.
2026-07-29 23:40:38 +02:00
FreeTLab e1505d7aaa fix(escuchar): show the station meta line in the hero
Audit 1.6 (t4 line 62): the Escuchar hero never rendered "genre ·
country · bitrate kbps" between the station name and the visualizer.
Built from fields Emisora already carries (tags/pais/bitrate) — no
new fields, no service calls — omitting gracefully whatever a station
lacks.
2026-07-29 23:34:07 +02:00
FreeTLab 9840205a83 fix(alarma-sonando): add the schedule pill and restyle the station name
Audit 9.3 (t4 line 415): the ringing screen never showed its schedule
pill. Reuses the alarmScheduleOnce/alarmScheduleWeekdays ARB keys that
already existed with no consumer, plus a new alarmScheduleDaily key
(translated to all 13 locales) for the recurring-alarm case shown in
the prototype.

Audit 9.7 (t4 line 423): the station name was cardTitle (14.5px/w700),
5.5px and 100 weight units under the prototype's 20px/w800.

Audit 9.9 (snooze tiles as number-over-unit) is intentionally NOT
included in this commit: it would require the tile to stop rendering
as a single flat "{minutes} min" Text node, which is exactly what
pantalla_alarma_sonando_dismiss_guard_test.dart taps via
find.text(l10n.alarmSnoozeOptionLabel(N)) in four places. That file
must stay untouched, so this restyle is deferred pending a decision
on how to restructure the tap target safely.
2026-07-29 23:33:17 +02:00
FreeTLab dcb716cbc3 fix(alarma-sonando): restyle Stop as a neutral translucent surface
Audit 9.11 (t4 line 434): the Stop button was a FilledButton in
colorScheme.primary (brand cyan), the wrong colour family for the
largest element on the ringing screen. The prototype draws a neutral
rgba(255,255,255,.08) surface with a rgba(255,255,255,.16) border and
radius 24 (none of PluriWaveTokens' three named radii).
2026-07-29 23:27:28 +02:00
FreeTLab cd77ec256e fix(escuchar): restore the prototype's 132px hero artwork
Audit 1.3: the hero art shipped at 84 where the prototype draws 132
(t4 line 56), with a 24 corner radius rather than the shared radiusMd.

Adds a dimension guard. Every existing test on this screen asserts
behaviour only, which is why an artwork 36% too small went unnoticed.
2026-07-29 23:04:40 +02:00
FreeTLab 615a5aac92 fix(eq): restore the prototype's band height and brand-teal fill
Audit 11.4 and 11.6: the band column was 152 instead of 280 (t4 line
581), leaving the sliders 46% short, and the fill read liveGreen -- the
LIVE badge colour -- instead of brand teal (t4 line 585).

Adds two guards. The existing tests only asserted the band COUNT, which
is exactly why both values could drift unnoticed.
2026-07-29 23:01:20 +02:00
FreeTLab 97e38becfe fix(chrome): retire PluriScreenHeader, it has no equivalent in the prototype
PluriScreenHeader was a 38-radius glass hero: an aurora banner at 24%
opacity, a black-to-transparent scrim, two radial orbs, a 120px app-mark
watermark and a 56px tri-gradient glyph badge. None of it is in the
prototype (t4) -- every root's title is plain text in its own 56px
row, which PluriRootHeader (S1) already provides. Delete the class
(and its now-orphaned _Orb helper) and its four call sites (Buscar,
Favoritos x2, Alarmas, Ajustes landed with S8).

Two of those call sites carried the hero's only functional bit besides
the title, so PluriRootHeader gains an `actions` slot (rendered before
the shared bedtime button) to keep them reachable:
  - Alarmas' create-alarm button (FilledButton.tonalIcon, unchanged
    shape, just relocated)
  - Buscar's filters entry point (the same tappable PluriStatusPill,
    just relocated)
Every other retired trailing pill (Ajustes' "Secure" status,
Favoritos' collection-count badges, Alarmas' alarm-count badge) was
purely decorative and matches the prototype by simply disappearing.

S2, Tier 1 visual-fidelity pass (audit id 2521).
2026-07-29 21:29:49 +02:00
FreeTLab 7ebe0b77a4 fix(layout): introduce the prototype's 3-tier horizontal padding scale
The prototype runs three horizontal padding tiers (t4): 20px for
section titles/eyebrows (lines 153, 254, 299, 511), 16px for cards
(lines 327, 512, 610), 12px for background-less list rows (lines 174,
226, 301). The build had collapsed all three into a single
PluriLayout.horizontal = 16, used everywhere regardless of context.

Add PluriLayout.titleHorizontal (20) and PluriLayout.rowHorizontal
(12) alongside the existing `horizontal` (16, unchanged -- it already
covers the card tier). horizontal keeps every one of its ~30 existing
call sites unchanged.

Committed ahead of S2 (item 6 in this pass) because that item's
PluriRootHeader edit reuses titleHorizontal/rowHorizontal for the
header's own padding, matching the prototype's own header spec exactly
(e.g. Alarmas padding:0 12px 0 20px) -- a real dependency, not just
numbering.

S5, Tier 1 visual-fidelity pass (audit id 2521).
2026-07-29 21:28:48 +02:00
FreeTLab e0164f68b5 fix(ajustes): show each settings row's current value
The prototype puts a trailing current-value string on nearly every
settings row (t4 lines 512-539, 625 -- "3 guardados", "Alfabetico",
"Espanol", "7 . 84 MB"). FilaAjuste only accepted icon/titulo/onTap,
so every row was value-blind.

Add an optional `valor` slot to FilaAjuste (13px, rgba(242,247,250,.55),
rendered before the chevron). Wire 8 of the 12 built rows to state
already available at the settings root: equalizer on/off, sleep-timer
active, favourite-group count, preferred station name, custom-station
count, sort order, recordings count-and-size (FutureBuilder over
EstadoGrabacion.listarGrabaciones), and the current language (hoisted
pantalla_ajustes_idioma.dart's native-name list to module level so the
root can read it without duplicating it). Salida de audio, Musica
local, Backup and Info's version are left without a value -- each
lacks a low-risk, deterministically-testable data source (see the
apply-progress note for the reason per row).

Reading EstadoRadio for these values through a root `context.watch`
would rebuild the whole settings list -- including the Grabaciones
FutureBuilder's disk read -- on every unrelated audio notification;
this follows the codebase's existing S4-R5 convention of narrow
`context.select` per field instead.

Ajustes' own PluriRootHeader/PluriScreenHeader edit (S2 in this same
pass) landed in this commit too, since both touched the same header
block in pantalla_ajustes.dart at the same time.

S8, Tier 1 visual-fidelity pass (audit id 2521).
2026-07-29 21:26:48 +02:00
FreeTLab f5a211492a fix(typography): bake the prototype colour into eyebrowLabel
The prototype's eyebrows are always rgba(242,247,250,.42) (t4 lines
254, 299, 381, 450, 511, 660). eyebrowLabel set size/weight/letter-
spacing only, no colour, so every call site (settings group headers,
vacation section titles, the ringing screen's snooze label) rendered
at whatever full-opacity default the ambient text theme resolved to.

One factory-level change fixes every current and future consumer of
PluriWaveTypography.eyebrowLabel — no call site needs editing.

S9, Tier 1 visual-fidelity pass (audit id 2521).
2026-07-29 20:34:09 +02:00
FreeTLab d0660a4966 fix(stations): replace the ringed circle thumbnail with a plain square
The prototype's station thumbnail is a plain 44-48px square, radius
11/12, with no ring or glow (t4 lines 84, 175, 227, 302, 614).
TarjetaEmisora's compact row variant instead painted a 58x58 circle
wrapped in a SweepGradient ring (magenta/cyan/coral) and a 22-blur
glow behind a 50x50 ClipRRect(18).

Replace it with a 48x48 ClipRRect(12) square. Update the loading
shimmer placeholder to match (was also a 58x58 circle block).

S6, Tier 1 visual-fidelity pass (audit id 2521).
2026-07-29 20:29:31 +02:00
FreeTLab 5a5f1655a9 fix(surfaces): make list/card surfaces opaque by default, fix card radius
The prototype states an explicit system rule (t4 line 40): "opaque list
surface #102532, glass only in the chrome and in the active card."
PluriGlassSurface backs nearly every card/row in the app, but always
rendered translucent + blurred (glassSurface, blur 18) — the listSurface
token (#102532) existed since WU1 but was only ever used at reduced
alpha, never opaquely.

Add a `glass` flag to PluriGlassSurface, defaulting to false: an opaque
listSurface fill with no BackdropFilter. Chrome (MiniReproductor) and
the active/now-playing card (Escuchar's hero, when a station is
playing) opt into the old translucent look via `glass: true` — every
other of the ~26 call sites needs no change and now renders opaquely.

Also fix the card radius: the prototype's dominant card radius is 18
(t4 lines 512, 613, 715, 133); radiusMd was 22, a systematic +4px drift
across every card that defaults to it. Retune TarjetaEmisora's own
radius ternary so its compact (row) variant uses the dominant row
radius, 14, instead of inheriting the card radius.

S3+S4, Tier 1 visual-fidelity pass (audit id 2521).
2026-07-29 20:23:55 +02:00
FreeTLab 78a7415dd8 fix(chrome): drop the global AppBar, give each root its own title row
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).
2026-07-29 20:17:24 +02:00
FreeTLab b8f078bc14 feat(nav): rebuild the bottom bar as the prototype's balloon bar
The functional redesign never touched pluri_bottom_navigation.dart, so the
bar kept the old glass + magenta/coral language while every other surface
moved on. Rebuilds it to prototype t4/4a: a 52px opaque #0A1B24 pill with a
110x74 balloon raised over the active tab, a teal radial glow behind it, the
active item lifted 15px with its label, and inactive items reduced to a 23px
icon at 46% opacity.

The five tabs and their PluriIconGlyph icons are unchanged, per the standing
decision — only the bar's shape, colour and behaviour move.

altura and PluriLayout.bottomChromeInset are both asserted against the real
laid-out height rather than hardcoded guesses.
2026-07-29 20:00:30 +02:00
ShanaiaBot 34388d364b chore: bump version to 1.2.1+123 [ci skip] 2026-07-29 18:35:47 +02:00
FreeTLab c01c518541 fix(favoritos): use the cross-version onReorder API and correct drag index math
Build & Deploy PluriWave / Análisis de código (push) Successful in 24s
Build & Deploy PluriWave / Build APK + AAB release (push) Successful in 2m26s
The CI Flutter SDK predates v3.41 and only exposes ReorderableListView's
onReorder; the newer onReorderItem broke the build with three analyzer
errors. onReorder exists in both SDKs, so use it and compensate for its
pre-removal newIndex internally.

Fixing the call site surfaced a real ordering bug: _onReorder located the
target neighbour in the untrimmed global list, while ServicioFavoritos
.reordenar inserts into the list after the station is removed. Dragging a
station downwards past its neighbours therefore landed it one slot too far.
Adds a mid-list downward-drag test, the only case that separates the two
coordinate spaces.
2026-07-29 18:35:08 +02:00
FreeTLab 40c2763061 feat: functional redesign of the whole app (1.2.0)
Build & Deploy PluriWave / Análisis de código (push) Failing after 16s
Build & Deploy PluriWave / Build APK + AAB release (push) Skipped
Adopts the approved Claude Design handoff across every screen while keeping
the existing five-tab navigation and icon set.

- Design tokens, named type scale and a shared push-chrome scaffold
- Settings split from 1897 lines into a 198-line root plus 12 detail screens
- Escuchar gains an embedded player and a favorites grid
- Buscar gains the discovery landing state, filter pills and client-side sort
- New screens: countries browser, vacation manager, recordings library, welcome
- Equalizer restyled at 5 bands with custom presets; full player restructured
- Alarms, ringing screen and connectivity banners restyled
- All 466 message keys translated across 13 locales

Tests: 530 -> 747. Analyzer unchanged from baseline.
2026-07-29 18:18:33 +02:00
FreeTLab 3ec41bb31e feat(i18n): add redesign strings and translate Escuchar rename to 11 locales 2026-07-29 16:35:30 +02:00
FreeTLab 2959941485 fix(bienvenida): wire the welcome screen into the first-launch flow 2026-07-29 15:49:44 +02:00
FreeTLab f3d744aeed fix(ajustes): dispose sheet text controllers after the close animation
Pre-existing bug, reproduces identically before this branch's changes:
_editarGrupo (pantalla_ajustes_grupos_favoritos.dart) and
_editarTamanoMaximo (pantalla_ajustes_grabaciones.dart) each created a
TextEditingController, awaited showModalBottomSheet, then disposed the
controller immediately on resolve - racing the sheet's own close
animation, which still holds a bound TextField for a couple more
frames. Manifests as "A TextEditingController was used after being
disposed" plus a couple of cascading framework-internal symptoms.

Fix: extract each sheet's content into its own StatefulWidget
(_HojaEditarGrupo, _HojaTamanoMaximo) that owns the controller in its
own State. Flutter only calls State.dispose() once the widget is
actually removed from the tree, i.e. after the close animation
finishes, so there is no dispose-timing decision left for the caller
to get wrong.

_editarGrupo's own test previously suppressed the crash via a
FlutterError.onError override instead of fixing it; that suppression
is removed here. _editarTamanoMaximo had no coverage at all for this
interaction; added it. Strict TDD: confirmed both sites fail without
the fix (RED) before applying it (GREEN).
2026-07-29 15:22:15 +02:00
FreeTLab 9b415e73b0 feat(bienvenida): add monetization-free welcome screen
Build the first-run welcome surface from mockup screen 14, stripped of
its entire monetization block: no PRO pill, no "14 dias PRO gratis"
trial line, no pricing card, no secondary "free version" link. Ships
only logo, headline, body copy, exactly 3 feature bullets, and the
single "Empezar a escuchar" CTA, as a full-screen route (not a modal).

Spanish copy is re-cast from the mockup's "tu" form to the app's
established voseo register (matching ~750 existing app_es.arb lines),
using "auto" instead of "coche" per the one existing precedent. The 3
bullet icons reuse existing tokens (electricMagenta/liveGreen/
warmCoral) that already match the mockup's own hex values for them.

CTA switches to the Escuchar tab and pops the route. Wiring this
screen into the real first-launch flow (main.dart/app.dart) is left
for a follow-up unit, same shape as WU15/WU15b - this WU only covers
the isolated, tested screen per its own task list and verify command.

WU17.
2026-07-29 15:04:44 +02:00
FreeTLab 862197ab48 feat(connectivity): restyle offline and reconnect banners
Tint the mini player's reconnecting/error sub-states with the
offlineAccent token (added in WU1, unused until now): the status
label, the reconnect spinner, and the error retry icon now read as
visually distinct "connectivity trouble" states instead of blending
into the ordinary loading/paused look. Plain buffering keeps the
default colour, confirmed by a dedicated regression test.

Verify-first gate (task 16.1): ControladorReconexion.intentos exists,
but ServicioAudio never surfaces it past a debug log line, and its
estadoStream only carries the EstadoReproduccion enum. Adding an
attempt-count label would require a getter/stream on
servicio_audio.dart, one of the files this change must keep at an
empty diff against main. Ship the restyle without the counter, per
the risk register's own fallback.

WU16.
2026-07-29 14:43:34 +02:00
FreeTLab dc21732027 feat(reproductor): restructure full player with tool-tray and EQ sheet
Restructure pantalla_reproductor.dart onto PluriPushScaffold (design
ADR-2 - this screen is the documented single consumer of titleOverride,
a centered live/not-playing status pill, and the non-default
keyboard_arrow_down leadingIcon). Square art replaces the old circular
hero, favorite moves from the AppBar into the transport row (the
redundant live-indicator dot is dropped - the AppBar pill already covers
that signal), the old separate info chips collapse into a single
subtitle line, and a new quality row surfaces codec/bitrate with a
"Cambiar" action that reconnects the current stream (this app has no
per-station alternate-quality capability to invoke, so this reuses the
same reproducir() call the existing error-state retry button already
uses, rather than a dead button or an invented picker).

The always-expanded recording panel and the standalone sleep-timer
button both become tool-tray tiles (EQ propio / Grabar / sleep timer /
Compartir), each opening its own bottom sheet. "EQ propio" opens a sheet
hosting EcualizadorWidget - the exact same component WU13 restyled for
Settings, bound via the existing presetParaEmisora/guardarPresetPorEmisora
per-station persistence path. No second editor was created; the
multi-device-eq resolution hierarchy is untouched.

pantalla_reproductor.dart had zero test coverage before this commit (907
lines) - writing it first surfaced two pre-existing bugs blocking any
coverage at all, both fixed: initState called estado.reproducir()
directly, which notifies listeners synchronously before its first await
and threw "setState() during build" the instant the screen mounted
against a fresh Provider tree (fixed via addPostFrameCallback); and the
body Column had no scrollable ancestor and overflowed even a generously
tall viewport (fixed by wrapping it in a SingleChildScrollView, a real
UX improvement and not just a test workaround).

The three protected EQ test files (servicio_ecualizador_test.dart,
estado_ecualizador_test.dart, servicio_audio_eq_reapply_test.dart) stay
unmodified. Full suite: 730/730 green (2 skipped, unchanged), up from 713.

size:exception - realized 1,410 changed lines (25 files including this
docs update) against the 450-600 forecast: the restructured screen file
alone is 658 lines (a near-total rewrite of a 907-line file, not a
patch), its new test file (first-ever coverage) is 519 lines, and a new
test fake plus a togglePlay() override account for the rest. Not
splittable: the restructure, the tool tray, and the EQ-sheet wiring are
one cohesive change to one screen.
2026-07-29 14:20:27 +02:00
FreeTLab c9fe0ad651 feat(eq): restyle equalizer screen and add custom presets
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.
2026-07-29 12:53:12 +02:00
FreeTLab e1732af222 feat(alarma-sonando): restyle ringing screen, drop live countdown label
Replace the glass-card container with a full-bleed blurred-art
background, giant heroTime display, 3 fixed snooze tiles (3/5/10 min,
one highlighted), and a full-width stop pill. The status label now
also renders a static "Subiendo volumen" line - no seconds counter -
when the alarm has a configured fade-in, per resolution 4: the
native-to-Flutter progress channel a live counter would need is
deliberately absent from this architecture.

The dismiss guard and force-stop retry banner are untouched: the
banner is byte-identical to its pre-restyle form, only repositioned,
and the guard test's own diff against main stays empty.

size:exception: 530 changed lines (440+/90-) against the 200-300
forecast - lib/ alone is 302 lines, at the edge of the band; the two
touched test files account for the rest. Not split further: this is
one cohesive restyle to the single screen in this branch where an
inconsistent intermediate state is least acceptable.
2026-07-29 12:00:59 +02:00
FreeTLab a2121d84bd feat(alarmas): rewrite alarm editor with inline time widget
Replace the native showTimePicker dialog in the alarm editor sheet with
a giant inline HH:MM editor (drag/tap to adjust, wraps at 23:59-00:00).
Weekday circles now render unconditionally (disabled outside weekly
mode) instead of being gated behind an `if`.

The date field, fallback-station picker, and sound dropdown are not
dropped: per resolution 3 they move into a collapsed "Advanced" section
so the mockup's weekday-circles-only layout does not lose capability.
Volume/fade-in sliders get a cosmetic type-scale restyle only.

size:exception: 993 changed lines (891+/102-) against the 500-650
forecast - lib/ production code alone is 429 lines, within band; new
test files and 13 regenerated l10n/gen files account for the rest, the
same pattern every prior work unit in this branch has hit.
2026-07-29 11:43:15 +02:00
FreeTLab 332c2192cd chore(deps): sync pubspec.lock for meta 1.18.0 and test_api 0.7.11 2026-07-29 11:09:27 +02:00
FreeTLab 61d873f035 docs(sdd): record WU9's realized diff and size:exception
Same estimating lesson as every prior WU: the 350-450 forecast
covered the state/model/UI work, not the matching test files, the 14
regenerated lib/l10n/gen/ files, or dart format correcting pre-existing
drift in a touched test file (Dart SDK 3.12.0 skew, not new logic).
Realized 1,822 changed lines; recorded as an accepted size:exception
with a breakdown, not a scope-creep surprise.
2026-07-29 10:52:02 +02:00
FreeTLab 9dfcf0b428 feat(vacaciones): add vacation range manager screen
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.
2026-07-29 10:49:51 +02:00
FreeTLab a09d614f52 docs(sdd): record WU8's realized diff and size:exception
Same estimating lesson as WU3a/WU3b/WU15: the 400-550 forecast covered
only the screen restyle itself, not the matching test-file additions
and the 13 regenerated lib/l10n/gen/ files an ARB touch always drags
in. Realized 1,113 changed lines; recorded as an accepted
size:exception with justification, not a scope-creep surprise.
2026-07-29 10:22:14 +02:00
FreeTLab 9a2eb57a0e feat(alarmas): simplify alarm cards and add vacation summary row
Restyle the Alarmas root per the functional redesign: alarm cards drop
the always-visible edit/skip/delete button row for a minimal giant
time + station + switch layout. Tap opens the editor, swipe deletes
(with an AlertDialog confirmation), and the hero banner gains an
inline "Saltar" pill for the featured (soonest-firing) alarm's skip
action. No capability from the old button row is lost, only the
trigger location moved; estado_alarmas.dart and its scheduling/
snooze/dismiss-guard tests are untouched.

The vacation inline panel becomes a tappable summary row (range count
+ next-range countdown, computed over the existing estado.vacaciones)
that pushes a Vacaciones manager screen. That destination is a
placeholder for now (_PantallaVacacionesTemporal, holding the old
panel's body verbatim so add/delete-range capability is preserved) --
WU9 replaces it with the real PantallaVacaciones per design ADR-6.

New ARB keys (en/es only; other 11 locales are WU18's job):
alarmHeroSkipAction, alarmDeleteConfirmTitle/Message,
vacationRangesCount, vacationSummary{Active,Upcoming}Countdown.
2026-07-29 10:20:25 +02:00
FreeTLab 6653e044c7 docs(sdd): add anti-copy guard so WU18 cannot pass with untranslated values 2026-07-29 09:44:07 +02:00
FreeTLab c6f16c81b5 feat(paises): add country browser and extract shared radio transport
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/.
2026-07-29 09:36:31 +02:00
FreeTLab 9bd828139f feat(buscar): add discovery landing state, filter pills, counter, and sort 2026-07-29 08:45:36 +02:00
FreeTLab 3a803ce2bf feat(escuchar): replace discovery browser with embedded player and favorites grid
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.
2026-07-29 00:12:05 +02:00
FreeTLab 504a13641f feat(favoritos): replace stacked group panels with chip-filtered reorderable list
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).
2026-07-28 23:49:06 +02:00
FreeTLab ebdde7df01 fix(grabaciones): wire the recordings library into Settings navigation
WU15 shipped PantallaGrabaciones (the recordings library: storage bar,
recording rows, the "..." Rename/Share/Delete menu) fully tested but
reachable from nowhere in the app - a gap flagged in WU15's own
apply-progress notes, not fixed there since it needed a design
decision rather than a guess.

Coordinator ruling applied: the approved mockup's "Ajustes > Grabaciones"
screen depicts the library, not the folder/size settings form. So the
GRABACIONES Y MUSICA group's "Grabaciones" row in pantalla_ajustes.dart
now opens PantallaGrabaciones instead of PantallaAjustesGrabaciones.
The settings form is not dropped - it stays reachable, now from within
the library via a settings icon in its PluriPushScaffold actions,
matching the existing pantalla_ajustes_timer_sueno.dart "Add" action
precedent for a real capability living in the header.

One new ARB key (en/es only, per precedent): recordingsLibrarySettingsTooltip.

Tests: 604 -> 605 (one scenario re-targeted in pantalla_ajustes_test.dart,
one new scenario in pantalla_grabaciones_test.dart, which needed the
same ListTile-ink-assertion suppression helper WU3a/WU3b established
since it now pushes a ListTile-bearing settings screen). flutter
analyze unchanged at 1 pre-existing info.

Recorded in tasks.md as WU15b - not part of the original 18-unit plan,
added here to close the gap WU15 flagged.
2026-07-28 23:21:34 +02:00
FreeTLab 589fc54580 feat(grabaciones): add recordings library screen 2026-07-28 23:01:33 +02:00
FreeTLab c1903623be refactor(ajustes): split remaining Settings sections into pushed screens 2026-07-28 22:14:03 +02:00
FreeTLab 48ece948ff docs(sdd): correct WU3b estimate after WU3a's realized diff 2026-07-28 21:44:05 +02:00
FreeTLab bd2b7d8e02 refactor(ajustes): split Settings AUDIO/EMISORAS into pushed detail screens
Moves the AUDIO group (Ecualizador, Salida de audio, Temporizador de
sueno) and the EMISORAS group (Grupos de favoritos, Emisora preferida,
Emisoras personalizadas, Orden de listas) out of pantalla_ajustes.dart
into 7 new lib/pantallas/ajustes/*.dart screens, each wrapped in
PluriPushScaffold. The root now reaches them through FilaAjuste rows
under two new GrupoAjustes cards (lib/pantallas/ajustes/widgets/
fila_ajuste.dart), per design ADR-3.

Verbatim-move rule applied throughout: only each section's panel header
(icon + title, sometimes a status chip) was removed, since the pushed
screen's own 56px header now carries the title. Two sections whose
header row carried a real action (Temporizador de sueno's "Add",
Grupos de favoritos' "Add list", Emisoras personalizadas' "Add") kept
that action in the body instead of dropping it.

size:exception (move-only diff, pre-recorded at design/tasks time):
34 files, ~4250 changed lines excluding the 13 auto-regenerated l10n
files (~90 more lines there) - higher than the 800-1000 estimate
because that estimate covered the 7 production screens but not the
matching 7 new test files (task 3a.2), one of which relocates ~10
pre-existing device-management test cases verbatim. Business logic is
untouched; app.dart's import of pantalla_ajustes.dart is unchanged.

Correction to tasks.md 3a.1/3a.8: those two lines describe the combined
WU3a+WU3b end state ("4 grouped nav lists", "<400 lines"), matching
design ADR-3's own aggregate blast-radius note - not a WU3a-only claim.
This commit converts only the 2 groups that are WU3a's job; the root
is 788 lines with 5 sections (Grabaciones, Musica local, Idioma,
Backup, Info) still inline, reachable, and unchanged, pending WU3b.

Two new ARB keys (settingsGroupAudioTitle, settingsGroupStationsTitle),
en/es only per the WU1 precedent - all 7 detail-screen titles reuse
existing keys. Discovered and worked around, without touching app
code: Directory.systemTemp hangs real dart:io writes in this sandbox,
and pumpAndSettle() cannot settle while a screen shows an indeterminate
CircularProgressIndicator - both are test-only concerns, documented
inline where hit.

Tests: 560 -> 579 (32 in this commit's scope, net +19 after retiring
13 relocated cases from the old combined pantalla_ajustes_test.dart).
flutter analyze: unchanged at 1 pre-existing info. git diff is empty
for navegacion_auto.dart, servicio_ecualizador.dart and
servicio_audio.dart; pantalla_alarma_sonando_dismiss_guard_test.dart
untouched.
2026-07-28 21:38:20 +02:00
FreeTLab e99ecffb0c docs(sdd): scope dart format to touched files in all verify commands 2026-07-28 20:12:47 +02:00
FreeTLab 610016e935 test(auto): confirm Android Auto tree matches the redesign, no code changes
Re-ran test/servicios/navegacion_auto_test.dart (129 tests) unmodified —
all pass. git diff for lib/servicios/navegacion_auto.dart is empty; this
commit makes zero code changes.

Formally closes A1-A5 from the proposal's Android Auto surface review. The
app uses the classic MediaBrowserService model (automotive_app_desc.xml,
no androidx.car.app); Android Auto's system templates render everything,
and the only controllable surface is MediaItem metadata, which already
matches the redesign's intent. A4's waveform visualization is confirmed
non-implementable on this platform — MediaBrowserService cannot render
custom widgets, only system-templated browse/playback UI.
2026-07-28 20:06:33 +02:00
FreeTLab ef90a3b849 fix(tokens): correct offlineAccent to the proposal's specified #E8879A
WU1's commit invented an arbitrary value for offlineAccent instead of using
the exact hex the proposal's WU1 scope line already specifies. listSurface
and liveGreen were correctly de-literalised from existing theme.dart
literals; offlineAccent has no prior literal, but its value is still not
this file's discretion — the proposal states #E8879A explicitly.
2026-07-28 20:05:00 +02:00
FreeTLab 172b2a42ac feat(tokens): add design tokens, type scale, push scaffold, and root nav state 2026-07-28 20:01:23 +02:00
FreeTLab 6dba89432e docs(sdd): add work-unit task breakdown for the functional redesign 2026-07-28 18:26:11 +02:00
FreeTLab 35df016aa3 docs(sdd): add technical design for the functional redesign 2026-07-28 18:14:07 +02:00
FreeTLab 433373ad7f docs(sdd): add delta specs for the functional redesign 2026-07-28 18:06:46 +02:00
FreeTLab f58cf8739f docs(sdd): add functional redesign proposal 2026-07-28 18:02:07 +02:00
FreeTLab 675b7fb4b7 docs(design): add Claude Design handoff bundle for the functional redesign 2026-07-28 15:58:39 +02:00
FreeTLab 17f8e69529 chore: bump version to 1.2.0+122 before the functional redesign 2026-07-28 15:58:32 +02:00
FreeTLab b183b3f3e5 fix(eq): stop the enable toggle from landing behind a disk write
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.
2026-07-28 13:32:57 +02:00
ShanaiaBot c8f6162deb chore: bump version to 1.1.16+121 [ci skip] 2026-07-27 15:51:17 +02:00
FreeTLab 2403da3c2e refactor(auto): drop the in-car equalizer, keep EQ on the phone
Build & Deploy PluriWave / Análisis de código (push) Successful in 28s
Build & Deploy PluriWave / Build APK + AAB release (push) Successful in 2m22s
The car tree carried a whole equalizer feature: an `Ecualizador` root folder
with the six factory presets, a browsable `Personalizado` folder, five band
folders and thirteen gain leaves each, plus the media-id namespaces, routing
predicates, persistence-targeting and children-changed plumbing that fed
them. Equalization is a phone task; the driver should not be tuning 5 bands
from a car screen.

Removed: the `eq_preset:`/`eq_banda:`/`eq_gain:` namespaces and their
predicates and parsers, the `ecualizador`/`eq_custom` folder ids and their
getChildren branches, itemPresetEq/presetsEq/itemEqPersonalizado/bandasEq/
gananciasBandaEq, resolverPresetEq, presetPersonalizadoEfectivo,
dispositivoDestinoEq, debeAplicarPrincipalAhora/debeAplicarSeleccionAhora,
aplicarPresetPorMediaId, aplicarGananciaPorMediaId, and in the handler the
playFromMediaId branches, _presetPersonalizadoAuto, _dispositivoActivoAuto,
_dispositivoDestinoEqAuto and the subscribeToChildren/_hijosSubjects
notification machinery that existed only to refresh band titles after a gain
tap.

Deliberately KEPT: automatic per-device EQ. Reaching the car still applies
that device's preset, because that lives in EstadoEcualizador and the
output-device detection, not in this tree — it works with Android Auto or
without it. Configuring is what moves to the phone; applying stays automatic.

Also kept: the `eq_preset_*_v1` SharedPreferences keys in
ServicioEcualizador, which share a name with the deleted media-id prefix by
coincidence only and hold the phone's own presets.

The root folder set goes from five entries to four (three without local
music); its test now asserts no equalizer folder is offered at all, so a
reintroduction has to be deliberate.
2026-07-27 15:50:24 +02:00
ShanaiaBot d12dd49afe chore: bump version to 1.1.15+120 [ci skip] 2026-07-26 01:26:53 +02:00
FreeTLab 9b8209ac93 fix(radio): discover live API mirrors instead of hardcoding dead ones
Build & Deploy PluriWave / Análisis de código (push) Successful in 26s
Build & Deploy PluriWave / Build APK + AAB release (push) Successful in 1m34s
Two of the three Radio Browser hosts this client shipped no longer resolve.
The retry loop rotates de1 -> nl1 -> at1, so once the first attempt failed
for any transient reason the remaining two were guaranteed to fail as well:
the retries meant to add resilience had become a dead end, and a single blip
surfaced as "No connection to the radio API" with a healthy API and a healthy
network. The live mirror list confirms only one server remains:

  [{"ip":"91.98.4.78","name":"de1.api.radio-browser.info"},
   {"ip":"2a01:4f8:1c1d:699::1","name":"de1.api.radio-browser.info"}]

The API docs say exactly what this code was doing wrong: "Never use a direct
link to a single new server. It is much better to get a list of the servers",
pointing clients at all.api.radio-browser.info to enumerate what exists.

Seed with that round-robin host plus de1, then resolve the real list from
/json/servers once per instance and rotate over that. Discovery shares one
in-flight request across concurrent callers, because the home screen loads
two lists at once through Future.wait, and any failure silently leaves the
seed list in place — it still contains a working host, so a failed discovery
must never be worse than not trying. Explicitly injected servers disable
discovery so callers can still pin a mirror.

Build the User-Agent from the running package too. The API asks clients to
identify themselves, and this header claimed PluriWave/0.1.0 while the app
shipped 1.1.x. A literal cannot stay correct here — CI bumps the version on
every single release — so read it via package_info_plus, already a dependency
used in three other places. If package info is unavailable the product name
goes out alone rather than a made-up version, and resolution never throws: a
header must not be able to fail a request.
2026-07-26 01:26:10 +02:00
ShanaiaBot 30f4445235 chore: bump version to 1.1.14+119 [ci skip] 2026-07-25 20:44:26 +02:00
FreeTLab 4042cf5ffd fix(eq): stop the phone's FM sink from posing as the active output
Build & Deploy PluriWave / Análisis de código (push) Successful in 24s
Build & Deploy PluriWave / Build APK + AAB release (push) Successful in 2m23s
Regression from the previous commit. Ranking every AudioDeviceInfo type this
build does not name individually ABOVE the built-in speaker was meant to let
a car stereo on LE Audio or an automotive bus win. It also promoted the
internal sinks a phone exposes permanently: on the Xiaomi test device
AudioManager reports TYPE_FM (14) as an output, so getActiveAudioDevice
picked it over the real speaker with nothing connected at all. Confirmed on
device:

  audio_devices.onListen -> {id=other:14:4, type=14, name=2412DPC0AG}

It then reached Dart under an `other:14:4` id whose type is neither the base
speaker nor a known one, slipped past the collision guard and had a preset
row persisted for it -- reinstating the exact symptom this series set out to
kill: a permanent green active-output dot on a device that was not connected.

Replace the deny-by-omission ranking with an explicit allow list of outputs a
user actually connects. The built-in speaker sits below all of them and above
everything else, so any sink that physically exists but is never where media
plays (TYPE_FM, TYPE_BUILTIN_SPEAKER_SAFE, telephony, remote submix) can no
longer be selected. A one-time purge clears the `other:` rows the bad build
persisted; genuine ones re-register on their next connection.

Fix the USB type constant while here: TYPE_USB_HEADSET is 22, not 14, and 14
is TYPE_FM. The Kotlin USB branch hardcoded 14 and the Dart type table
mirrored the same mistake, so the two cancelled out for real USB headsets
while making a phone's own FM sink decode as USB audio. Both now use 22.

Verified with javap against android.jar (android-36) rather than trusting the
comment that introduced the error.
2026-07-25 20:43:47 +02:00
ShanaiaBot c94bc3d770 chore: bump version to 1.1.13+118 [ci skip] 2026-07-25 17:03:39 +02:00
FreeTLab 4f91f490b8 feat(eq): name Bluetooth devices from the system pairing list
Build & Deploy PluriWave / Análisis de código (push) Successful in 25s
Build & Deploy PluriWave / Build APK + AAB release (push) Successful in 1m34s
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.
2026-07-25 17:01:40 +02:00
ShanaiaBot 2391fb767b chore: bump version to 1.1.12+117 [ci skip] 2026-07-25 16:11:27 +02:00
FreeTLab 39ead7bea4 fix(eq): stop the phone speaker from impersonating a Bluetooth device
Build & Deploy PluriWave / Análisis de código (push) Successful in 27s
Build & Deploy PluriWave / Build APK + AAB release (push) Successful in 1m39s
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.
2026-07-25 16:10:36 +02:00
ShanaiaBot eee2ae98d0 chore: bump version to 1.1.11+116 [ci skip] 2026-07-25 15:07:54 +02:00
FreeTLab 1e33a79724 fix(recordings): open the recordings folder from the system file manager
Build & Deploy PluriWave / Análisis de código (push) Successful in 25s
Build & Deploy PluriWave / Build APK + AAB release (push) Successful in 1m47s
The recordings live in app-private storage (<data>/app_flutter/grabaciones),
which the Android sandbox forbids any other app from reading, so no
ACTION_VIEW on a file:// or FileProvider URI could ever open it. On top of
that, viewDirectory built an EMPTY candidate list for that path:
directoryDocumentUri returned null (path outside external storage) and
FileProvider.getUriForFile threw because pluriwave_file_paths.xml never
covered app_flutter. The loop never ran, so both entry points -- the radio
recorder and Settings -- always showed "could not open the folder".

Publish the folder as a browsable storage root via
RecordingsDocumentsProvider instead. The files never leave private storage;
the document framework asks us for them one document at a time, and the user
can browse, copy out, rename and delete straight from the file manager. The
root follows a user-configured path and falls back to the default recordings
directory. Its title reuses the already-translated recordingsFolderTitle, so
no new literal is introduced in any of the 13 locales.

Also fixes "open last recording", broken by the same missing FileProvider
root, and replaces Intent.createChooser with a bare startActivity in the
candidate loop: a chooser never throws when nothing can handle the intent, so
the first candidate always "succeeded" and the fallback chain never ran.

Device QA pending -- the provider is driven entirely by the platform's
document framework, so no unit test covers it. Each candidate logs its own
name under file_actions.viewDirectory for logcat triage.
2026-07-25 15:07:10 +02:00
ShanaiaBot 321362b1bf chore: bump version to 1.1.10+115 [ci skip] 2026-07-25 13:52:58 +02:00
FreeTLab d0abe32eef fix(audio): survive audio_service init hang on Android Auto cold start
Build & Deploy PluriWave / Análisis de código (push) Successful in 27s
Build & Deploy PluriWave / Build APK + AAB release (push) Successful in 2m23s
AudioService.init has no internal timeout and an unhandled
onConnectionSuspended case in its MediaBrowser self-bind; under bind
contention with the car's connection it can hang forever, so runApp
never ran (black car screen, white phone UI until process kill).

Race init against an 8s timeout without ever re-calling it: on timeout
run a bootstrap app that waits on the same future, wires the handler
exactly once when it resolves, reports errors via FlutterError, and
swaps to the real app. Auto browse sources now register before the
init await since they take no handler dependency.
2026-07-25 13:43:40 +02:00
ShanaiaBot 37dee8cb5a chore: bump version to 1.1.9+114 [ci skip] 2026-07-24 11:17:21 +02:00
Javier Bautista Fernández 87acfae069 fix(alarm): declare pendingMissedIntent nullable to stop STOP_NATIVE NPE
Build & Deploy PluriWave / Análisis de código (push) Successful in 31s
Build & Deploy PluriWave / Build APK + AAB release (push) Successful in 2m27s
cancelAutoSilence calls pendingMissedIntent with FLAG_NO_CREATE, the
one Android flag whose entire purpose is to make getBroadcast() return
null on no match. The Kotlin signature declared a non-null return type,
so the compiler-inserted assertion threw NPE inside onStartCommand,
crashing the process before the Dart-side postpone flow could reach
its actual +N-minute reschedule call.
2026-07-24 11:16:36 +02:00
ShanaiaBot f950da789a chore: bump version to 1.1.8+113 [ci skip] 2026-07-23 00:02:15 +02:00
Javier Bautista Fernández 3d48da77f5 docs(sdd): add alarm-system-overhaul verify report (PASS WITH WARNINGS, device QA pending)
Build & Deploy PluriWave / Análisis de código (push) Successful in 24s
Build & Deploy PluriWave / Build APK + AAB release (push) Successful in 1m43s
2026-07-23 00:01:39 +02:00
ShanaiaBot c3b7a302e7 chore: bump version to 1.1.7+112 [ci skip] 2026-07-22 23:53:17 +02:00
Javier Bautista Fernández 29f7d54e85 fix(alarm): fail-safe alarm system overhaul (SDD alarm-system-overhaul, slice A)
Build & Deploy PluriWave / Análisis de código (push) Successful in 25s
Build & Deploy PluriWave / Build APK + AAB release (push) Successful in 2m25s
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.
2026-07-22 23:52:36 +02:00
ShanaiaBot 0f9a6a1719 chore: bump version to 1.1.6+111 [ci skip] 2026-07-22 10:26:48 +02:00
Javier Bautista Fernández 163ff69f7a feat(eq): android auto custom equalizer and robust device detection
Build & Deploy PluriWave / Análisis de código (push) Successful in 26s
Build & Deploy PluriWave / Build APK + AAB release (push) Successful in 2m16s
- 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.
2026-07-22 10:26:02 +02:00
ShanaiaBot 3473034b58 chore: bump version to 1.1.5+110 [ci skip] 2026-07-21 10:05:18 +02:00
Javier Bautista Fernández 90b75c1825 chore(l10n): add CI guard against ARB placeholder corruption
Build & Deploy PluriWave / Análisis de código (push) Successful in 24s
Build & Deploy PluriWave / Build APK + AAB release (push) Successful in 1m45s
Adds tool/check_arb_placeholder_corruption.py, a static check that flags
literal "?" glued to an ICU placeholder brace in any lib/l10n/app_*.arb
value that has a placeholders metadata block. This is the exact corruption
shape fixed in the previous commit; flutter analyze doesn't catch it since
the JSON/ICU stays syntactically valid. Wired as a CI step before
flutter analyze so it fails fast.

Also audited lib/l10n/app_localizations_ext.dart (hand-maintained weekday/
month/date-sentence maps, not covered by ARB tooling): all 22 locale maps
have the full 13/13 keys with no corruption or leftover English — no
changes needed there.
2026-07-21 10:04:41 +02:00
ShanaiaBot 689a386403 chore: bump version to 1.1.4+109 [ci skip] 2026-07-21 09:56:59 +02:00
Javier Bautista Fernández fb7fe8774b fix(l10n): repair corrupted duration abbreviations in ar/bn/hi/ja/ru/zh ARB files
Build & Deploy PluriWave / Análisis de código (push) Successful in 25s
Build & Deploy PluriWave / Build APK + AAB release (push) Successful in 2m24s
Sleep-timer duration strings (durationHoursMinutesSeconds, durationMinutesSeconds,
durationMinutesOnly, durationSecondsOnly) contained literal "?" characters instead
of the native hour/minute/second abbreviation in 6 locales. Replaced with correct
native short-form units per locale, verified byte-exact against a pinned spec table
and against the already-correct neighboring hoursLabel/minutesLabel/secondsLabel
values in each file.
2026-07-21 09:56:18 +02:00
ShanaiaBot 12967e9894 chore: bump version to 1.1.3+108 [ci skip] 2026-07-20 12:04:33 +02:00
Javier Bautista Fernández 7daa6cfdb6 fix(auto): clear stuck bluetooth EQ selection on device disconnect
Build & Deploy PluriWave / Análisis de código (push) Successful in 24s
Build & Deploy PluriWave / Build APK + AAB release (push) Successful in 1m40s
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.
2026-07-20 12:03:59 +02:00
ShanaiaBot 09e0216874 chore: bump version to 1.1.2+107 [ci skip] 2026-07-20 10:22:52 +02:00
Javier Bautista Fernández 2a5030431b fix(android): break literal /* in KDoc that unclosed the comment
Build & Deploy PluriWave / Análisis de código (push) Successful in 24s
Build & Deploy PluriWave / Build APK + AAB release (push) Successful in 2m15s
Kotlin nests block comments, so the literal `audio/*` text inside a
KDoc comment opened a phantom nested comment. The closing */ two
lines later closed that nested one instead, leaving the real KDoc
open for the rest of the file and breaking release compilation.
2026-07-20 10:22:10 +02:00
ShanaiaBot dca3a1107e chore: bump version to 1.1.1+106 [ci skip] 2026-07-20 01:16:35 +02:00
FreeTLab 49def4b276 docs(openspec): archive android-auto-local-music-phase3
Build & Deploy PluriWave / Análisis de código (push) Successful in 28s
Build & Deploy PluriWave / Build APK + AAB release (push) Failing after 1m18s
Merges its delta requirements into the android-auto-media base spec.
Completes the 3-phase local-music-in-Android-Auto feature. Standing
pre-release gate: on-device/DHU validation of every native surface
built across all 4 phases (SAF picking, metadata extraction, art
cache, queue/shuffle handler wiring) is still outstanding.
2026-07-20 01:15:46 +02:00
FreeTLab dfd40ca937 feat(auto): queue playback and shuffle for local music folders [size:exception]
Adds "Reproducir carpeta" (sequential) and "Reproducir aleatorio"
(Fisher-Yates over the name-sorted order) as folder-scoped playable
actions, with auto-advance on track completion and skip next/prev.

Isolation from live radio is structural, not disciplinary: the
public playMediaItem always clears the local queue on any call, and
a new private _encolarCambioFuente is the only path that can advance
within it. _cambiarFuente, ControladorReconexion, and the reconnect
error path are untouched -- confirmed by a byte-for-byte empty diff
on all 4 pre-existing radio/reconnect regression suites, independently
re-run before and after (21/21 both times).

Handler wiring itself is static-review-only (PluriWaveAudioHandler
can't be unit-instantiated); the isolation/advance/race-guard
decision logic is extracted into cola_local.dart's pure functions,
which are fully unit-tested.
2026-07-20 01:08:15 +02:00
FreeTLab 85dd043cd4 docs(openspec): archive android-auto-local-music-phase2
Merges its delta requirements into the android-auto-media base spec.
Completes Phase 2; only Phase 3 (subfolder scoping, shuffle) remains.
On-device validation of the native metadata path is still an
outstanding pre-release gate across all local-music phases shipped
today.
2026-07-19 23:59:21 +02:00
FreeTLab 352eb9fc37 feat(auto): real metadata, quality sort and name buckets for local music [size:exception]
Local tracks now show embedded title/artist/album art (via native
MediaMetadataRetriever, cached through the existing FileProvider)
instead of the raw filename, falling back gracefully when a file
has no usable tags. Adds two navigable entry points per folder: sort
by audio quality (bitrate, capped at 150 tracks per folder to bound
worst-case latency) and alphabetical name buckets -- the closest
realistic form of "filtering" given Android Auto has no text-search
UI in this integration.

Metadata resolves only for the page actually being browsed (same
slice-cheap-then-map discipline as the paging change), backed by a
flat 256-entry LRU session cache that survives across pages. No new
permission, no new pub dependency, no l10n changes (car-tree labels
stay hardcoded Spanish, matching every existing label in the tree).
2026-07-19 23:52:08 +02:00
FreeTLab e030a0975d docs(openspec): archive android-auto-local-music-paging
Merges its delta requirements into the android-auto-media base spec.
This closes out Phase-1 polish for local music; Phase 2 (metadata,
sort/filter, real art) and Phase 3 (subfolder scoping, shuffle)
remain the only planned future work for this feature.
2026-07-19 22:21:45 +02:00
FreeTLab 725169cd31 feat(auto): page local-music folders instead of truncating at 50 [size:exception]
Folders over the 50-item cap now show a "Mas..." item that reveals
the next page on tap, instead of silently dropping the rest. Paging
slices the cheap raw list before building any MediaItem, so items
beyond the requested page are never resolved (art, title) -- proven
by a call-count test. Also swaps the raw SAF content:// URI shown in
settings for a parsed, human-readable folder name with a localized
fallback across all 13 locales.

servicio_audio.dart is untouched; this stays entirely within the
local-music tree/dispatch layer.
2026-07-19 22:14:05 +02:00
FreeTLab 977cbcd8cc docs(openspec): archive android-auto-local-music Phase 1
Merges its delta requirements into the android-auto-media base spec.
Phases 2 (metadata/sort/filter/art) and 3 (subfolder scoping/shuffle)
remain planned future work.
2026-07-19 20:37:54 +02:00
FreeTLab 6ae7e378c4 feat(auto): browse and play local music folders in Android Auto [size:exception]
Phase 1: pick a device folder via SAF (persisted grant, no new
permission), browse its nested subfolders/tracks as a 5th Android
Auto root folder (hidden until configured), and play tracks through
the existing pipeline (EQ, art rotation, cold-start-safe source).
No metadata/sort/filter/shuffle yet -- filename is the title, generic
rotating art is the placeholder; deferred to a follow-up phase.

Adds a new pluriwave/file_actions native method (listAudioChildren)
and an onActivityResult override in MainActivity for the SAF folder
picker -- both static-review-only, no Android build available here.
2026-07-19 20:30:50 +02:00
FreeTLab 99897ec848 chore(release): open 1.1.0 development line [version set]
Marks the start of the local-folder music playback feature work.
2026-07-19 18:34:21 +02:00
FreeTLab 9bfa9ac408 docs(openspec): archive android-auto-eq-presets
Merges its delta requirements into the android-auto-media base spec.
2026-07-19 14:19:11 +02:00
FreeTLab 90cd232ad2 feat(auto): expose EQ presets as a browsable Android Auto folder
Adds an Ecualizador folder listing the 6 fixed presets; selecting one
applies and persists it through the existing headless-safe seam
without touching playback or the now-playing media item.
2026-07-19 14:12:39 +02:00
FreeTLab 066fedb7bc docs(openspec): archive android-auto-favorite-groups
Merges its delta requirements into the android-auto-media base spec.
2026-07-19 13:47:39 +02:00
FreeTLab f368bcc777 feat(auto): surface favorite groups as Android Auto sub-folders
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.
2026-07-19 13:42:25 +02:00
FreeTLab f9003436ea docs(openspec): archive android-auto-media and auto-media-art-quality
Promotes the android-auto-media capability spec to openspec/specs/
and moves both completed changes into openspec/changes/archive/.
2026-07-19 13:22:18 +02:00
FreeTLab c193650cc4 fix(auto): fall back to brand art and surface quality on dead/missing favicons
Android Auto no longer copies the launcher icon as placeholder art; it
rotates through the same 4 on-brand station_art assets the phone UI
already uses, keyed by the same per-station hash for visual parity.
Malformed or unusable favicon URLs (including a Dart Uri quirk where
'http://' reports hasAuthority=true with an empty host) now fail the
validity gate instead of being handed to the OS media browser as-is.
Browsable items also show codec/bitrate as a subtitle when known.
2026-07-19 13:06:18 +02:00
ShanaiaBot 08cae2a5d4 chore: bump version to 1.0.1+105 [ci skip] 2026-07-16 16:29:34 +02:00
Javier Bautista Fernández 07c6e32af0 docs(auto): android auto research guide and sdd artifacts for android-auto-media
Build & Deploy PluriWave / Análisis de código (push) Successful in 26s
Build & Deploy PluriWave / Build APK + AAB release (push) Successful in 2m22s
2026-07-16 16:28:54 +02:00
Javier Bautista Fernández 35bb180612 feat(auto): browsable Android Auto media tree with play-by-id [size:exception]
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.
2026-07-16 16:28:44 +02:00
ShanaiaBot 43781274ce chore: bump version to 1.0.0+104 [ci skip] 2026-07-15 15:44:16 +02:00
Javier Bautista Fernández 03de273369 chore(release): promote to 1.0.0 stable [version set]
Build & Deploy PluriWave / Análisis de código (push) Successful in 47s
Build & Deploy PluriWave / Build APK + AAB release (push) Successful in 2m23s
The app is stable, so mark this as the 1.0.0 milestone. The CI bump step
now honors a [version set] marker: when present it ships the pinned semver
as-is and only advances the build number (Play requires it monotonic),
instead of the automatic patch bump that cannot cross the 0.x -> 1.0.0
boundary. Normal commits keep auto-incrementing the patch (1.0.1, 1.0.2, ...).
2026-07-15 15:43:16 +02:00
ShanaiaBot f1bb54d25a chore: bump version to 0.1.102+103 [ci skip] 2026-07-14 15:19:47 +02:00
Javier Bautista Fernández 38d78fc4f8 fix(alarm): honor the persisted trigger on reschedule so app updates stop re-arming the wrong day
Build & Deploy PluriWave / Análisis de código (push) Successful in 40s
Build & Deploy PluriWave / Build APK + AAB release (push) Successful in 2m30s
reschedulePersistedAlarms re-armed every stored alarm through the native
recompute engine (trustDartTrigger defaulted to false). That engine can
diverge from Dart's next-occurrence verdict and arm the alarm for the
wrong day, so it silently never fires. Because this runs on boot, unlock
and ACTION_MY_PACKAGE_REPLACED (which fires on every app install), each
new build re-broke correctly-armed alarms while snooze kept working
(snooze never touches the recompute for its trigger value).

Trust the persisted trigger (Dart's own verdict, saved when the alarm was
last armed) whenever it is still in the future; a genuinely stale past
trigger still falls back to the native recompute inside scheduleSpec.
2026-07-14 15:18:38 +02:00
ShanaiaBot 448fbec354 chore: bump version to 0.1.101+102 [ci skip] 2026-07-12 23:33:36 +02:00
FreeTLab d8e67a5204 fix(alarm): make all date math wall-clock correct across DST and timezone changes
Build & Deploy PluriWave / Análisis de código (push) Successful in 38s
Build & Deploy PluriWave / Build APK + AAB release (push) Successful in 1m46s
Full time-domain audit (three shipped date bugs prompted it) found one
root cause and two latent travel defects, all now fixed:

Day-stepping used add(Duration(days: 1)), which shifts the absolute
instant by exactly 86400s — documented Dart behavior (sdk#47666), so
crossing a DST transition drifted the wall hour by +-1h permanently
for the rest of the candidate scan (verified: 2026-03-28 07:30
Europe/Madrid + "1 day" = 08:30). The native Calendar engine preserves
wall time, and the single-authority fix made the drifted Dart verdict
win. Candidates now advance by calendar reconstruction (_siguienteDia:
DateTime(y, m, d+1, hora, minuto)), the same wall-clock-preserving
semantics as Calendar.add(DAY_OF_YEAR, 1) plus AOSP DeskClock's
defensive hour/minute re-assertion, keeping both engines in agreement
through any transition.

Instant-valued fields (snoozeHasta/snoozeOrigen/proximaEjecucion/
ultimaEjecucionGestionada/creadaEn/actualizadaEn) serialized as
offset-less local ISO, so re-parsing after a device timezone change
reinterpreted the same wall fields as a different instant. They now
serialize as UTC ("Z"); reads normalize to local, and legacy
offset-less payloads parse identically — no migration. fechaUnica
stays local on purpose: it is a wall-clock date.

One-shot alarms sent fechaUnica's midnight epoch to the native side,
whose boot/travel re-arm derives the calendar day back from it in the
CURRENT zone — a westward shift rolled the date to the previous day.
The channel now anchors the date at local noon, keeping it stable
across real-world zone shifts.

Property tests lock the no-drift guarantee (400 daily / 200 weekday
iterations must all land exactly at hora:minuto — on DST-observing
dev machines this crosses real transitions), plus UTC round-trip,
legacy-payload compatibility, and wall-date preservation tests.
2026-07-12 23:32:32 +02:00
ShanaiaBot e84cd2d7ed chore: bump version to 0.1.100+101 [ci skip] 2026-07-12 23:18:26 +02:00
FreeTLab 9c7cf4e261 fix(alarm): anchor snooze to the ringing occurrence, never a future one
Build & Deploy PluriWave / Análisis de código (push) Successful in 37s
Build & Deploy PluriWave / Build APK + AAB release (push) Successful in 1m49s
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.
2026-07-12 23:17:26 +02:00
ShanaiaBot 597c98d0e7 chore: bump version to 0.1.99+100 [ci skip] 2026-07-12 23:07:11 +02:00
FreeTLab 8741cdba5f fix(alarm): honor Dart's next-occurrence verdict on fresh schedule calls
Build & Deploy PluriWave / Análisis de código (push) Successful in 39s
Build & Deploy PluriWave / Build APK + AAB release (push) Successful in 2m32s
The system ran two independent next-occurrence engines: Dart computes
proximaProgramable (what the UI shows) and sends it as triggerAtMillis,
but the native scheduleAlarm discarded it and recomputed from
hour/minute/weekdays. Two engines over the same data WILL diverge —
observed on-device: Dart said "today 22:48", the native weekday scan
armed next Friday, and the alarm silently never rang at its hour while
snooze (which bypasses recomputation and obeys a timestamp) always
worked. That asymmetry was the user-visible "saving an alarm breaks,
snoozing works" split.

Fresh channel calls now arm exactly the trigger Dart sent whenever it
is in the future or within the shared 90s imminence window; the native
recompute remains as the fallback for stale triggers and for
autonomous re-arms with no fresh Dart data (onAlarmFired's next
occurrence, boot/persisted reschedules). Snooze preservation is
untouched: a live native snooze still short-circuits through the
compute path. Also logs the weekdays/trigger/lastHandled payload on
every schedule call so day-convention divergences are diagnosable
from logcat.
2026-07-12 23:05:56 +02:00
ShanaiaBot 3fd5080cd1 chore: bump version to 0.1.98+99 [ci skip] 2026-07-12 12:37:23 +02:00
FreeTLab 41b95fed44 docs(openspec): archive native-alarm-ring and update the native-alarms spec
Build & Deploy PluriWave / Análisis de código (push) Successful in 38s
Build & Deploy PluriWave / Build APK + AAB release (push) Successful in 1m45s
Close the SDD cycle for the ring architecture replacement: verified
with one critical (channel silence by omission) fixed and re-checked
before archive, delta merged into the main native-alarms spec (2
requirements removed, 5 added), artifacts archived byte-for-byte.
Phase 3 on-device QA (9 items) remains the mandatory human gate.
2026-07-12 12:36:22 +02:00
ShanaiaBot a788eabfcb chore: bump version to 0.1.97+98 [ci skip] 2026-07-12 12:21:42 +02:00
FreeTLab 6f07e27905 fix(alarm): silence the fire channel explicitly instead of by omission
Build & Deploy PluriWave / Análisis de código (push) Successful in 36s
Build & Deploy PluriWave / Build APK + AAB release (push) Successful in 1m51s
Omitting setSound on a NotificationChannel leaves the platform DEFAULT
notification sound active — omission is not silence. The v3 channel
now calls setSound(null, null) exactly like the pre-notice channel
does, so the native STREAM_ALARM player stays the ring's only audible
source. Caught by verification against design D4 before any build.
2026-07-12 12:20:42 +02:00
ShanaiaBot 8a4a8bd5d7 chore: bump version to 0.1.96+97 [ci skip] 2026-07-12 12:03:57 +02:00
FreeTLab 884567beaa feat(alarm): native-only ring with DeskClock fade curve and silent channel
Build & Deploy PluriWave / Análisis de código (push) Successful in 42s
Build & Deploy PluriWave / Build APK + AAB release (push) Successful in 2m1s
PluriWaveAlarmService becomes the sole ring-audio owner for the whole
ring (WU2 of 2, completes the split started at bd7f883): the linear
step ramp (initialVolume/startFadeIn, 250ms steps) is replaced by a
DeskClock-style exponential dB curve (computeFadeVolume: gainDb =
fraction*40-40, curve = 10^(gainDb/20)) on a 50ms Handler loop anchored
at ring start, not audio start, so all three fallback sources (station,
fallback station, bundled WAV) share one clock and a source that joins
mid-fade enters at the elapsed level instead of restarting from
silence. Each source also recomputes and applies the curve immediately
before start() to stay pop-free through prepareAsync's variable
buffering delay.

The STREAM_MUSIC device-volume override/restore is replaced by manual
AUDIOFOCUS_GAIN_TRANSIENT request/abandon on STREAM_ALARM (no-op focus
listener, requested once per ring in startAudio, abandoned
unconditionally in stopAlarm's full-teardown branch): the service never
calls setStreamVolume on any stream. The fire notification channel
migrates pluriwave_alarm_fire_v2 -> pluriwave_alarm_fire_v3, now silent
(no setSound; native MediaPlayer is the only audible source) while
keeping vibration and IMPORTANCE_HIGH for the full-screen intent; the
migration guard folds in a third delete for the v2 id alongside the two
pre-existing legacy ids, guarded by a renamed channels_migrated_v3 flag
so it still runs exactly once.

Since the Dart ringing screen (WU1) no longer calls confirmFlutterAudio,
overrideMediaVolumeForRing or restoreMediaVolume, their native surface
is now dead: deletes the flutterOwnsRing handoff flag and both its
backstop call sites in PluriWaveAlarmService, and the three
MethodChannel handlers plus their backing methods and companion state
in MainActivity.
2026-07-12 12:02:45 +02:00
ShanaiaBot 5bd861d7fb chore: bump version to 0.1.95+96 [ci skip] 2026-07-12 11:37:53 +02:00
FreeTLab bd7f883118 refactor(alarm): make the ringing screen pure UI over a reduced native port
Build & Deploy PluriWave / Análisis de código (push) Successful in 36s
Build & Deploy PluriWave / Build APK + AAB release (push) Successful in 2m40s
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.
2026-07-12 11:36:31 +02:00
ShanaiaBot 836fb44adb chore: bump version to 0.1.94+95 [ci skip] 2026-07-12 00:36:40 +02:00
FreeTLab 2e64740b26 fix(alarm): anchor the fade at alarm time and defer the override to first audio
Build & Deploy PluriWave / Análisis de código (push) Successful in 36s
Build & Deploy PluriWave / Build APK + AAB release (push) Successful in 1m41s
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.
2026-07-12 00:35:29 +02:00
ShanaiaBot f73a12ad48 chore: bump version to 0.1.93+94 [ci skip] 2026-07-12 00:05:54 +02:00
FreeTLab 86225cbc68 fix(alarm): scope native stop to the ringing id and intercept system back
Build & Deploy PluriWave / Análisis de código (push) Successful in 36s
Build & Deploy PluriWave / Build APK + AAB release (push) Successful in 1m43s
Two exit-path holes found by adversarial review before the next build:

PluriWaveAlarmService.stopAlarm() never compared the requested id to
activeAlarmId, so any stop request for a DIFFERENT alarm tore down
whichever ring was active: with two alarms firing close together, the
second one's routine hide-notification call (via dismissAlarmNotification
-> ACTION_STOP) killed the first alarm mid-ring and prematurely restored
the device volume override. A mismatched id now only cancels that id's
notification and returns; null keeps full-teardown semantics for
internal/onDestroy callers.

The ringing screen never intercepted the system back gesture: a plain
route pop ran only dispose(), leaving the shared radio player ringing
with no alarm UI left anywhere to stop it. Back now routes through
PopScope into the same _detener() flow as the Stop button, guarded by a
single-exit flag so a back-press racing a button tap cannot run the
teardown twice and pop the route underneath.

Also resets the shared handler gain to 1.0 on ring exit: the fade-in
mutates the radio player's persistent volume, and exiting mid-ramp used
to leave every later radio play at the partial ramp level.
2026-07-12 00:04:53 +02:00
ShanaiaBot e84a41acd4 chore: bump version to 0.1.92+93 [ci skip] 2026-07-11 23:33:15 +02:00
FreeTLab 1b1b692f04 fix(alarm): apply the configured fraction to the ring volume override
Build & Deploy PluriWave / Análisis de código (push) Successful in 37s
Build & Deploy PluriWave / Build APK + AAB release (push) Successful in 1m45s
overrideMediaVolumeForRing parsed and logged the fraction argument but
called the no-arg override, which always forced STREAM_MUSIC to the
device maximum. On-device logcat confirmed it: the Dart side sent
fraction=0.5 yet the stream was set to index=30 of 30. Combined with
the player now ramping to its full range, the ring peaked at 100% of
the device maximum instead of the configured 50%.

The override now sets the stream to round(max * fraction), clamped to
at least 1 so rounding can never mute the ring. With fraction=0.5 the
stream caps at half the device maximum and the player ramps up to that
cap, so the ring peaks at the configured level and the opening buffer
click drops to the configured fraction rather than full scale.
2026-07-11 23:32:05 +02:00
ShanaiaBot 5f65d068a8 chore: bump version to 0.1.91+92 [ci skip] 2026-07-11 23:03:13 +02:00
FreeTLab 3ebb41aa9d fix(alarm): arm a just-passed occurrence instead of skipping it a day
Build & Deploy PluriWave / Análisis de código (push) Successful in 35s
Build & Deploy PluriWave / Build APK + AAB release (push) Successful in 1m42s
The native next-occurrence recompute required the trigger to be
strictly in the future, while the Dart side keeps an occurrence whose
trigger passed within a 90s tolerance. When the periodic resync
re-armed an alarm microseconds after its trigger (app foregrounded,
the 60s tick straddling the trigger instant), computeNextTriggerMillis
recomputed the next weekday/daily occurrence as tomorrow and, through
the shared FLAG_UPDATE_CURRENT fire PendingIntent, replaced the
in-flight fire before AlarmManager delivered it. The alarm never rang
until the screen was turned on and the Dart watchdog caught it late.

computeNextTriggerMillis now mirrors Dart's toleranciaDisparoInminente:
base is lowered by a 90s grace window so a just-passed occurrence is
armed (and delivered ~immediately) rather than pushed to the next day.
The handledFloor (lastHandledAtMillis + 60s) stays a hard lower bound,
so an already-fired occurrence can never be re-selected — no
double-fire. Dart contract tests lock the boundary the native constant
must track. Native verification is on-device (no JVM test harness).
2026-07-11 23:02:14 +02:00
ShanaiaBot 812922d7f1 chore: bump version to 0.1.90+91 [ci skip] 2026-07-11 22:49:56 +02:00
FreeTLab 5291221fc8 fix(alarm): cap the ring at the configured volume instead of the device max
Build & Deploy PluriWave / Análisis de código (push) Successful in 35s
Build & Deploy PluriWave / Build APK + AAB release (push) Successful in 1m48s
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.
2026-07-11 22:48:56 +02:00
ShanaiaBot 2c28f1696a chore: bump version to 0.1.89+90 [ci skip] 2026-07-11 22:27:25 +02:00
FreeTLab b294b287c7 fix(alarm): stop a duplicate fire event from killing the active ring
Build & Deploy PluriWave / Análisis de código (push) Successful in 40s
Build & Deploy PluriWave / Build APK + AAB release (push) Successful in 2m31s
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.
2026-07-11 22:26:13 +02:00
ShanaiaBot b7af1064cc chore: bump version to 0.1.88+89 [ci skip] 2026-07-11 17:35:37 +02:00
FreeTLab c65497e58a docs(openspec): archive persistence-corruption-guard and promote its spec
Build & Deploy PluriWave / Análisis de código (push) Successful in 36s
Build & Deploy PluriWave / Build APK + AAB release (push) Successful in 1m49s
Close the SDD cycle for the data-loss shielding change: verified pass
with warnings (0 critical, 10/10 scenarios with named tests, W1 fixed
post-verify), three stacked work units shipped plus the path-resolution
fix. The persistence-resilience capability spec is promoted to
openspec/specs/.
2026-07-11 17:34:36 +02:00
ShanaiaBot 762e740c89 chore: bump version to 0.1.87+88 [ci skip] 2026-07-11 17:21:09 +02:00
FreeTLab 48e74e6bfe fix(radio): treat custom-station path resolution as part of the IO surface
Build & Deploy PluriWave / Análisis de código (push) Successful in 36s
Build & Deploy PluriWave / Build APK + AAB release (push) Successful in 2m30s
_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.
2026-07-11 17:19:44 +02:00
ShanaiaBot d824ef5c21 chore: bump version to 0.1.86+87 [ci skip] 2026-07-11 13:01:16 +02:00
FreeTLab 45b7fc8741 fix(eq): keep valid presets when stored maps are partially corrupt
Build & Deploy PluriWave / Build APK + AAB release (push) Successful in 1m39s
Build & Deploy PluriWave / Análisis de código (push) Successful in 36s
Convert the 4 EQ persistence readers (device presets, matrix presets,
device names, per-station presets) to per-entry tolerant parsing via
the shared persistencia_tolerante helper, so one corrupt entry no
longer discards every sibling preset. The principal-preset reader
gains diagnostic logging on its existing fallback path. No degraded
flag or quarantine here (unlike alarms/stations) since EQ writes are
explicit-only and presets are trivially re-creatable.
2026-07-11 13:00:15 +02:00
ShanaiaBot 2255c18ce8 chore: bump version to 0.1.85+86 [ci skip] 2026-07-11 12:46:49 +02:00
FreeTLab 13ad736917 fix(radio): quarantine corrupt custom-station files instead of silently emptying them
Build & Deploy PluriWave / Build APK + AAB release (push) Successful in 1m39s
Build & Deploy PluriWave / Análisis de código (push) Successful in 36s
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.
2026-07-11 12:45:52 +02:00
ShanaiaBot a34182fdaf chore: bump version to 0.1.84+85 [ci skip] 2026-07-11 12:28:50 +02:00
FreeTLab 65c1ac2085 fix(alarm): stop corrupt entries and unreadable payloads from wiping saved alarms
Build & Deploy PluriWave / Análisis de código (push) Successful in 38s
Build & Deploy PluriWave / Build APK + AAB release (push) Successful in 1m38s
A single malformed alarm entry (bad/missing id, wrong type) used to
discard the ENTIRE persisted list on next load, and a fully unparseable
payload let the periodic recalculation silently overwrite it with an
empty one -- both destroyed valid alarms with no user action.

Adds a shared per-entry tolerant-parse helper (persistencia_tolerante.dart)
that skips and logs only the bad entry; ServicioAlarmas now normalizes its
cached raw after a partial load (no dirty-guard thrash) and sets a
degraded-read flag after a total decode failure that suppresses automatic
writes until a good read or an explicit user mutation restores authority.
2026-07-11 12:27:39 +02:00
ShanaiaBot 23ab3494a7 chore: bump version to 0.1.83+84 [ci skip] 2026-07-11 11:09:36 +02:00
FreeTLab 7eaa87b462 fix(alarm): start the fade-in when pre-started audio is already playing
Build & Deploy PluriWave / Análisis de código (push) Successful in 38s
Build & Deploy PluriWave / Build APK + AAB release (push) Successful in 1m48s
The ringing screen only confirmed the native-to-Flutter handoff from
its playback-state listener, but app.dart pre-starts the station
before pushing the screen, so `reproduciendo` could be emitted before
the listener subscribed and no further event ever arrived. That
branch only cancelled the fallback timer: the gated fade-in never
started and the native alarm player was never told to stop, so the
alarm blared at the alarm-stream volume with no 5%-to-target ramp.
Previously this was a timing race the stream usually lost; gating the
ramp on the confirmation made the failure deterministic.

The already-playing branch now confirms the handoff explicitly
(idempotent with the listener), and the ramp re-imposes its 5% start
volume immediately instead of waiting for the first periodic tick.
Adds the regression test mounting in the real pre-started path.
2026-07-11 11:08:33 +02:00
ShanaiaBot f6ea4e64b9 chore: bump version to 0.1.82+83 [ci skip] 2026-07-11 10:33:15 +02:00
FreeTLab efbf289f6b docs(openspec): archive alarm-volume-ramp-restore and promote native-alarms spec
Build & Deploy PluriWave / Análisis de código (push) Successful in 39s
Build & Deploy PluriWave / Build APK + AAB release (push) Successful in 1m48s
Close the SDD cycle: verified pass with warnings (0 critical), slice 1
cancelled with SDK evidence, slices 2-3 shipped, post-verify dispose
fix landed. The native-alarms capability spec is promoted to
openspec/specs/ with the corrected FGS requirement. Phase 5 on-device
QA remains the pending human gate.
2026-07-11 10:32:07 +02:00
ShanaiaBot 0804a612ec chore: bump version to 0.1.81+82 [ci skip] 2026-07-11 10:17:46 +02:00
FreeTLab 79f6f8ef38 fix(alarm): capture alarm state before dispose so its volume restore works
Build & Deploy PluriWave / Análisis de código (push) Successful in 43s
Build & Deploy PluriWave / Build APK + AAB release (push) Successful in 1m52s
_restaurarVolumenMediaUnaVez() read the BuildContext to reach the
alarm port, but dispose() runs after the element is defunct, so the
lookup always threw (caught and logged) and the dispose safety-net
never actually restored the media volume when it was the sole exit
path. The state is now captured once in initState and the restore
helper uses the field. Adds the missing dispose-as-sole-caller
regression test.
2026-07-11 10:16:25 +02:00
ShanaiaBot b9539223de chore: bump version to 0.1.80+81 [ci skip] 2026-07-11 09:58:08 +02:00
FreeTLab 66a19525bd fix(alarm): defer the Dart fade-in until the native handoff confirms
Build & Deploy PluriWave / Análisis de código (push) Successful in 37s
Build & Deploy PluriWave / Build APK + AAB release (push) Successful in 1m50s
The native service and the Flutter player each ran their own 5%-to-
target fade-in, and both could drive audible volume at the handoff,
producing a jump or ramp reset. The Dart ramp now starts exactly once
from the handoff-confirmation path: the player still pre-starts at 5%,
and _confirmarAudioFlutterListo() starts the ramp in a finally block
so it runs whether the native confirmation succeeds or fails — the
alarm can never stay stuck at 5% if the native side is already gone.

Work unit 3/3 of alarm-volume-ramp-restore (fade-in dedup).
2026-07-11 09:57:06 +02:00
ShanaiaBot a6e1177752 chore: bump version to 0.1.79+80 [ci skip] 2026-07-11 09:17:06 +02:00
FreeTLab acd903d9a8 feat(alarm): make the ring immune to device media volume
Build & Deploy PluriWave / Análisis de código (push) Successful in 41s
Build & Deploy PluriWave / Build APK + AAB release (push) Successful in 2m39s
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).
2026-07-11 09:15:37 +02:00
ShanaiaBot 251d3fd3cd chore: bump version to 0.1.78+79 [ci skip] 2026-07-11 01:23:25 +02:00
FreeTLab 43f61d7c21 docs(openspec): cancel alarm FGS slice, the alarm service type never existed
Build & Deploy PluriWave / Análisis de código (push) Successful in 34s
Build & Deploy PluriWave / Build APK + AAB release (push) Successful in 1m43s
Apply-stage SDK verification (javap on android-34/35/36 platform jars
plus api-versions.xml) proved FOREGROUND_SERVICE_TYPE_ALARM and the
FOREGROUND_SERVICE_ALARM permission are fictional constants. The
existing mediaPlayback|systemExempted declaration is the documented
correct pattern for an alarm app holding exact-alarm permissions, so
slice 1 ships no code and root cause B is withdrawn. Spec, design,
and tasks amended with the evidence; volume-override and fade-dedup
slices proceed unaffected.
2026-07-11 01:22:18 +02:00
ShanaiaBot 39693ce995 chore: bump version to 0.1.77+78 [ci skip] 2026-07-11 01:16:08 +02:00
FreeTLab 159334f997 docs(openspec): archive bt-device-identity and promote its spec
Build & Deploy PluriWave / Análisis de código (push) Successful in 35s
Build & Deploy PluriWave / Build APK + AAB release (push) Successful in 1m44s
Move the change folder to changes/archive/2026-07-11-bt-device-identity
with the verified artifact set (verdict: pass with warnings, 0 critical,
102/102 targeted tests) and create the bt-device-identity capability
spec under openspec/specs/. Phase 7 on-device QA remains the pending
human gate before release.
2026-07-11 01:15:06 +02:00
ShanaiaBot 41b35c7f44 chore: bump version to 0.1.76+77 [ci skip] 2026-07-11 00:57:31 +02:00
FreeTLab 747738d20a docs(openspec): add SDD artifact trails for bt-device-identity and alarm-volume-ramp-restore
Build & Deploy PluriWave / Build APK + AAB release (push) Successful in 1m38s
Build & Deploy PluriWave / Análisis de código (push) Successful in 35s
In-progress artifact sets from the current SDD cycles: exploration,
proposal, spec, design, tasks, and verify reports as produced so far.
Also drops a leftover working copy of eq-device-disconnect-revert
whose contents were already committed under changes/archive/.
2026-07-11 00:56:22 +02:00
ShanaiaBot 0b18540935 chore: bump version to 0.1.75+76 [ci skip] 2026-07-11 00:54:46 +02:00
FreeTLab 8cca7c3daa test(devices): cover rename surviving a re-pair cycle end to end
Build & Deploy PluriWave / Análisis de código (push) Successful in 36s
Build & Deploy PluriWave / Build APK + AAB release (push) Successful in 1m42s
The rename-priority and reconnect-dedup behaviors were each tested in
isolation but never composed: connect, rename, disconnect, re-pair
with the same MAC. Adds that regression test asserting no duplicate
entry appears, the preset entry survives untouched, and the custom
name still wins after reconnection.
2026-07-11 00:53:45 +02:00
ShanaiaBot 158203fee9 chore: bump version to 0.1.74+75 [ci skip] 2026-07-11 00:26:07 +02:00
FreeTLab b17c582572 fix(devices): cache platform device names, dedupe placeholder ids, purge collided EQ entries
Build & Deploy PluriWave / Análisis de código (push) Successful in 36s
Build & Deploy PluriWave / Build APK + AAB release (push) Successful in 1m47s
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).
2026-07-11 00:25:00 +02:00
ShanaiaBot 224763bca3 chore: bump version to 0.1.73+74 [ci skip] 2026-07-11 00:03:51 +02:00
FreeTLab aef4e02c1f fix(devices): use real Bluetooth MAC as device identity on Android 12+
Build & Deploy PluriWave / Análisis de código (push) Successful in 46s
Build & Deploy PluriWave / Build APK + AAB release (push) Successful in 1m53s
Without BLUETOOTH_CONNECT, Android 12+ returns the fixed placeholder
02:00:00:00:00:00 for every Bluetooth device's address, so all BT
devices collapsed onto the same equalizer identity and renames
appeared to duplicate devices after re-pairing.

Declare the permission, add a requestBluetoothConnect channel method
mirroring the existing notification-permission flow, guard the
placeholder in deviceToMap() with a colon-sanitized name-based
fallback id (replacing the dead 00:00:00:00:00:00 branch), and
re-emit the active device after the grant so already-connected
devices pick up their real MAC without a reconnect.

Work unit 1/2 of bt-device-identity (Kotlin plumbing).
2026-07-11 00:02:38 +02:00
ShanaiaBot d3763eaec5 chore: bump version to 0.1.72+73 [ci skip] 2026-07-10 23:55:15 +02:00
FreeTLab 8f7ca8059b fix(eq): resolve base-speaker preset live instead of pinning a stale copy
Build & Deploy PluriWave / Análisis de código (push) Successful in 36s
Build & Deploy PluriWave / Build APK + AAB release (push) Successful in 2m30s
_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.
2026-07-10 23:54:01 +02:00
ShanaiaBot bfa95a1e57 chore: bump version to 0.1.71+72 [ci skip] 2026-07-10 18:53:22 +02:00
FreeTLab 0ab63731d0 fix(eq): re-apply equalizer when the native audio session rotates
Build & Deploy PluriWave / Análisis de código (push) Successful in 41s
Build & Deploy PluriWave / Build APK + AAB release (push) Successful in 2m33s
ExoPlayer assigns a new audio session id after transient audio-focus
interruptions (navigation prompts, radar warnings), leaving the
AndroidEqualizer attached to the dead session so playback resumed
without equalization until the next station switch. The session-id
listener now detects genuine rotations through a dedicated guard and
re-activates the equalizer with the current preset, gated on EQ
availability to stay clear of player teardown/rebuild.
2026-07-10 18:51:45 +02:00
ShanaiaBot a31dc07318 chore: bump version to 0.1.70+71 [ci skip] 2026-07-04 12:43:21 +02:00
FreeTLab bccc5c48b8 docs(openspec): add SDD artifact trail for recent alarm and EQ changes
Build & Deploy PluriWave / Análisis de código (push) Successful in 38s
Build & Deploy PluriWave / Build APK + AAB release (push) Successful in 2m30s
Persist the exploration, proposal, spec, design, tasks, and
verify/archive reports produced during the multi-device EQ,
alarm-countdown, and notification-visual-polish SDD cycles.
2026-07-04 12:42:11 +02:00
ShanaiaBot e5b6d8acb3 chore: bump version to 0.1.69+70 [ci skip] 2026-07-02 18:54:39 +02:00
FreeTLab 8f2bf2bdd6 feat(notifications): add branded monochrome icon and color to all notifications
Build & Deploy PluriWave / Análisis de código (push) Successful in 36s
Build & Deploy PluriWave / Build APK + AAB release (push) Successful in 2m28s
Replace generic system icons (info bubble, stock alarm clock) with a
custom equalizer-bars vector drawable across all 4 notification
builders: pre-notice, snooze countdown, ringing alarm, and the audio
player. Apply the app's cyan brand color to the 3 alarm notifications
that previously had none. Audio notification now explicitly declares
its icon instead of falling back to the full-color launcher icon,
which Android was auto-silhouetting into an illegible status-bar
blob.
2026-07-02 18:53:33 +02:00
ShanaiaBot c78af4b1e8 chore: bump version to 0.1.68+69 [ci skip] 2026-07-02 15:23:08 +02:00
Javier Bautista Fernández 28b663bbe7 fix(alarm): recalculate every alarm on each mutation, not just the touched one
Build & Deploy PluriWave / Análisis de código (push) Successful in 37s
Build & Deploy PluriWave / Build APK + AAB release (push) Successful in 2m33s
guardarAlarma only recomputed proximaEjecucion for the alarm being
saved; every other alarm kept whatever snapshot the last periodic
recalculation left, which can be stale or already past-due. Since
EstadoAlarmas.proximaAlarma just sorts by proximaProgramable, a stale
sibling could wrongly outrank a freshly activated/created/edited
alarm in the "Próxima alarma" panel until the next 1-minute tick.

Extended the same full-list recalculation guardarVacaciones already
did to guardarAlarma, eliminarAlarma, completarEjecucion,
sincronizarEjecucionesNativas, saltarProxima and
posponerEjecucionHasta, via a shared _recalcularLista helper.
2026-07-02 15:21:58 +02:00
ShanaiaBot a8a2db4b64 chore: bump version to 0.1.67+68 [ci skip] 2026-07-02 10:53:12 +02:00
Javier Bautista Fernández 7cfde24811 fix(alarm): avoid ClassCastException casting snooze millis to Long
Build & Deploy PluriWave / Análisis de código (push) Successful in 38s
Build & Deploy PluriWave / Build APK + AAB release (push) Successful in 2m29s
Flutter's StandardMethodCodec encodes Dart ints that fit in 32 bits as
Java Integer, not Long. scheduleAlarm sends preNoticeAtMillis=0 when
rescheduling a snooze, which crashed the unchecked argument<Long>()
cast. Read all millis args as Number and convert with toLong().
2026-07-02 10:52:07 +02:00
ShanaiaBot da9d32849c chore: bump version to 0.1.66+67 [ci skip] 2026-07-01 00:24:18 +02:00
FreeTLab 6acbd7ca93 fix(alarm): handle snooze reschedule failures instead of silently dropping them
Build & Deploy PluriWave / Análisis de código (push) Successful in 47s
Build & Deploy PluriWave / Build APK + AAB release (push) Successful in 2m1s
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.
2026-07-01 00:22:58 +02:00
ShanaiaBot cc98f3f331 chore: bump version to 0.1.65+66 [ci skip] 2026-06-30 22:11:58 +02:00
FreeTLab 5877c2a4ee feat(alarm): add true per-minute live countdown to pre-notice
Build & Deploy PluriWave / Análisis de código (push) Successful in 41s
Build & Deploy PluriWave / Build APK + AAB release (push) Successful in 2m34s
Mirror the shipped snooze-countdown chain for the 30-min pre-notice
notification: re-arm ACTION_PRE_NOTICE at each minute boundary via
slot 9, self-stop at remaining<=1, self-heal from wall clock on
missed ticks. Wire cancellation at all 5 sites (cancelAlarm,
scheduleSpec no-trigger branch, snooze-transition branch,
ACTION_SKIP_NEXT, ACTION_POSTPONE_NEXT) using AlarmScheduler's own
requestCode formula to keep PendingIntent identity consistent.
2026-06-30 22:10:41 +02:00
ShanaiaBot f5cf261f12 chore: bump version to 0.1.64+65 [ci skip] 2026-06-30 15:44:24 +02:00
Javier Bautista Fernández ffd09a2179 i18n(alarm): localize all native notification, channel and chooser texts
Build & Deploy PluriWave / Análisis de código (push) Successful in 40s
Build & Deploy PluriWave / Build APK + AAB release (push) Successful in 2m33s
Centralize every native-side user-facing string in a single
AlarmNotificationStrings store written by Flutter via a new
setNotificationStrings MethodChannel whenever the app locale changes,
and read at notification/channel build time (with English fallbacks)
even when the engine is dead. This replaces the hardcoded Spanish text
in the ringing notification ("Alarma PluriWave", "Posponer", "Detener"),
the pre-notice notification ("Posponer", "Omitir esta vez"), both
notification channels (names + descriptions) and the file-action
choosers ("Abrir carpeta", "Abrir grabación").

The per-alarm preNoticeTemplate/snoozeCountdown template+label args are
dropped from scheduleAlarm and the persisted spec and folded into the
shared store, so a locale change now also relocalizes already-scheduled
alarms. Channels are re-created on each use so their name/description
refresh after a language switch.

Adds alarmRingingNotificationTitle, alarmFire/PreNoticeChannelName,
alarmFire/PreNoticeChannelDescription and openFolder/openRecording
chooser keys across all 13 locales (reusing snoozeAction, stopAlarmAction,
skipNextAction, snoozeAgainAction). Rewrites the template test around
setNotificationStrings. Kotlin is static-reviewed only; no Android build
environment available here.
2026-06-30 15:41:29 +02:00
Javier Bautista Fernández 481944815f fix(alarm): unfreeze snooze modal and add per-minute snooze countdown
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.
2026-06-30 15:27:05 +02:00
ShanaiaBot 89ff6a3912 chore: bump version to 0.1.63+64 [ci skip] 2026-06-28 11:56:21 +02:00
FreeTLab 4ffd73d136 fix(alarm): localize pre-notice countdown and fix snooze dismiss
Build & Deploy PluriWave / Análisis de código (push) Successful in 39s
Build & Deploy PluriWave / Build APK + AAB release (push) Successful in 2m33s
Replace hardcoded Spanish pre-notice text with computed remaining
minutes using l10n template passed via MethodChannel. Fix snooze
dismiss in dead-app state with canPop guard and SystemNavigator.pop
fallback.
2026-06-28 11:55:15 +02:00
FreeTLab 58922de6fc fix(eq): seed device ID at startup and add device management UI
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.
2026-06-28 11:55:15 +02:00
ShanaiaBot 71978de68f chore: bump version to 0.1.62+63 [ci skip] 2026-06-27 12:16:24 +02:00
FreeTLab d1d4afb88f i18n(eq): translate advanced EQ keys to all 11 remaining locales
Build & Deploy PluriWave / Análisis de código (push) Successful in 38s
Build & Deploy PluriWave / Build APK + AAB release (push) Successful in 2m25s
Add advancedEq* translations for ar, bn, de, fr, hi, id, it, ja, pt,
ru, zh — matching register and style conventions of each locale.
2026-06-27 11:38:45 +02:00
FreeTLab 4632d53eb8 feat(eq): add per-device equalizer with 4-level preset resolution
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
2026-06-27 11:33:53 +02:00
ShanaiaBot 8f42e67b48 chore: bump version to 0.1.61+62 [ci skip] 2026-06-26 23:05:20 +02:00
FreeTLab f7753c8402 Merge branch 'feat/s6-quality-gates' into main
Build & Deploy PluriWave / Análisis de código (push) Successful in 1m10s
Build & Deploy PluriWave / Build APK + AAB release (push) Successful in 3m29s
2026-06-12 00:12:43 +02:00
FreeTLab 8a032e6e62 feat(quality): harden lint rules and add quality-gate tests 2026-06-12 00:05:06 +02:00
FreeTLab 202bef3539 feat(ui): design token discipline, accessibility and i18n pass
- Replace all hardcoded Color literals outside lib/tema with theme tokens (new static brand palette in PluriWaveTokens); media notification uses the brand color instead of the Material default purple
- Favorite button on station cards grows to a 48dp target and becomes an independent semantics node for screen readers (Semantics container fix)
- All flutter_animate call sites route through the PluriAnimate reduced-motion gate (zero direct .animate() left)
- Locale-aware short dates via intl DateFormat (new lib/l10n/formato_fechas.dart) replacing the hardcoded DD/MM/YYYY; proper plural messages for the favorites counter; example stream URL as a localized key - all 13 locales
- Rounded shimmer placeholders matching card radii; shimmer loading state in search instead of a bare spinner; rounded icon variants unified in settings; bottom-sheet conventions on the custom station form
- Fix latent debug crash: vacation editor read AppLocalizations in initState
- 11 new tests (121 total green), flutter analyze clean
2026-06-11 23:42:16 +02:00
FreeTLab 52855e75c2 refactor(state): extract recording and search state, scope screen rebuilds
- 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
2026-06-11 21:43:18 +02:00
FreeTLab 0416b301b2 refactor(state): extract export/import service and equalizer state from EstadoRadio
- 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
2026-06-11 21:16:30 +02:00
FreeTLab 0380bbb1e7 feat(streaming): buffer resilience and automatic reconnection
- Construct the audio player with an enlarged live-stream buffer (15-50s forward cushion, 2.5s to start, 5s after rebuffer) so short network drops play through silently
- Add reconnect-on-stall state machine with bounded exponential backoff (1/2/4/8/16s, ~90s total window, 5 attempts) that re-prepares to the live edge; backoff/decision logic extracted to controlador_reconexion.dart as pure testable code
- Surface a new reconnecting playback state in the mini player and full player (localized in all 13 locales) instead of error dialogs during the retry window; a single friendly error appears only after exhaustion
- Guard interplay: user pause/stop cancels retries, audio interruptions cancel reconnect, alarm wake-up path keeps precedence, recording fails cleanly during drops
- Reset retry budget on station change; route stream timeouts through the network-error class
- 10 new tests (99 total green), flutter analyze clean
2026-06-11 19:54:30 +02:00
FreeTLab 079e19f0ee feat(audio): audio session integration and runtime robustness
- 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
2026-06-11 16:25:09 +02:00
FreeTLab f3e9487215 feat(alarms): native reliability fixes and end-to-end snooze
- Use mediaPlayback|systemExempted FGS type with FOREGROUND_SERVICE_SYSTEM_EXEMPTED so alarms fire on Android 14+ (FOREGROUND_SERVICE_ALARM does not exist in the SDK)
- Deduplicate fire notifications: the foreground service FSI notification is the single owner; receiver path removed
- Notification channel v2 with alarm sound URI and USAGE_ALARM attributes, one-time guarded migration from legacy channels
- Pass fallback station through the MethodChannel (NativeAlarmSpec schemaVersion 3) with a three-stage audio chain: primary -> fallback station -> bundled WAV
- Native fade-in volume ramp honoring fadeInSegundos when the app is killed
- Request battery-optimization exemption once, tracked with a persisted asked-once flag
- Fix snooze end-to-end: native ACTION_SNOOZE now reports back to Flutter (snoozed event + cold-start sync), snooze anchor unified to occurrence+minutes on both sides, periodic recalc no longer erases an active snooze
- Add snooze buttons (3/5/10/custom) to the ringing screen with shared audio teardown
- Redesign ringing screen on PluriWaveScaffold with reduced-motion-aware entry animation (new PluriAnimate helper)
- Alarm editor: live next-trigger preview, searchable station pickers (primary and fallback), configurable snooze duration, volume floor down to 0
- New alarm strings localized across all 13 locales
- New unit/widget tests for the snooze flow, alarm bridge payloads, ringing screen and editor (77 tests green)
- SDD artifacts for the app-quality-and-native-alarms change (explore, proposal, spec, design, tasks, apply progress)
2026-06-11 15:33:30 +02:00
ShanaiaBot b5acf97ba4 chore: bump version to 0.1.60+61 [ci skip] 2026-06-04 16:30:30 +02:00
Javier Bautista Fernández cf9422dff3 Exportar e importar absolutamente toda la información de las preferencias de la aplicación
Build & Deploy PluriWave / Análisis de código (push) Successful in 38s
Build & Deploy PluriWave / Build APK + AAB release (push) Successful in 2m25s
2026-06-04 16:05:58 +02:00
ShanaiaBot 957615dcd6 chore: bump version to 0.1.59+60 [ci skip] 2026-06-03 22:07:12 +02:00
FreeTLab 089b8b4227 fix(i18n): normalize translations and fallbacks
Build & Deploy PluriWave / Análisis de código (push) Successful in 38s
Build & Deploy PluriWave / Build APK + AAB release (push) Successful in 2m34s
2026-06-03 21:20:08 +02:00
ShanaiaBot a5475ce118 chore: bump version to 0.1.58+59 [ci skip] 2026-06-03 14:55:56 +02:00
Javier Bautista Fernández 00fe49c309 fix: resolver advertencias de analisis i18n
Build & Deploy PluriWave / Análisis de código (push) Successful in 35s
Build & Deploy PluriWave / Build APK + AAB release (push) Successful in 2m26s
2026-06-03 14:54:50 +02:00
Javier Bautista Fernández 643ba1eb45 fix: completar migracion i18n de literales visibles
Build & Deploy PluriWave / Análisis de código (push) Failing after 28s
Build & Deploy PluriWave / Build APK + AAB release (push) Has been skipped
2026-06-03 13:43:43 +02:00
ShanaiaBot 7abc8c3b0f chore: bump version to 0.1.57+58 [ci skip] 2026-06-02 10:20:40 +02:00
570 changed files with 139481 additions and 9723 deletions
+5
View File
@@ -0,0 +1,5 @@
# CodeGraph data files — local to each machine, not for committing.
# Ignore everything in .codegraph/ except this file itself, so transient
# files (the database, daemon.pid, sockets, logs) never show up in git.
*
!.gitignore
+181 -14
View File
@@ -27,6 +27,9 @@ jobs:
- name: Obtener dependencias
run: flutter pub get
- name: Verificar integridad de literales i18n
run: python3 tool/check_arb_placeholder_corruption.py
- name: Analizar código
run: flutter analyze --no-fatal-infos --no-fatal-warnings
@@ -73,12 +76,21 @@ jobs:
CURRENT=$(grep '^version:' pubspec.yaml | awk '{print $2}')
SEMVER=$(echo "$CURRENT" | cut -d'+' -f1)
BUILD=$(echo "$CURRENT" | cut -d'+' -f2)
MAJOR=$(echo "$SEMVER" | cut -d. -f1)
MINOR=$(echo "$SEMVER" | cut -d. -f2)
PATCH=$(echo "$SEMVER" | cut -d. -f3)
NEW_PATCH=$((PATCH + 1))
NEW_BUILD=$((BUILD + 1))
NEW_VERSION="${MAJOR}.${MINOR}.${NEW_PATCH}+${NEW_BUILD}"
# If the triggering commit explicitly pins the version name via the
# [version set] marker, ship that semver as-is (a milestone like 1.0.0
# or a major/minor jump the automatic patch bump cannot reach) and only
# advance the build number, which Google Play requires to stay
# monotonic. Otherwise keep the default automatic patch+build bump.
if git log -1 --pretty=%B | grep -q '\[version set\]'; then
NEW_VERSION="${SEMVER}+${NEW_BUILD}"
else
MAJOR=$(echo "$SEMVER" | cut -d. -f1)
MINOR=$(echo "$SEMVER" | cut -d. -f2)
PATCH=$(echo "$SEMVER" | cut -d. -f3)
NEW_PATCH=$((PATCH + 1))
NEW_VERSION="${MAJOR}.${MINOR}.${NEW_PATCH}+${NEW_BUILD}"
fi
sed -i '' "s/^version: .*/version: ${NEW_VERSION}/" pubspec.yaml
git add pubspec.yaml
git commit -m "chore: bump version to ${NEW_VERSION} [ci skip]"
@@ -97,17 +109,152 @@ jobs:
- name: Obtener dependencias
run: flutter pub get
# OBLIGATORIO en este runner autoalojado, no es higiene opcional.
#
# El directorio build/ sobrevive entre ejecuciones y el merge
# incremental de recursos de Gradle se queda rancio: los drawables
# ic_auto_eq_on/ic_auto_eq_off (anadidos el 31-07 en 2540556) NUNCA
# llegaron a entrar en el APK, mientras que ic_stat_pluriwave -- misma
# carpeta, anadido el 02-07 -- si estaba. Verificado extrayendo el
# base.apk instalado en el dispositivo: los ficheros no existen ni como
# entrada del zip ni en resources.arsc.
#
# El coste fue semanas de diagnostico equivocado. Cada setState
# publicaba una CustomAction cuyo icono resolvia a 0, y
# PlaybackStateCompat.CustomAction.Builder lanza en ese caso, abortando
# setState antes de activar la sesion de medios: Android Auto se
# quedaba con la sesion congelada e inactiva. El codigo Dart siempre
# llegaba porque se recompila; el recurso Android no.
- name: Limpiar artefactos de compilacion
run: flutter clean
- name: Reinstalar dependencias tras limpiar
run: flutter pub get
- name: Build APK release
run: flutter build apk --release
# Guardian de recursos: el APK debe contener los drawables que el codigo
# resuelve POR NOMBRE en runtime (getResources().getIdentifier).
#
# Un nombre que no resuelve devuelve id 0, y eso no falla la
# compilacion: falla en el coche. Concretamente
# PlaybackStateCompat.CustomAction.Builder lanza con icono 0, ese throw
# aborta AudioService.setState antes de activar la sesion de medios, y
# Android Auto se queda con la interfaz congelada. Paso exactamente eso
# entre el 31-07 (commit 2540556) y el 07-08 sin que nada lo detectara.
#
# Anadir un drawable nuevo referenciado por nombre => anadirlo aqui.
# Guardian de recursos: el APK debe contener los drawables que el codigo
# resuelve POR NOMBRE en runtime (getResources().getIdentifier).
#
# Un nombre que no resuelve devuelve id 0, y eso no falla la
# compilacion: falla en el coche. PlaybackStateCompat.CustomAction
# .Builder lanza con icono 0, ese throw aborta AudioService.setState
# antes de activar la sesion de medios, y Android Auto se queda con la
# interfaz congelada. Paso exactamente eso desde el 31-07 (commit
# 2540556) sin que nada lo detectara.
#
# La primera version de este paso daba FALSOS POSITIVOS: no comprobaba
# que el APK existiera ni que unzip estuviera disponible, asi que
# cualquier fallo de la tuberia se reportaba como "faltan todos los
# recursos". Un guardian que miente es peor que no tener guardian:
# manda a buscar fantasmas. De ahi que ahora verifique primero sus
# propias herramientas y vuelque el inventario real antes de juzgar.
#
# Anadir un drawable nuevo referenciado por nombre => anadirlo aqui.
# Guardian de recursos: el APK debe contener los drawables que el codigo
# resuelve POR NOMBRE en runtime (getResources().getIdentifier).
#
# Un nombre que no resuelve devuelve id 0. Eso no falla la compilacion:
# falla en el coche. PlaybackStateCompat.CustomAction.Builder lanza con
# icono 0, ese throw aborta AudioService.setState antes de activar la
# sesion de medios, y Android Auto se queda con la interfaz congelada.
# Paso exactamente eso desde el 31-07 (commit 2540556) sin deteccion.
#
# Se inspecciona resources.arsc, NO las rutas del zip: el APK release
# acorta/renombra las rutas de recursos (una version anterior de este
# paso listo "ningun drawable" en un APK de 105MB, que es imposible).
# Los NOMBRES de recurso siguen en la tabla pase lo que pase.
#
# El centinela existe porque este guardian ya mintio una vez: al no
# validar su propio metodo, reporto como ausente hasta un recurso que
# estaba verificado presente. Si el centinela no aparece, la inspeccion
# no es fiable y NO tenemos derecho a declarar nada ausente.
#
# Anadir un drawable nuevo referenciado por nombre => anadirlo aqui.
- name: Verificar recursos criticos en el APK
run: |
set -u
APK=build/app/outputs/flutter-apk/app-release.apk
CENTINELA=station_art_nova
if [ ! -f "$APK" ]; then
echo "El APK no esta donde se esperaba: $APK"
find build/app/outputs -name '*.apk' 2>/dev/null || echo " (nada)"
exit 1
fi
echo "APK: $APK ($(wc -c < "$APK") bytes)"
if ! command -v unzip >/dev/null 2>&1; then
echo "unzip no esta disponible: no se puede inspeccionar el APK."
exit 1
fi
ARSC=$(mktemp)
unzip -p "$APK" resources.arsc > "$ARSC" 2>/dev/null || true
if [ ! -s "$ARSC" ]; then
echo "No se pudo extraer resources.arsc del APK."
exit 1
fi
echo "resources.arsc: $(wc -c < "$ARSC") bytes"
if ! grep -a -q "$CENTINELA" "$ARSC"; then
echo "El centinela '$CENTINELA' no aparece en la tabla de recursos."
echo "La inspeccion no es fiable; no se declara nada ausente."
exit 1
fi
echo "Centinela '$CENTINELA' localizado: la inspeccion es fiable."
FALTAN=0
for RECURSO in ic_auto_eq_on ic_auto_eq_off ic_stat_pluriwave; do
if grep -a -q "$RECURSO" "$ARSC"; then
echo "OK $RECURSO"
else
echo "FALTA $RECURSO"
FALTAN=$((FALTAN + 1))
fi
done
if [ "$FALTAN" -ne 0 ]; then
echo ""
echo "$FALTAN drawable(s) resueltos por nombre NO estan en el APK."
echo "En runtime resolveran a id 0 y tumbaran la sesion de medios."
exit 1
fi
echo "Todos los recursos criticos viajan en el APK."
- name: Build AAB release
run: flutter build appbundle --release
# El nombre lleva RAMA y CÓDIGO DE VERSIÓN, no solo el semver.
#
# Antes, cada build de 1.3.0 se llamaba `pluriwave-v1.3.0.aab` y caía en
# la misma carpeta, así que main y PRO se pisaban y tres builds distintos
# eran indistinguibles una vez descargados: el navegador los guarda como
# "(1)", "(2)"... y ya no se sabe cuál es cuál. Eso costó subir a Play
# Console un código de versión ya usado, dos veces.
#
# Con `pluriwave-PRO-v1.3.0+156.aab` el archivo se identifica solo,
# incluso semanas después y fuera de este repo.
- name: Publicar en ftl-builds (Zimaboard)
run: |
VERSION="${{ steps.version.outputs.version }}"
APK_NOMBRE="pluriwave-v${VERSION}.apk"
AAB_NOMBRE="pluriwave-v${VERSION}.aab"
BUILD_NUMBER="${{ steps.version.outputs.build_number }}"
BRANCH="${CURRENT_REF#refs/heads/}"
ETIQUETA="${BRANCH}-v${VERSION}+${BUILD_NUMBER}"
APK_NOMBRE="pluriwave-${ETIQUETA}.apk"
AAB_NOMBRE="pluriwave-${ETIQUETA}.aab"
DESTINO="/opt/ftl-builds/builds/pluriwave/v${VERSION}"
SSH_KEY="/Users/freetlab/.openclaw/workspace/.secure/zimaboard_ed25519"
@@ -118,28 +265,43 @@ jobs:
scp -i "$SSH_KEY" -o StrictHostKeyChecking=no \
build/app/outputs/bundle/release/app-release.aab \
"ShanaiaBot@192.168.0.33:${DESTINO}/${AAB_NOMBRE}"
echo "✅ APK: builds.freetimelab.es → pluriwave → v${VERSION}"
echo "✅ AAB: builds.freetimelab.es → pluriwave → v${VERSION}"
echo "✅ APK: builds.freetimelab.es → pluriwave → v${VERSION} → ${APK_NOMBRE}"
echo "✅ AAB: builds.freetimelab.es → pluriwave → v${VERSION} → ${AAB_NOMBRE}"
# La publicacion automatica en Google Play es OPCIONAL.
#
# Este paso hacia `exit 1` cuando faltaba el secreto, asi que TODA
# compilacion de PRO terminaba en rojo por una funcion que nunca llego a
# activarse: el secreto no se configuro nunca y las subidas a Play se han
# hecho siempre a mano. Un rojo permanente entrena a ignorar los rojos, y
# entonces el dia que falle algo de verdad tampoco se mira.
#
# Ahora se omite con un aviso. El AAB ya esta compilado, firmado y subido
# a ftl-builds por el paso anterior, asi que no se pierde nada. El dia que
# se configure el secreto, los tres pasos se activan solos.
- name: Preparar credenciales de Google Play
id: credenciales_play
if: ${{ gitea.ref == 'refs/heads/PRO' }}
env:
GOOGLE_PLAY_SERVICE_ACCOUNT_JSON: ${{ secrets.GOOGLE_PLAY_SERVICE_ACCOUNT_JSON }}
run: |
if [ -z "$GOOGLE_PLAY_SERVICE_ACCOUNT_JSON" ]; then
echo "ERROR: falta el secreto GOOGLE_PLAY_SERVICE_ACCOUNT_JSON"
exit 1
echo "disponible=no" >> "$GITHUB_OUTPUT"
echo "AVISO: falta el secreto GOOGLE_PLAY_SERVICE_ACCOUNT_JSON."
echo "Se omite la publicacion en Google Play; sube el AAB a mano."
exit 0
fi
mkdir -p fastlane/credentials
printf '%s' "$GOOGLE_PLAY_SERVICE_ACCOUNT_JSON" > fastlane/credentials/google-play-service-account.json
echo "disponible=si" >> "$GITHUB_OUTPUT"
- name: Instalar Fastlane
if: ${{ gitea.ref == 'refs/heads/PRO' }}
if: ${{ gitea.ref == 'refs/heads/PRO' && steps.credenciales_play.outputs.disponible == 'si' }}
run: |
gem list -i fastlane >/dev/null 2>&1 || gem install fastlane --no-document
- name: Publicar AAB en Google Play Internal Testing
if: ${{ gitea.ref == 'refs/heads/PRO' }}
if: ${{ gitea.ref == 'refs/heads/PRO' && steps.credenciales_play.outputs.disponible == 'si' }}
env:
PLAY_JSON_KEY_PATH: fastlane/credentials/google-play-service-account.json
PLAY_AAB_PATH: build/app/outputs/bundle/release/app-release.aab
@@ -157,8 +319,13 @@ jobs:
if [ -z "$BOT_TOKEN" ]; then exit 0; fi
if [ "${{ job.status }}" = "success" ]; then
MSG="✅ *PluriWave* v${VERSION} · rama ${BRANCH} · ${COMMIT}%0AAPK + AAB generados"
if [ "$BRANCH" = "PRO" ]; then
# Solo se anuncia la subida a Play cuando de verdad ocurrio: el paso
# se omite si falta el secreto, y un aviso que dice "publicado"
# cuando no se publico es peor que no avisar.
if [ "$BRANCH" = "PRO" ] && [ "${{ steps.credenciales_play.outputs.disponible }}" = "si" ]; then
MSG="${MSG}%0APublicado en Google Play · Internal Testing"
elif [ "$BRANCH" = "PRO" ]; then
MSG="${MSG}%0AEn builds.freetimelab.es · sube el AAB a Play a mano"
else
MSG="${MSG}%0APublicado en builds.freetimelab.es"
fi
+5
View File
@@ -34,6 +34,11 @@ migrate_working_dir/
/coverage/
.atl/
# Test-run scratch files (created and best-effort cleaned up by
# pantalla_ajustes_emisoras_personalizadas_test.dart; ignored as a backstop
# in case a run is interrupted before its own cleanup runs)
test/fixtures/.tmp_*
# Symbolication related
app.*.symbols
+5
View File
@@ -23,6 +23,11 @@ linter:
rules:
# avoid_print: false # Uncomment to disable the `avoid_print` rule
# prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule
cancel_subscriptions: true
close_sinks: true
unawaited_futures: true
prefer_final_locals: true
avoid_dynamic_calls: true
# Additional information about this file can be found at
# https://dart.dev/guides/language/analysis-options
+42 -1
View File
@@ -3,6 +3,7 @@
<uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SYSTEM_EXEMPTED"/>
<uses-permission android:name="android.permission.WAKE_LOCK"/>
<uses-permission android:name="android.permission.RECORD_AUDIO"/>
<uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM"/>
@@ -13,6 +14,15 @@
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS"/>
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION"/>
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION"/>
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT"/>
<!--
Reading the paired-device list is gated by BLUETOOTH_CONNECT from API 31
and by this legacy permission below it. Normal permission: granted at
install, no runtime prompt.
-->
<uses-permission
android:name="android.permission.BLUETOOTH"
android:maxSdkVersion="30"/>
<application
android:label="PluriWave"
@@ -53,7 +63,7 @@
<service
android:name=".PluriWaveAlarmService"
android:foregroundServiceType="mediaPlayback"
android:foregroundServiceType="mediaPlayback|systemExempted"
android:exported="false" />
<!-- Receptor de controles de media (auriculares, notificación) -->
@@ -102,9 +112,40 @@
android:resource="@xml/pluriwave_file_paths" />
</provider>
<!--
Publishes the app-private recordings folder as a browsable storage
root for the system file manager. MANAGE_DOCUMENTS restricts direct
access to the document framework (DocumentsUI); grantUriPermissions
lets it hand single-file access to whatever app the user picks.
-->
<provider
android:name=".RecordingsDocumentsProvider"
android:authorities="${applicationId}.recordings"
android:exported="true"
android:grantUriPermissions="true"
android:permission="android.permission.MANAGE_DOCUMENTS">
<intent-filter>
<action android:name="android.content.action.DOCUMENTS_PROVIDER" />
</intent-filter>
</provider>
<meta-data
android:name="flutterEmbedding"
android:value="2" />
<!-- Android Auto discovery (android-auto-media) -->
<meta-data
android:name="com.google.android.gms.car.application"
android:resource="@xml/automotive_app_desc" />
<!-- AdMob application id (iap-freemium-unlock). Real id, provisioned
in the AdMob console. Safe to use in all build modes — this id
only initializes the SDK; it never serves an ad by itself, so it
carries none of the "don't tap your own ads" risk that ad unit
ids do. -->
<meta-data
android:name="com.google.android.gms.ads.APPLICATION_ID"
android:value="ca-app-pub-6038935671414339~4085536467" />
</application>
<queries>
<intent>
@@ -0,0 +1,91 @@
package es.freetimelab.pluriwave
import android.content.Context
/**
* Localized strings for native alarm notifications, channels and choosers.
*
* Flutter is the single source of truth for i18n: it pushes the current-locale
* strings via the `setNotificationStrings` MethodChannel whenever the app locale
* is (re)configured. They are persisted in device-protected storage so the
* native side can read them when building a notification or channel even while
* the Flutter engine is dead (alarm fired from a killed app, after reboot, in
* direct-boot). Every getter falls back to English when a value is unset.
*/
object AlarmNotificationStrings {
private const val PREFS = "pluriwave_alarm_strings"
const val KEY_RING_TITLE = "ringTitle"
const val KEY_SNOOZE = "snoozeLabel"
const val KEY_STOP = "stopLabel"
const val KEY_SKIP = "skipLabel"
const val KEY_SNOOZE_AGAIN = "snoozeAgainLabel"
const val KEY_FIRE_CHANNEL_NAME = "fireChannelName"
const val KEY_FIRE_CHANNEL_DESC = "fireChannelDescription"
const val KEY_PRE_NOTICE_CHANNEL_NAME = "preNoticeChannelName"
const val KEY_PRE_NOTICE_CHANNEL_DESC = "preNoticeChannelDescription"
const val KEY_PRE_NOTICE_TEMPLATE = "preNoticeTemplate"
const val KEY_SNOOZE_COUNTDOWN_TEMPLATE = "snoozeCountdownTemplate"
const val KEY_OPEN_FOLDER = "openFolderTitle"
const val KEY_OPEN_RECORDING = "openRecordingTitle"
const val KEY_RECORDINGS_ROOT_TITLE = "recordingsRootTitle"
const val KEY_MISSED_TITLE = "missedTitle"
const val KEY_MISSED_TEMPLATE = "missedTemplate"
/** Persists the localized strings pushed by Flutter. Blank values are removed. */
fun save(context: Context, values: Map<String, Any?>) {
val editor = prefs(context).edit()
for ((key, value) in values) {
val str = value as? String
if (str.isNullOrBlank()) editor.remove(key) else editor.putString(key, str)
}
editor.apply()
}
fun ringTitle(context: Context) = get(context, KEY_RING_TITLE, "PluriWave alarm")
fun snoozeLabel(context: Context) = get(context, KEY_SNOOZE, "Snooze")
fun stopLabel(context: Context) = get(context, KEY_STOP, "Stop")
fun skipLabel(context: Context) = get(context, KEY_SKIP, "Skip this time")
fun snoozeAgainLabel(context: Context) = get(context, KEY_SNOOZE_AGAIN, "Snooze again")
fun fireChannelName(context: Context) = get(context, KEY_FIRE_CHANNEL_NAME, "Ringing alarms")
fun fireChannelDescription(context: Context) =
get(context, KEY_FIRE_CHANNEL_DESC, "Urgent sound and screen when a music alarm must ring")
fun preNoticeChannelName(context: Context) =
get(context, KEY_PRE_NOTICE_CHANNEL_NAME, "Alarm reminders")
fun preNoticeChannelDescription(context: Context) =
get(context, KEY_PRE_NOTICE_CHANNEL_DESC, "Silent notifications before the alarm")
fun openFolderTitle(context: Context) = get(context, KEY_OPEN_FOLDER, "Open folder")
fun openRecordingTitle(context: Context) = get(context, KEY_OPEN_RECORDING, "Open recording")
/** Title of the storage root published by [RecordingsDocumentsProvider]. */
fun recordingsRootTitle(context: Context) =
get(context, KEY_RECORDINGS_ROOT_TITLE, "PluriWave recordings")
fun missedTitle(context: Context) = get(context, KEY_MISSED_TITLE, "Missed alarm")
fun missedText(context: Context, name: String): String =
format(
// "10 minutes" mirrors AlarmScheduler.AUTO_SILENCE_MILLIS
// (READ-3/READ-4) -- keep both, and the alarmMissedNotificationText
// entry of ALL 13 lib/l10n/app_*.arb files, in sync.
get(context, KEY_MISSED_TEMPLATE, "{name} was silenced automatically after 10 minutes."),
name
)
fun preNoticeText(context: Context, minutes: Long): String =
format(get(context, KEY_PRE_NOTICE_TEMPLATE, "Starts in {minutes} min"), minutes)
fun snoozeCountdownText(context: Context, minutes: Long): String =
format(get(context, KEY_SNOOZE_COUNTDOWN_TEMPLATE, "Rings in {minutes} min"), minutes)
private fun format(template: String, minutes: Long): String =
template.replace("{minutes}", minutes.toString())
private fun format(template: String, name: String): String =
template.replace("{name}", name)
private fun get(context: Context, key: String, fallback: String): String =
prefs(context).getString(key, null)?.takeIf { it.isNotBlank() } ?: fallback
private fun prefs(context: Context) =
context.applicationContext.createDeviceProtectedStorageContext()
.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
}
File diff suppressed because it is too large Load Diff
@@ -2,10 +2,14 @@ package es.freetimelab.pluriwave
import android.Manifest
import android.app.NotificationManager
import android.bluetooth.BluetoothManager
import android.content.ClipData
import android.content.Intent
import android.content.ActivityNotFoundException
import android.content.pm.PackageManager
import android.media.AudioDeviceCallback
import android.media.AudioDeviceInfo
import android.media.AudioManager
import android.net.Uri
import android.media.audiofx.Visualizer
import android.app.AlarmManager
@@ -21,6 +25,7 @@ import android.util.Log
import androidx.core.app.NotificationManagerCompat
import androidx.core.content.FileProvider
import com.ryanheise.audioservice.AudioServiceActivity
import es.freetimelab.pluriwave.fileactions.FileActionsHandler
import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugin.common.EventChannel
import io.flutter.plugin.common.MethodChannel
@@ -31,16 +36,40 @@ class MainActivity : AudioServiceActivity() {
private val visualizerChannel = "pluriwave/audio_visualizer"
private val alarmChannel = "pluriwave/alarm_scheduler"
private val fileActionsChannel = "pluriwave/file_actions"
private val audioDevicesChannel = "pluriwave/audio_devices"
private val visualizerPermissionRequestCode = 4821
private val notificationPermissionRequestCode = 4822
private val bluetoothConnectPermissionRequestCode = 4823
private val pickMusicFolderRequestCode = 4824
private val bluetoothMacPlaceholder = "02:00:00:00:00:00"
// MIME types DocumentsUI's file browser declares an ACTION_VIEW filter for.
// DocumentsContract only exposes the directory one as a constant.
private val directoryDocumentMimeType = DocumentsContract.Document.MIME_TYPE_DIR
private val rootDocumentMimeType = "vnd.android.document/root"
private var visualizer: Visualizer? = null
private var pendingSink: EventChannel.EventSink? = null
private var pendingArgs: Map<*, *>? = null
private var alarmMethodChannel: MethodChannel? = null
private val mainHandler = Handler(Looper.getMainLooper())
// Local-music SAF folder picker (android-auto-local-music, static
// review only — see file_actions.pickMusicFolder / onActivityResult):
// the MethodChannel.Result held across the startActivityForResult round
// trip, so the eventual onActivityResult callback can respond to the
// SAME pending Dart call instead of a stale one.
private var pendingMusicFolderResult: MethodChannel.Result? = null
// Audio devices channel state
private var audioDevicesSink: EventChannel.EventSink? = null
private var audioDeviceCallback: AudioDeviceCallback? = null
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine)
// --- Audio Devices Channel ---
setupAudioDevicesChannel(flutterEngine)
EventChannel(
flutterEngine.dartExecutor.binaryMessenger,
visualizerChannel
@@ -68,8 +97,8 @@ class MainActivity : AudioServiceActivity() {
"scheduleAlarm" -> {
val id = call.argument<String>("id")
val title = call.argument<String>("title") ?: "PluriWave"
val triggerAtMillis = call.argument<Long>("triggerAtMillis")
val preNoticeAtMillis = call.argument<Long>("preNoticeAtMillis") ?: 0L
val triggerAtMillis = call.argument<Number>("triggerAtMillis")?.toLong()
val preNoticeAtMillis = call.argument<Number>("preNoticeAtMillis")?.toLong() ?: 0L
val stationName = call.argument<String>("stationName")
val stationUrl = call.argument<String>("stationUrl")
val fallbackSound = call.argument<String>("fallbackSound")
@@ -95,12 +124,15 @@ class MainActivity : AudioServiceActivity() {
minute = call.argument<Int>("minute"),
scheduleType = call.argument<String>("scheduleType"),
weekdays = weekdays,
oneShotDateMillis = call.argument<Long>("oneShotDateMillis"),
snoozeUntilMillis = call.argument<Long>("snoozeUntilMillis"),
snoozeOriginMillis = call.argument<Long>("snoozeOriginMillis"),
lastHandledAtMillis = call.argument<Long>("lastHandledAtMillis"),
oneShotDateMillis = call.argument<Number>("oneShotDateMillis")?.toLong(),
snoozeUntilMillis = call.argument<Number>("snoozeUntilMillis")?.toLong(),
snoozeOriginMillis = call.argument<Number>("snoozeOriginMillis")?.toLong(),
lastHandledAtMillis = call.argument<Number>("lastHandledAtMillis")?.toLong(),
soundOnVacation = call.argument<Boolean>("soundOnVacation") ?: true,
snoozeMinutes = call.argument<Int>("snoozeMinutes") ?: 5
snoozeMinutes = call.argument<Int>("snoozeMinutes") ?: 5,
fallbackStationName = call.argument<String>("fallbackStationName"),
fallbackStationUrl = call.argument<String>("fallbackStationUrl"),
fadeInSegundos = call.argument<Int>("fadeInSegundos") ?: 0
)
result.success(scheduled)
}
@@ -126,6 +158,16 @@ class MainActivity : AudioServiceActivity() {
result.success(null)
}
}
"dismissAlarmNotificationOnly" -> {
val id = call.argument<String>("id")
Log.d(tag, "alarm.channel dismissAlarmNotificationOnly id=$id")
if (id == null) {
result.error("INVALID_ALARM", "Missing alarm id", null)
} else {
alarmScheduler.dismissFireNotification(id)
result.success(null)
}
}
"stopNativeAlarmSound" -> {
val id = call.argument<String>("id")
Log.d(tag, "alarm.channel stopNativeAlarmSound id=$id")
@@ -136,14 +178,28 @@ class MainActivity : AudioServiceActivity() {
result.success(null)
}
}
"confirmFlutterAudio" -> {
val id = call.argument<String>("id")
Log.d(tag, "alarm.channel confirmFlutterAudio id=$id")
if (id == null) {
result.error("INVALID_ALARM", "Missing alarm id", null)
} else {
PluriWaveAlarmService.stop(this, id)
result.success(null)
"getActiveRingingAlarmId" -> {
result.success(PluriWaveAlarmService.activeRingingId)
}
"stopActiveAlarm" -> {
try {
// Verified stop (feedback item 1, RISK-1/RES-1/REL-2): the
// id is snapshotted BEFORE stopping, and "stopped" now
// reflects stopActiveVerified's post-teardown check
// instead of a literal true decided before teardown ran.
val activeId = PluriWaveAlarmService.activeRingingId
val stopped = PluriWaveAlarmService.stopActiveVerified(this)
Log.d(tag, "alarm.channel stopActiveAlarm activeId=$activeId stopped=$stopped")
result.success(
mapOf(
"stopped" to stopped,
"wasRinging" to (activeId != null),
"activeAlarmId" to activeId
)
)
} catch (error: Throwable) {
Log.e(tag, "alarm.channel stopActiveAlarm failed", error)
result.error("STOP_FAILED", error.message, null)
}
}
"diagnostics" -> {
@@ -172,6 +228,14 @@ class MainActivity : AudioServiceActivity() {
Log.d(tag, "alarm.channel requestFullScreenIntentPermission")
result.success(requestFullScreenIntentPermission())
}
"requestIgnoreBatteryOptimizations" -> {
Log.d(tag, "alarm.channel requestIgnoreBatteryOptimizations")
result.success(requestIgnoreBatteryOptimizations())
}
"openNotificationSettings" -> {
Log.d(tag, "alarm.channel openNotificationSettings")
result.success(openNotificationSettings())
}
"getInitialAlarmIntent" -> {
val payload = alarmPayload(intent)
Log.d(tag, "alarm.channel getInitialAlarmIntent payload=$payload")
@@ -182,14 +246,54 @@ class MainActivity : AudioServiceActivity() {
Log.d(tag, "alarm.channel getHandledAlarmOccurrences")
result.success(alarmScheduler.handledOccurrences())
}
"getNativeSnoozeState" -> {
Log.d(tag, "alarm.channel getNativeSnoozeState")
result.success(alarmScheduler.nativeSnoozeStates())
}
"getNativeSchedulingFailures" -> {
Log.d(tag, "alarm.channel getNativeSchedulingFailures")
result.success(alarmScheduler.scheduleFailures())
}
"setNotificationStrings" -> {
val args = call.arguments as? Map<*, *>
if (args != null) {
AlarmNotificationStrings.save(
this,
args.entries.associate { (k, v) -> k.toString() to v }
)
}
result.success(null)
}
else -> result.notImplemented()
}
}
activeInstance = this
// fix/android-auto-musica-local: los cuatro metodos SAF que solo
// necesitan un ContentResolver viven en FileActionsHandler, dentro del
// paquete plugin `packages/pluriwave_file_actions`. Alli
// PluriWaveFileActionsPlugin los registra en TODOS los engines via
// GeneratedPluginRegistrant -- incluido el headless que audio_service
// crea para Android Auto, donde este configureFlutterEngine nunca
// corre.
//
// Este engine SI tiene Activity, asi que instala UN solo handler para
// todo el canal, superconjunto del del plugin: primero delega en el
// handler compartido (misma y unica implementacion) y, si este no
// reconoce el metodo, atiende sus propios metodos ligados a la
// Activity (picker SAF e intents de la carpeta de grabaciones).
//
// El orden esta garantizado: GeneratedPluginRegistrant corre DENTRO
// del constructor de FlutterEngine, y configureFlutterEngine solo
// puede ejecutarse despues, con el engine ya construido. Este handler
// siempre pisa al del plugin en una Activity, nunca al reves.
val fileActionsHandler = FileActionsHandler(applicationContext)
MethodChannel(
flutterEngine.dartExecutor.binaryMessenger,
fileActionsChannel
).setMethodCallHandler { call, result ->
if (fileActionsHandler.manejar(call, result)) return@setMethodCallHandler
when (call.method) {
"openDirectory" -> {
val path = call.argument<String>("path")
@@ -219,11 +323,85 @@ class MainActivity : AudioServiceActivity() {
result.success(openFile(path, mimeType))
}
}
// ---- android-auto-local-music (static review only) ----
"pickMusicFolder" -> {
Log.d(tag, "file_actions.pickMusicFolder launching picker")
// A stale pending call (e.g. the user backgrounded the
// app mid-picker and re-triggered it) resolves as
// cancelled first, so no Dart-side `Future` is left
// dangling and `pendingMusicFolderResult` always points
// at the LATEST call by the time onActivityResult fires.
pendingMusicFolderResult?.success(null)
pendingMusicFolderResult = result
try {
val intent = Intent(Intent.ACTION_OPEN_DOCUMENT_TREE).apply {
addFlags(
Intent.FLAG_GRANT_READ_URI_PERMISSION or
Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION
)
}
@Suppress("DEPRECATION")
startActivityForResult(intent, pickMusicFolderRequestCode)
} catch (error: Throwable) {
Log.e(tag, "file_actions.pickMusicFolder launch failed", error)
pendingMusicFolderResult?.success(null)
pendingMusicFolderResult = null
}
}
// listAudioChildren / resolvePlayableUri /
// hasPersistedPermission / readAudioMetadataBatch los
// atiende FileActionsHandler arriba (item 3): no necesitan
// Activity, asi que tienen que poder registrarse tambien en
// un engine que no la tiene.
else -> result.notImplemented()
}
}
}
/**
* Handles the [pickMusicFolderRequestCode] round trip from
* `file_actions.pickMusicFolder` (android-auto-local-music, static
* review only — no existing `onActivityResult` override existed on this
* Activity before this change). On a successful pick, persists the
* granted read permission via [android.content.ContentResolver.takePersistableUriPermission]
* and resolves the pending [MethodChannel.Result] with the tree URI
* string; on cancel, missing data, or a persistence failure, resolves
* with `null` instead of throwing. Any other request code is delegated
* to `super` untouched.
*/
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
if (requestCode == pickMusicFolderRequestCode) {
val pending = pendingMusicFolderResult
pendingMusicFolderResult = null
val treeUri = data?.data
if (resultCode == RESULT_OK && treeUri != null) {
try {
contentResolver.takePersistableUriPermission(
treeUri,
Intent.FLAG_GRANT_READ_URI_PERMISSION
)
Log.d(tag, "file_actions.pickMusicFolder picked uri=$treeUri")
pending?.success(treeUri.toString())
} catch (error: Throwable) {
Log.e(
tag,
"file_actions.pickMusicFolder takePersistableUriPermission failed",
error
)
pending?.success(null)
}
} else {
Log.d(
tag,
"file_actions.pickMusicFolder cancelled or no data resultCode=$resultCode"
)
pending?.success(null)
}
return
}
super.onActivityResult(requestCode, resultCode, data)
}
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
setIntent(intent)
@@ -310,6 +488,54 @@ class MainActivity : AudioServiceActivity() {
return powerManager.isIgnoringBatteryOptimizations(packageName)
}
private fun requestIgnoreBatteryOptimizations(): Boolean {
if (isIgnoringBatteryOptimizations()) return true
return try {
startActivity(
Intent(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS).apply {
data = Uri.parse("package:$packageName")
}
)
true
} catch (error: Throwable) {
Log.e(tag, "alarm.channel requestIgnoreBatteryOptimizations failed", error)
false
}
}
/**
* Opens the system's per-app notification settings screen directly
* (diagnostics screen, fix/alarmas-fiabilidad). Unlike
* [requestPostNotificationsPermission] -- which shows the runtime
* permission popup and is meant for the FIRST time an alarm is created
* -- this is meant for a user troubleshooting an alarm that already
* failed, where the OS may no longer show that popup at all after a
* prior denial. `ACTION_APP_NOTIFICATION_SETTINGS` only exists from API
* 26; older devices fall back to the app's own details screen, which
* still surfaces the notification toggle. Never throws across the
* channel boundary -- an unresolvable intent on some ROM is caught and
* reported as `false`, same shape as every other `request*`/`open*`
* helper in this class.
*/
private fun openNotificationSettings(): Boolean {
return try {
val intent = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS).apply {
putExtra(Settings.EXTRA_APP_PACKAGE, packageName)
}
} else {
Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply {
data = Uri.parse("package:$packageName")
}
}
startActivity(intent)
true
} catch (error: Throwable) {
Log.e(tag, "alarm.channel openNotificationSettings failed", error)
false
}
}
private fun openDirectory(path: String): Boolean {
val folder = File(path)
if (!folder.exists()) {
@@ -349,55 +575,89 @@ class MainActivity : AudioServiceActivity() {
return opened
}
/**
* Opens [path] in whatever app the system uses to browse folders.
*
* The default recordings folder lives in app-private storage, which NO file
* manager can reach through a `file://` or `FileProvider` URI -- the Android
* sandbox forbids other apps from reading `/data/user/0/<package>/`. That is
* why the old `resource/folder` candidate could never work.
* [RecordingsDocumentsProvider] publishes the folder as a document root
* instead, so the candidates below hand the system a URI it can actually
* resolve without a single byte leaving private storage. A user-configured
* folder on shared storage keeps using the platform's own external-storage
* provider, which the file manager already indexes.
*
* `startActivity` is used directly instead of `Intent.createChooser`,
* because a chooser never throws when nothing can handle the intent (it just
* shows an empty dialog). That swallowed the failure and stopped the
* fallback chain from ever running.
*/
private fun viewDirectory(path: String): Boolean {
val directory = File(path)
if (!directory.exists()) {
directory.mkdirs()
}
// Point the published root at the folder Flutter is really using, so a
// path changed in Settings is what the file manager shows.
RecordingsDocumentsProvider.rememberRoot(this, path)
val candidates = mutableListOf<Intent>()
val candidates = mutableListOf<Pair<String, Intent>>()
// Shared storage: the platform provider already exposes this folder, so
// prefer it when the user picked a public path.
directoryDocumentUri(path)?.let { uri ->
candidates.add(
Intent(Intent.ACTION_VIEW).apply {
setDataAndType(uri, "vnd.android.document/directory")
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
)
candidates.add(
Intent(Intent.ACTION_VIEW).apply {
setData(uri)
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
)
}
try {
val uri = FileProvider.getUriForFile(this, "$packageName.fileprovider", directory)
candidates.add(
Intent(Intent.ACTION_VIEW).apply {
setDataAndType(uri, "resource/folder")
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
)
} catch (error: Throwable) {
Log.w(tag, "file_actions.viewDirectory fileprovider unavailable path=$path", error)
}
for (intent in candidates) {
try {
startActivity(Intent.createChooser(intent, "Abrir carpeta"))
Log.d(tag, "file_actions.viewDirectory launched path=$path")
return true
} catch (_: ActivityNotFoundException) {
Log.w(tag, "file_actions.viewDirectory no activity for candidate path=$path")
} catch (error: Throwable) {
Log.e(tag, "file_actions.viewDirectory candidate failed path=$path", error)
candidates += "externalstorage-dir" to Intent(Intent.ACTION_VIEW).apply {
setDataAndType(uri, directoryDocumentMimeType)
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
}
// Our own root: handled by DocumentsUI's file browser on every device
// that ships it, and the only option that works for private storage.
candidates += "recordings-root" to Intent(Intent.ACTION_VIEW).apply {
setDataAndType(
RecordingsDocumentsProvider.rootUri(this@MainActivity),
rootDocumentMimeType
)
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
candidates += "recordings-dir" to Intent(Intent.ACTION_VIEW).apply {
setDataAndType(
RecordingsDocumentsProvider.rootDocumentUri(this@MainActivity),
directoryDocumentMimeType
)
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
// Last resort: DocumentsUI always handles OPEN_DOCUMENT_TREE, and
// EXTRA_INITIAL_URI lands it straight on the recordings folder.
candidates += "recordings-tree" to Intent(Intent.ACTION_OPEN_DOCUMENT_TREE).apply {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
putExtra(
DocumentsContract.EXTRA_INITIAL_URI,
RecordingsDocumentsProvider.rootTreeUri(this@MainActivity)
)
}
}
for ((origin, intent) in candidates) {
if (openIntentSafely(intent, "file_actions.viewDirectory $origin", path, requireData = false)) {
return true
}
}
Log.w(tag, "file_actions.viewDirectory no candidate could be launched path=$path")
return false
}
private fun openIntentSafely(intent: Intent?, origin: String, path: String): Boolean {
if (intent == null || intent.data == null) return false
private fun openIntentSafely(
intent: Intent?,
origin: String,
path: String,
requireData: Boolean = true,
): Boolean {
if (intent == null) return false
if (requireData && intent.data == null) return false
return try {
startActivity(intent)
Log.d(tag, "$origin launched path=$path")
@@ -428,15 +688,17 @@ class MainActivity : AudioServiceActivity() {
clipData = ClipData.newUri(contentResolver, "recording", uri)
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
startActivity(Intent.createChooser(intent, "Abrir grabación"))
startActivity(
Intent.createChooser(intent, AlarmNotificationStrings.openRecordingTitle(this))
)
Log.d(tag, "file_actions.openFile launched path=$path")
true
} catch (_: ActivityNotFoundException) {
Log.w(tag, "file_actions.openFile no viewer path=$path; opening parent")
openDirectory(file.parentFile?.absolutePath ?: path)
viewDirectory(file.parentFile?.absolutePath ?: path)
} catch (error: Throwable) {
Log.e(tag, "file_actions.openFile failed path=$path; opening parent", error)
openDirectory(file.parentFile?.absolutePath ?: path)
viewDirectory(file.parentFile?.absolutePath ?: path)
}
}
@@ -457,6 +719,10 @@ class MainActivity : AudioServiceActivity() {
if (!path.startsWith(external)) return null
val relative = path.removePrefix(external).trimStart('/')
// Android 11+ hides Android/data and Android/obb from the document
// framework, so a URI into them resolves to nothing useful.
if (relative.startsWith("Android/data") || relative.startsWith("Android/obb")) return null
val documentId = if (relative.isBlank()) "primary:" else "primary:$relative"
return DocumentsContract.buildDocumentUri(
"com.android.externalstorage.documents",
@@ -561,6 +827,14 @@ class MainActivity : AudioServiceActivity() {
) {
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
if (requestCode == notificationPermissionRequestCode) return
if (requestCode == bluetoothConnectPermissionRequestCode) {
if (grantResults.firstOrNull() == PackageManager.PERMISSION_GRANTED) {
val device = getActiveAudioDevice()
Log.d(tag, "audio_devices.requestBluetoothConnect granted -> $device")
audioDevicesSink?.success(device)
}
return
}
if (requestCode != visualizerPermissionRequestCode) return
if (grantResults.firstOrNull() == PackageManager.PERMISSION_GRANTED) {
@@ -574,8 +848,340 @@ class MainActivity : AudioServiceActivity() {
}
}
// -------------------------------------------------------------------------
// Audio Devices Channel
// -------------------------------------------------------------------------
private fun setupAudioDevicesChannel(flutterEngine: FlutterEngine) {
val messenger = flutterEngine.dartExecutor.binaryMessenger
MethodChannel(messenger, audioDevicesChannel).setMethodCallHandler { call, result ->
when (call.method) {
"getActiveDevice" -> {
val device = getActiveAudioDevice()
Log.d(tag, "audio_devices.getActiveDevice -> $device")
result.success(device)
}
"requestBluetoothConnect" -> {
Log.d(tag, "audio_devices.requestBluetoothConnect")
result.success(requestBluetoothConnect())
}
"getBondedDeviceNames" -> {
val names = bondedDeviceNames()
Log.d(tag, "audio_devices.getBondedDeviceNames count=${names.size}")
result.success(names)
}
else -> result.notImplemented()
}
}
EventChannel(messenger, audioDevicesChannel).setStreamHandler(
object : EventChannel.StreamHandler {
override fun onListen(arguments: Any?, events: EventChannel.EventSink?) {
audioDevicesSink = events
registerAudioDeviceCallback()
// Immediate resync: emit the current active device on every
// (re)subscription. The Flutter engine outlives the Activity
// (AudioServiceActivity), so a recreated Activity installs a
// fresh StreamHandler that never sees a "listen" until Dart
// resubscribes — without this emission the Dart side would
// keep a stale device until the next physical connect event.
val device = getActiveAudioDevice()
Log.d(tag, "audio_devices.onListen -> $device")
events?.success(device)
}
override fun onCancel(arguments: Any?) {
unregisterAudioDeviceCallback()
audioDevicesSink = null
}
}
)
}
private fun requestBluetoothConnect(): Boolean {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) return true
if (checkSelfPermission(Manifest.permission.BLUETOOTH_CONNECT) ==
PackageManager.PERMISSION_GRANTED
) {
return true
}
requestPermissions(
arrayOf(Manifest.permission.BLUETOOTH_CONNECT),
bluetoothConnectPermissionRequestCode
)
return true
}
/**
* Returns MAC -> name for every PAIRED Bluetooth device, connected or not.
*
* `AudioDeviceInfo.productName` only exists while a device is enumerated as
* an active output, so a paired-but-switched-off device can never report its
* own name and its row falls back to the raw id. The bond list is the
* system's own record and is the only source that survives disconnection.
*
* Returns an empty map instead of throwing when the answer is unavailable
* (BLUETOOTH_CONNECT denied, no adapter, device with Bluetooth off): a
* missing name must degrade to the id, never break device resolution.
*/
private fun bondedDeviceNames(): Map<String, String> {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S &&
checkSelfPermission(Manifest.permission.BLUETOOTH_CONNECT)
!= PackageManager.PERMISSION_GRANTED
) {
Log.d(tag, "audio_devices.bondedDeviceNames BLUETOOTH_CONNECT not granted")
return emptyMap()
}
return try {
val manager = getSystemService(Context.BLUETOOTH_SERVICE) as? BluetoothManager
val adapter = manager?.adapter ?: return emptyMap()
adapter.bondedDevices
.orEmpty()
.mapNotNull { device ->
val address = device.address
?.takeIf { it.isNotBlank() && it != bluetoothMacPlaceholder }
?: return@mapNotNull null
val name = device.name?.takeIf { it.isNotBlank() }
?: return@mapNotNull null
address.uppercase() to name
}
.toMap()
} catch (error: Throwable) {
Log.w(tag, "audio_devices.bondedDeviceNames failed", error)
emptyMap()
}
}
private fun registerAudioDeviceCallback() {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) return
val audioManager = getSystemService(Context.AUDIO_SERVICE) as AudioManager
// Idempotent: a re-listen without a prior cancel must not leak the
// previously registered callback.
audioDeviceCallback?.let { audioManager.unregisterAudioDeviceCallback(it) }
val callback = object : AudioDeviceCallback() {
override fun onAudioDevicesAdded(addedDevices: Array<out AudioDeviceInfo>) {
// Emit the current active output device when something connects.
val device = getActiveAudioDevice()
Log.d(tag, "audio_devices.onDevicesAdded active=$device")
mainHandler.post { audioDevicesSink?.success(device) }
}
override fun onAudioDevicesRemoved(removedDevices: Array<out AudioDeviceInfo>) {
// Emit the new active device after something disconnects.
// AudioManager.getDevices() can still momentarily report a
// just-removed sink (observed on Bluetooth A2DP), so the
// removed ids are excluded explicitly instead of trusting
// getDevices() to already be up to date.
val excludedIds = removedDevices.map { it.id }.toSet()
val device = getActiveAudioDevice(excludeIds = excludedIds)
Log.d(tag, "audio_devices.onDevicesRemoved active=$device")
mainHandler.post { audioDevicesSink?.success(device) }
}
}
audioDeviceCallback = callback
audioManager.registerAudioDeviceCallback(callback, mainHandler)
}
private fun unregisterAudioDeviceCallback() {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) return
val audioManager = getSystemService(Context.AUDIO_SERVICE) as AudioManager
audioDeviceCallback?.let { audioManager.unregisterAudioDeviceCallback(it) }
audioDeviceCallback = null
}
/**
* Returns a map describing the current active audio output device.
*
* Device ID format (matches spec):
* - "builtin_speaker" — TYPE_BUILTIN_SPEAKER (2)
* - "wired_headset" — TYPE_WIRED_HEADSET (3) or TYPE_WIRED_HEADPHONES (4)
* - "bt_a2dp:<MAC>" — TYPE_BLUETOOTH_A2DP (8); MAC from AudioDeviceInfo.address
* - "bt_a2dp:name:<productName>" — TYPE_BLUETOOTH_A2DP (8) fallback when the MAC is absent
* or still the OS placeholder ("02:00:00:00:00:00", seen
* without BLUETOOTH_CONNECT); productName colons are
* sanitized to '-' to preserve the matrix-key delimiter
* - "usb_headset:<address>" — TYPE_USB_HEADSET (22)
* - "other:<type>:<address>" — any other external output this build
* does not name individually
* - "builtin_speaker" — fallback when API < 23
*
* Type int values sent to Dart match the AudioDeviceInfo.TYPE_* constants.
*/
private fun getActiveAudioDevice(excludeIds: Set<Int> = emptySet()): Map<String, Any> {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) {
return mapOf("id" to "builtin_speaker", "type" to 2, "name" to "Speaker")
}
val audioManager = getSystemService(Context.AUDIO_SERVICE) as AudioManager
val outputs = audioManager.getDevices(AudioManager.GET_DEVICES_OUTPUTS)
// The built-in speaker is the LAST resort, never a peer in this list:
// it is always present, so ranking it alongside the others made any
// output type absent from the list (LE Audio car stereos, car buses,
// docks) sort BELOW it and never win — the car would connect and the
// phone speaker would still be reported as the active device.
val best = outputs
.filter { it.isSink && it.id !in excludeIds }
.minByOrNull { device -> outputPriority(device.type) }
return deviceToMap(best)
}
/**
* Media outputs a user actively connects, in the order they should win when
* several are present. Index IS the priority.
*
* This is an ALLOW list on purpose. Ranking "everything not named here"
* above the built-in speaker looks equivalent and is not: a phone
* permanently exposes internal sinks that are legitimate outputs but never
* where music is playing -- TYPE_FM (14) on this project's Xiaomi test
* device, TYPE_BUILTIN_SPEAKER_SAFE (24) on many others. Those outranked
* the real speaker, got reported as the active device and had a preset row
* persisted for them.
*/
private val externalOutputPriority = listOf(
AudioDeviceInfo.TYPE_BLUETOOTH_A2DP,
AudioDeviceInfo.TYPE_BLE_HEADSET,
AudioDeviceInfo.TYPE_BLE_SPEAKER,
AudioDeviceInfo.TYPE_BLE_BROADCAST,
AudioDeviceInfo.TYPE_HEARING_AID,
AudioDeviceInfo.TYPE_BUS,
AudioDeviceInfo.TYPE_USB_HEADSET,
AudioDeviceInfo.TYPE_USB_DEVICE,
AudioDeviceInfo.TYPE_USB_ACCESSORY,
AudioDeviceInfo.TYPE_WIRED_HEADSET,
AudioDeviceInfo.TYPE_WIRED_HEADPHONES,
AudioDeviceInfo.TYPE_LINE_ANALOG,
AudioDeviceInfo.TYPE_LINE_DIGITAL,
AudioDeviceInfo.TYPE_AUX_LINE,
AudioDeviceInfo.TYPE_DOCK,
AudioDeviceInfo.TYPE_HDMI,
AudioDeviceInfo.TYPE_HDMI_ARC,
)
/**
* Ranks an [AudioDeviceInfo] type as a media output candidate; lower wins.
*
* The built-in speaker is the fallback, so it sits below every external
* output and above everything else — including outputs that physically
* exist but are never where media plays.
*/
private fun outputPriority(type: Int): Int {
val index = externalOutputPriority.indexOf(type)
if (index >= 0) return index
return if (type == AudioDeviceInfo.TYPE_BUILTIN_SPEAKER) 90 else 99
}
/**
* [AudioDeviceInfo.getAddress] is API 28 while minSdk is 24, so reading it
* unguarded throws NoSuchMethodError on Android 7-8.1. Below API 28 there is
* no address to read and callers fall back to a non-MAC identity (the
* `bt_a2dp:name:` placeholder shape, or the device's session id).
*/
private fun deviceAddress(device: AudioDeviceInfo): String? =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) device.address else null
private fun deviceToMap(device: AudioDeviceInfo?): Map<String, Any> {
if (device == null) {
return mapOf("id" to "builtin_speaker", "type" to 2, "name" to "Speaker")
}
return when (device.type) {
AudioDeviceInfo.TYPE_BUILTIN_SPEAKER ->
mapOf("id" to "builtin_speaker", "type" to 2, "name" to (device.productName?.toString() ?: "Speaker"))
AudioDeviceInfo.TYPE_WIRED_HEADSET ->
mapOf("id" to "wired_headset", "type" to 3, "name" to (device.productName?.toString() ?: "Wired Headset"))
AudioDeviceInfo.TYPE_WIRED_HEADPHONES ->
mapOf("id" to "wired_headset", "type" to 3, "name" to (device.productName?.toString() ?: "Wired Headphones"))
AudioDeviceInfo.TYPE_BLUETOOTH_A2DP -> {
// The OS reports a placeholder MAC ("02:00:00:00:00:00") when
// BLUETOOTH_CONNECT has not been granted; treat that (and any
// null/blank address) as absent instead of using it as an id.
val mac = deviceAddress(device)?.takeIf { it.isNotBlank() && it != bluetoothMacPlaceholder }
val id = if (mac != null) {
"bt_a2dp:$mac"
} else {
val safeProductName = (device.productName?.toString()?.takeIf { it.isNotBlank() } ?: "unknown")
.replace(":", "-")
"bt_a2dp:name:$safeProductName"
}
mapOf(
"id" to id,
"type" to 8,
"name" to (device.productName?.toString() ?: "Bluetooth"),
)
}
AudioDeviceInfo.TYPE_USB_HEADSET -> {
val addr = deviceAddress(device)?.takeIf { it.isNotBlank() } ?: device.id.toString()
mapOf(
"id" to "usb_headset:$addr",
// Send the real constant (22). The hardcoded 14 that used to
// sit here is TYPE_FM, and Dart mirrored the mistake, so a
// phone's own FM sink decoded as a USB headset.
"type" to AudioDeviceInfo.TYPE_USB_HEADSET,
"name" to (device.productName?.toString() ?: "USB Headset"),
)
}
// NEVER reuse builtin_speaker's id here. An output this build does
// not name individually (LE Audio car stereo, car bus, dock) would
// collide with the phone's own speaker: Dart persisted a device
// entry under that shared id, and from then on every playback
// through the phone speaker matched it, pinning the green
// active-device marker to the wrong row forever. The type is kept
// verbatim so Dart can still tell it apart from a real speaker.
else -> {
val address = deviceAddress(device)
?.takeIf { it.isNotBlank() && it != bluetoothMacPlaceholder }
?: device.id.toString()
mapOf(
"id" to "other:${device.type}:$address",
"type" to device.type,
"name" to (device.productName?.toString() ?: "Unknown"),
)
}
}
}
// -------------------------------------------------------------------------
override fun onDestroy() {
if (activeInstance === this) {
activeInstance = null
}
unregisterAudioDeviceCallback()
stopVisualizer()
super.onDestroy()
}
companion object {
private const val STATIC_TAG = "PluriWave"
/** alarmAction reported when the native service snoozed by itself. */
const val ALARM_ACTION_SNOOZED = "snoozed"
/** alarmAction reported when a pending snooze was cancelled natively. */
const val ALARM_ACTION_SNOOZE_CANCELLED = "snoozeCancelled"
/** alarmAction reported when a fired alarm auto-silenced unattended (Decision 3). */
const val ALARM_ACTION_MISSED = "missed"
@Volatile
private var activeInstance: MainActivity? = null
/**
* Bridge for components without an activity (PluriWaveAlarmService):
* forwards alarm events through the existing alarmFired MethodChannel
* when the Flutter engine is alive; no-op when dead — the cold-start
* sync (getNativeSnoozeState) covers that case (Decision 2.1).
*/
fun notifyAlarmEvent(payload: Map<String, Any?>) {
val activity = activeInstance
if (activity == null) {
Log.d(STATIC_TAG, "alarm.channel notifyAlarmEvent skipped (engine dead)")
return
}
activity.mainHandler.post {
activity.alarmMethodChannel?.invokeMethod("alarmFired", payload)
}
}
}
}
@@ -0,0 +1,14 @@
package es.freetimelab.pluriwave
import androidx.annotation.ColorInt
/**
* Shared brand color for native notification icons.
*
* Single source of truth for the cyan tint applied via `NotificationCompat.Builder.setColor()`
* across all PluriWave alarm and audio notifications, mirroring the [AlarmNotificationStrings]
* shared-constants precedent.
*/
object NotificationBrand {
@ColorInt const val CYAN: Int = 0xFF21D4D9.toInt()
}
@@ -34,7 +34,9 @@ class PluriWaveAlarmReceiver : BroadcastReceiver() {
putExtra(EXTRA_OCCURRENCE_AT, intent.getLongExtra(EXTRA_OCCURRENCE_AT, 0L))
putExtra(EXTRA_SNOOZE_MINUTES, snoozeMinutes)
}
showFireNotification(context, alarmId, title, launch, snoozeMinutes)
// The service's startForeground notification (single FSI owner) is
// posted by PluriWaveAlarmService.start above; the receiver must NOT
// post a duplicate fire notification.
try {
context.startActivity(launch)
Log.d(TAG, "alarm.receiver fire startActivity OK id=$alarmId")
@@ -54,6 +56,7 @@ class PluriWaveAlarmReceiver : BroadcastReceiver() {
}
ACTION_POSTPONE_NEXT -> {
NotificationManagerCompat.from(context).cancel(notificationIdForAlarm(alarmId))
AlarmScheduler(context).cancelPreNoticeCountdown(alarmId)
val occurrenceAt = AlarmScheduler(context).postponeNext(alarmId, snoozeMinutes)
?: intent.getLongExtra(EXTRA_OCCURRENCE_AT, 0L)
val launch = Intent(context, MainActivity::class.java).apply {
@@ -74,6 +77,7 @@ class PluriWaveAlarmReceiver : BroadcastReceiver() {
}
ACTION_SKIP_NEXT -> {
NotificationManagerCompat.from(context).cancel(notificationIdForAlarm(alarmId))
AlarmScheduler(context).cancelPreNoticeCountdown(alarmId)
AlarmScheduler(context).skipNext(alarmId)
val launch = Intent(context, MainActivity::class.java).apply {
this.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
@@ -88,50 +92,47 @@ class PluriWaveAlarmReceiver : BroadcastReceiver() {
Log.e(TAG, "alarm.receiver skipNext startActivity ERROR id=$alarmId", error)
}
}
ACTION_SNOOZE_COUNTDOWN -> {
AlarmScheduler(context).handleSnoozeCountdownTick(alarmId)
}
ACTION_MISSED -> {
AlarmScheduler(context).onAlarmMissed(alarmId)
}
ACTION_SNOOZE_AGAIN -> {
val snoozed = AlarmScheduler(context).snoozeAgain(alarmId, snoozeMinutes)
if (snoozed != null) {
// Reuses the existing native-snooze event so Flutter records
// the new snooze (live) and the cold-start sync imports it.
MainActivity.notifyAlarmEvent(
mapOf(
"alarmId" to alarmId,
"alarmTitle" to snoozed.title,
"alarmAction" to MainActivity.ALARM_ACTION_SNOOZED,
"occurrenceAtMillis" to snoozed.occurrenceAtMillis,
"snoozeUntilMillis" to snoozed.snoozeUntilMillis,
"snoozeMinutes" to snoozeMinutes
)
)
}
}
ACTION_CANCEL_SNOOZE -> {
val occurrence = AlarmScheduler(context).cancelSnooze(alarmId)
NotificationManagerCompat.from(context).cancel(notificationIdForAlarm(alarmId))
if (occurrence != null) {
MainActivity.notifyAlarmEvent(
mapOf(
"alarmId" to alarmId,
"alarmTitle" to title,
"alarmAction" to MainActivity.ALARM_ACTION_SNOOZE_CANCELLED,
"occurrenceAtMillis" to occurrence
)
)
}
}
else -> Log.w(TAG, "alarm.receiver unknown action=${intent.action} id=$alarmId")
}
}
private fun showFireNotification(
context: Context,
alarmId: String,
title: String,
launch: Intent,
snoozeMinutes: Int
) {
ensureFireChannel(context)
val fullScreenIntent = PendingIntent.getActivity(
context,
requestCode(alarmId, 10),
launch,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
val notification = NotificationCompat.Builder(context, FIRE_CHANNEL_ID)
.setSmallIcon(android.R.drawable.ic_lock_idle_alarm)
.setContentTitle("Alarma PluriWave")
.setContentText(title)
.setCategory(NotificationCompat.CATEGORY_ALARM)
.setPriority(NotificationCompat.PRIORITY_MAX)
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
.setOngoing(true)
.setAutoCancel(false)
.setContentIntent(fullScreenIntent)
.setFullScreenIntent(fullScreenIntent, true)
.addAction(0, "Posponer", snoozePendingIntent(context, alarmId, snoozeMinutes))
.addAction(0, "Detener", stopPendingIntent(context, alarmId))
.build()
try {
NotificationManagerCompat.from(context).notify(
fireNotificationIdForAlarm(alarmId),
notification,
)
Log.d(TAG, "alarm.notification fire shown id=$alarmId")
} catch (error: SecurityException) {
Log.e(TAG, "alarm.notification fire SecurityException id=$alarmId", error)
}
}
private fun showPreNoticeNotification(
context: Context,
alarmId: String,
@@ -142,6 +143,9 @@ class PluriWaveAlarmReceiver : BroadcastReceiver() {
) {
ensureChannel(context)
val remaining = computeRemainingMinutes(triggerAtMillis)
val contentText = AlarmNotificationStrings.preNoticeText(context, remaining)
val openAppIntent = PendingIntent.getActivity(
context,
requestCode(alarmId, 1),
@@ -180,109 +184,117 @@ class PluriWaveAlarmReceiver : BroadcastReceiver() {
)
val notification = NotificationCompat.Builder(context, CHANNEL_ID)
.setSmallIcon(android.R.drawable.ic_dialog_info)
.setSmallIcon(R.drawable.ic_stat_pluriwave)
.setColor(NotificationBrand.CYAN)
.setContentTitle(title)
.setContentText("Empieza en 30 minutos")
.setContentText(contentText)
.setPriority(NotificationCompat.PRIORITY_LOW)
.setCategory(NotificationCompat.CATEGORY_REMINDER)
.setSilent(true)
.setAutoCancel(true)
.setContentIntent(openAppIntent)
.addAction(0, "Posponer", postponeNextIntent)
.addAction(0, "Omitir esta vez", skipNextIntent)
.addAction(0, AlarmNotificationStrings.snoozeLabel(context), postponeNextIntent)
.addAction(0, AlarmNotificationStrings.skipLabel(context), skipNextIntent)
.build()
try {
NotificationManagerCompat.from(context).notify(notificationIdForAlarm(alarmId), notification)
Log.d(TAG, "alarm.notification preNotice shown id=$alarmId")
Log.d(TAG, "alarm.notification preNotice shown id=$alarmId remaining=$remaining")
} catch (error: SecurityException) {
Log.e(TAG, "alarm.notification preNotice SecurityException id=$alarmId", error)
}
// Re-arm the next minute tick so the countdown keeps live-updating
// until the real alarm fires. Reuses the SAME [remaining] computed
// above for the notification text to avoid a second clock read that
// could drift and cause an off-by-one between displayed text and the
// next-boundary math.
AlarmScheduler(context).armNextPreNoticeCountdownTick(
id = alarmId,
title = title,
snoozeMinutes = snoozeMinutes,
triggerAtMillis = triggerAtMillis,
occurrenceAtMillis = occurrenceAtMillis,
remaining = remaining
)
}
private fun ensureFireChannel(context: Context) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
val existing = manager.getNotificationChannel(FIRE_CHANNEL_ID)
if (existing != null) return
val channel = NotificationChannel(
FIRE_CHANNEL_ID,
"Alarmas sonando",
NotificationManager.IMPORTANCE_HIGH
).apply {
description = "Pantalla urgente cuando una alarma musical debe sonar"
enableVibration(true)
}
manager.createNotificationChannel(channel)
}
/**
* Computes the number of minutes remaining until [triggerAtMillis] using
* ceiling rounding (consistent with [AlarmScheduler]'s snooze-countdown
* ceilMinutes), clamped to a minimum of 1. Handles Doze-delayed wakeups
* and clock drift.
*/
private fun computeRemainingMinutes(triggerAtMillis: Long): Long =
maxOf(1L, (triggerAtMillis - System.currentTimeMillis() + 59_999L) / 60_000L)
private fun ensureChannel(context: Context) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
val existing = manager.getNotificationChannel(CHANNEL_ID)
if (existing != null) return
// Re-create each time so the localized name/description refresh after a
// locale change (Android updates them on an existing channel).
val channel = NotificationChannel(
CHANNEL_ID,
"Preavisos de alarmas",
AlarmNotificationStrings.preNoticeChannelName(context),
NotificationManager.IMPORTANCE_LOW
).apply {
description = "Notificaciones silenciosas 30 minutos antes de la alarma"
description = AlarmNotificationStrings.preNoticeChannelDescription(context)
setSound(null, null)
enableVibration(false)
}
manager.createNotificationChannel(channel)
}
private fun requestCode(id: String, slot: Int): Int = 47 * id.hashCode() + slot
private fun sanitizeSnoozeMinutes(minutes: Int): Int =
if (minutes == 3 || minutes == 5 || minutes == 10) minutes else 5
private fun snoozePendingIntent(context: Context, alarmId: String, minutes: Int): PendingIntent =
PendingIntent.getService(
context,
requestCode(alarmId, 20 + minutes),
Intent(context, PluriWaveAlarmService::class.java).apply {
action = PluriWaveAlarmService.ACTION_SNOOZE
putExtra(EXTRA_ALARM_ID, alarmId)
putExtra(PluriWaveAlarmService.EXTRA_SNOOZE_MINUTES, minutes)
},
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
private fun stopPendingIntent(context: Context, alarmId: String): PendingIntent =
PendingIntent.getService(
context,
requestCode(alarmId, 40),
Intent(context, PluriWaveAlarmService::class.java).apply {
action = PluriWaveAlarmService.ACTION_STOP
putExtra(EXTRA_ALARM_ID, alarmId)
},
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
companion object {
const val TAG = "PluriWave"
const val CHANNEL_ID = "pluriwave_alarm_pre_notice"
const val FIRE_CHANNEL_ID = "pluriwave_alarm_fire"
const val ACTION_FIRE = "es.freetimelab.pluriwave.alarm.FIRE"
const val ACTION_PRE_NOTICE = "es.freetimelab.pluriwave.alarm.PRE_NOTICE"
const val ACTION_SKIP_NEXT = "es.freetimelab.pluriwave.alarm.SKIP_NEXT"
const val ACTION_POSTPONE_NEXT = "es.freetimelab.pluriwave.alarm.POSTPONE_NEXT"
const val ACTION_SNOOZE_COUNTDOWN = "es.freetimelab.pluriwave.alarm.SNOOZE_COUNTDOWN"
const val ACTION_SNOOZE_AGAIN = "es.freetimelab.pluriwave.alarm.SNOOZE_AGAIN"
const val ACTION_CANCEL_SNOOZE = "es.freetimelab.pluriwave.alarm.CANCEL_SNOOZE"
const val ACTION_MISSED = "es.freetimelab.pluriwave.alarm.MISSED"
const val EXTRA_ALARM_ID = "alarmId"
const val EXTRA_ALARM_TITLE = "alarmTitle"
const val EXTRA_ALARM_ACTION = "alarmAction"
const val EXTRA_STATION_NAME = "stationName"
const val EXTRA_STATION_URL = "stationUrl"
const val EXTRA_FALLBACK_STATION_NAME = "fallbackStationName"
const val EXTRA_FALLBACK_STATION_URL = "fallbackStationUrl"
const val EXTRA_FALLBACK_SOUND = "fallbackSound"
const val EXTRA_VOLUME = "volume"
const val EXTRA_FADE_IN_SECONDS = "fadeInSegundos"
const val EXTRA_TRIGGER_AT = "triggerAtMillis"
const val EXTRA_OCCURRENCE_AT = "occurrenceAtMillis"
const val EXTRA_SNOOZE_MINUTES = "snoozeMinutes"
fun notificationIdForAlarm(alarmId: String): Int = 53 * alarmId.hashCode() + 7
fun fireNotificationIdForAlarm(alarmId: String): Int = 59 * alarmId.hashCode() + 9
/**
* Shared PendingIntent requestCode formula (READ-3/READ-4): kept in
* ONE place so instance call sites (showPreNoticeNotification, which
* resolve this unqualified via companion-member lookup) and
* companion-object call sites ([pendingMissedIntent]) can never
* diverge into two different formulas for the same alarm id.
*/
private fun requestCode(id: String, slot: Int): Int = 47 * id.hashCode() + slot
/** Shared PendingIntent factory for the MISSED transition alarm (Decision 3). */
fun pendingMissedIntent(context: Context, alarmId: String, flags: Int): PendingIntent? =
PendingIntent.getBroadcast(
context,
requestCode(alarmId, 4),
Intent(context, PluriWaveAlarmReceiver::class.java).apply {
action = ACTION_MISSED
putExtra(EXTRA_ALARM_ID, alarmId)
},
flags or PendingIntent.FLAG_IMMUTABLE
)
}
}
@@ -6,7 +6,10 @@ import android.app.PendingIntent
import android.app.Service
import android.content.Context
import android.content.Intent
import android.content.pm.ServiceInfo
import android.media.AudioAttributes
import android.media.AudioFocusRequest
import android.media.AudioManager
import android.media.MediaPlayer
import android.net.Uri
import android.os.Build
@@ -14,21 +17,57 @@ import android.os.Handler
import android.os.IBinder
import android.os.Looper
import android.os.PowerManager
import android.os.SystemClock
import android.util.Log
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.core.content.ContextCompat
import java.io.File
/**
* Foreground service that owns native alarm audio and the single ringing
* notification (NOTIFICATION_ID, full-screen intent).
*
* Sole ring-audio ownership: this service is the ONLY audio source for the
* whole ring, from start to dismiss/snooze/timeout, on STREAM_ALARM via its
* own MediaPlayer (station stream, fallback station, or bundled WAV). The
* Flutter ringing screen is display-only: it never starts a player and
* never touches system volume, only EstadoAlarmas.finalizarEjecucion /
* posponerAlarma from Stop/Snooze/back.
*/
class PluriWaveAlarmService : Service() {
private var player: MediaPlayer? = null
private var wakeLock: PowerManager.WakeLock? = null
private var activeAlarmId: String? = null
private val mainHandler = Handler(Looper.getMainLooper())
private var stationFallbackRunnable: Runnable? = null
private var fadeLoopRunnable: Runnable? = null
private var fadeAnchorElapsedMs: Long = 0L
private var audioFocusRequest: AudioFocusRequest? = null
private val noopAudioFocusChangeListener = AudioManager.OnAudioFocusChangeListener { }
override fun onBind(intent: Intent?): IBinder? = null
/**
* Paired-write helper (feedback item, READ-6): the instance-scoped
* [activeAlarmId] and the same-process companion [activeRingingId] must
* always move together -- setting one without the other would let
* [stopActiveVerified] read a stale/wrong ring state. Used at every write
* site instead of assigning each field separately.
*/
private fun setActiveIds(id: String?) {
activeAlarmId = id
activeRingingId = id
}
override fun onCreate() {
super.onCreate()
// Same-process companion instance (feedback item 1, RISK-1/RES-1/REL-2):
// lets stopActiveVerified() call stopEverything() SYNCHRONOUSLY instead
// of trusting an async startService dispatch to have completed.
instance = this
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
val action = intent?.action
val requestedId = intent?.getStringExtra(PluriWaveAlarmReceiver.EXTRA_ALARM_ID)
@@ -39,10 +78,34 @@ class PluriWaveAlarmService : Service() {
stopAlarm(requestedId)
return START_NOT_STICKY
}
ACTION_STOP_ACTIVE -> {
// Id-agnostic fail-safe stop (Decision 1): silences whatever is
// ringing regardless of the id the caller passed (or omitted).
// Used by the ringing UI and the notification Stop action so a
// stop request can never silently no-op a live ring.
stopEverything()
return START_NOT_STICKY
}
ACTION_SNOOZE -> {
val minutes = intent.getIntExtra(EXTRA_SNOOZE_MINUTES, 5)
if (requestedId != null) {
AlarmScheduler(this).snooze(requestedId, minutes)
val snoozed = AlarmScheduler(this).snooze(requestedId, minutes)
if (snoozed != null) {
// D1 fix (Decision 2.1): report the native snooze back to
// Flutter so the canonical config records it. If the engine
// is dead this is a no-op and the cold-start sync
// (getNativeSnoozeState) reconciles on next launch.
MainActivity.notifyAlarmEvent(
mapOf(
"alarmId" to requestedId,
"alarmTitle" to snoozed.title,
"alarmAction" to MainActivity.ALARM_ACTION_SNOOZED,
"occurrenceAtMillis" to snoozed.occurrenceAtMillis,
"snoozeUntilMillis" to snoozed.snoozeUntilMillis,
"snoozeMinutes" to minutes
)
)
}
}
stopAlarm(requestedId)
return START_NOT_STICKY
@@ -57,69 +120,182 @@ class PluriWaveAlarmService : Service() {
val alarmId = intent?.getStringExtra(PluriWaveAlarmReceiver.EXTRA_ALARM_ID) ?: return
if (activeAlarmId != null) {
Log.w(TAG, "alarm.service ignored id=$alarmId because active=$activeAlarmId")
// Orphaned firing record fix (RES-2): the newcomer's own firing
// record + auto-silence were already armed by onAlarmFired before
// this refusal, so they must be cleared here or a false MISSED
// fires 10 minutes later for an alarm that never actually rang.
val scheduler = AlarmScheduler(this)
scheduler.clearFiringRecord(alarmId)
scheduler.cancelAutoSilence(alarmId)
return
}
activeAlarmId = alarmId
// onStartCommand re-validation (Decision 4): a redelivered/resurrected
// start for a firing record older than the auto-silence bound must
// never resume audio -- treat it as an already-missed ring instead.
val scheduler = AlarmScheduler(this)
val firingAge = scheduler.firingRecordAgeMillis(alarmId)
if (firingAge != null && firingAge > AlarmScheduler.AUTO_SILENCE_MILLIS) {
Log.w(TAG, "alarm.service startAlarm stale firing record id=$alarmId ageMs=$firingAge; treating as missed")
scheduler.onAlarmMissed(alarmId)
stopSelf()
return
}
// Durable firing record (Decision 4): written before MediaPlayer.start()
// (via startAudio below) so a process death mid-ring leaves proof the
// ring was in flight for the re-validation above / boot cleanup.
scheduler.recordFiring(alarmId)
setActiveIds(alarmId)
// Anchor the fade curve at RING start, not audio start (design D2):
// every source in the 3-stage fallback chain shares this ONE clock,
// so a source that begins mid-fade (e.g. after a station timeout)
// joins at the already-elapsed gain instead of restarting from
// silence (Requirement: Exponential dB fade-in ceiling).
fadeAnchorElapsedMs = SystemClock.elapsedRealtime()
val title = intent.getStringExtra(PluriWaveAlarmReceiver.EXTRA_ALARM_TITLE) ?: "PluriWave"
val stationName = intent.getStringExtra(PluriWaveAlarmReceiver.EXTRA_STATION_NAME)
val stationUrl = intent.getStringExtra(PluriWaveAlarmReceiver.EXTRA_STATION_URL)
val fallbackStationName =
intent.getStringExtra(PluriWaveAlarmReceiver.EXTRA_FALLBACK_STATION_NAME)
val fallbackStationUrl =
intent.getStringExtra(PluriWaveAlarmReceiver.EXTRA_FALLBACK_STATION_URL)
val fallbackSound = intent.getStringExtra(PluriWaveAlarmReceiver.EXTRA_FALLBACK_SOUND)
val volume = intent.getFloatExtra(PluriWaveAlarmReceiver.EXTRA_VOLUME, 0.85f).coerceIn(0f, 1f)
val fadeInSegundos =
intent.getIntExtra(PluriWaveAlarmReceiver.EXTRA_FADE_IN_SECONDS, 0).coerceIn(0, 60)
val snoozeMinutes = sanitizeSnoozeMinutes(
intent.getIntExtra(PluriWaveAlarmReceiver.EXTRA_SNOOZE_MINUTES, 5)
)
acquireWakeLock()
// The FSI notification must be visible BEFORE audio prepares (prepareAsync is
// slow); startForeground runs first so the ringing surface never lags audio.
try {
startForeground(NOTIFICATION_ID, buildNotification(alarmId, title, stationName, snoozeMinutes))
val notification = buildNotification(alarmId, title, stationName, snoozeMinutes)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
startForeground(
NOTIFICATION_ID,
notification,
ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK or
ServiceInfo.FOREGROUND_SERVICE_TYPE_SYSTEM_EXEMPTED
)
} else {
startForeground(NOTIFICATION_ID, notification)
}
} catch (error: Throwable) {
// Silent before this fix: same user-visible symptom as a refused
// startForegroundService (the ring never actually starts) --
// recorded under the SAME tipo so the alarms list surfaces it
// regardless of which of the two calls the OS refused.
Log.e(TAG, "alarm.service startForeground failed id=$alarmId", error)
NativeSchedulingFailures.record(
this,
alarmId,
NativeSchedulingFailures.TYPE_FOREGROUND_SERVICE
)
releaseWakeLock()
activeAlarmId = null
// Second documented clear site (feedback item, READ-5): this
// branch never reaches stopEverything(), so without the same
// cleanup below the receiver-armed auto-silence timer + durable
// firing record for alarmId would survive and fire a ghost
// MISSED notification ~10 minutes later for a ring that never
// actually started.
scheduler.clearFiringRecord(alarmId)
scheduler.cancelAutoSilence(alarmId)
setActiveIds(null)
stopSelf()
return
}
startAudio(alarmId, stationName, stationUrl, fallbackSound, volume)
NativeSchedulingFailures.clear(
this,
alarmId,
NativeSchedulingFailures.TYPE_FOREGROUND_SERVICE
)
startAudio(
alarmId,
stationName,
stationUrl,
fallbackStationName,
fallbackStationUrl,
fallbackSound,
volume,
fadeInSegundos
)
}
private fun startAudio(
alarmId: String,
stationName: String?,
stationUrl: String?,
fallbackStationName: String?,
fallbackStationUrl: String?,
fallbackSound: String?,
volume: Float
volume: Float,
fadeInSegundos: Int
) {
player?.release()
player = null
requestAlarmAudioFocus()
startFadeLoop(alarmId, volume, fadeInSegundos)
if (!stationUrl.isNullOrBlank()) {
startStationAudio(
alarmId,
stationName,
stationUrl.trim(),
fallbackSound,
volume
)
return
// Three-stage ordered fallback: primary station -> fallback station -> bundled WAV.
// Each stage owns its own 15s timeout window via scheduleStationFallback.
val startBundled: (String) -> Unit = { reason ->
startFallbackAudio(alarmId, fallbackSound, volume, fadeInSegundos, reason)
}
val startFallbackStation: (String) -> Unit = { reason ->
if (fallbackStationUrl.isNullOrBlank()) {
startBundled(reason)
} else {
startStationAudio(
alarmId,
fallbackStationName,
fallbackStationUrl.trim(),
volume,
fadeInSegundos,
"fallback-station",
startBundled
)
}
}
startFallbackAudio(alarmId, fallbackSound, volume, "station url missing")
if (stationUrl.isNullOrBlank()) {
startFallbackStation("station url missing")
return
}
startStationAudio(
alarmId,
stationName,
stationUrl.trim(),
volume,
fadeInSegundos,
"station",
startFallbackStation
)
}
private fun startStationAudio(
alarmId: String,
stationName: String?,
stationUrl: String,
fallbackSound: String?,
volume: Float
volume: Float,
fadeInSegundos: Int,
stage: String,
onStageFailed: (String) -> Unit
) {
scheduleStationFallback(alarmId, fallbackSound, volume)
player?.release()
player = null
scheduleStationFallback(alarmId, stage, onStageFailed)
val startVolume = computeFadeVolume(
SystemClock.elapsedRealtime() - fadeAnchorElapsedMs,
fadeInSegundos * 1000L,
volume
)
try {
player = MediaPlayer().apply {
setAudioAttributes(alarmAudioAttributes())
isLooping = false
setVolume(volume, volume)
setVolume(startVolume, startVolume)
setDataSource(
this@PluriWaveAlarmService,
Uri.parse(stationUrl),
@@ -128,34 +304,46 @@ class PluriWaveAlarmService : Service() {
setOnPreparedListener {
if (activeAlarmId != alarmId) return@setOnPreparedListener
cancelStationFallback()
// Recompute at prepare-time (not the stale value captured
// before prepareAsync): buffering can take seconds, during
// which the fade clock keeps advancing. Setting volume
// BEFORE start() avoids an audible pop (Requirement:
// No-fade path starts pop-free; same principle applies
// mid-fade).
val current = computeFadeVolume(
SystemClock.elapsedRealtime() - fadeAnchorElapsedMs,
fadeInSegundos * 1000L,
volume
)
it.setVolume(current, current)
it.start()
Log.d(
TAG,
"alarm.service station started id=$alarmId station=$stationName url=$stationUrl"
"alarm.service $stage started id=$alarmId station=$stationName url=$stationUrl"
)
}
setOnCompletionListener {
if (activeAlarmId != alarmId) return@setOnCompletionListener
Log.w(TAG, "alarm.service station completed id=$alarmId url=$stationUrl")
startFallbackAudio(alarmId, fallbackSound, volume, "station completed")
Log.w(TAG, "alarm.service $stage completed id=$alarmId url=$stationUrl")
onStageFailed("$stage completed")
}
setOnErrorListener { mp, what, extra ->
Log.e(
TAG,
"alarm.service station error id=$alarmId what=$what extra=$extra url=$stationUrl"
"alarm.service $stage error id=$alarmId what=$what extra=$extra url=$stationUrl"
)
runCatching { mp.reset() }
if (activeAlarmId == alarmId) {
startFallbackAudio(alarmId, fallbackSound, volume, "station error")
onStageFailed("$stage error")
}
true
}
prepareAsync()
}
Log.d(TAG, "alarm.service station preparing id=$alarmId station=$stationName url=$stationUrl")
Log.d(TAG, "alarm.service $stage preparing id=$alarmId station=$stationName url=$stationUrl")
} catch (error: Throwable) {
Log.e(TAG, "alarm.service station prepare failed id=$alarmId url=$stationUrl", error)
startFallbackAudio(alarmId, fallbackSound, volume, "station prepare failed")
Log.e(TAG, "alarm.service $stage prepare failed id=$alarmId url=$stationUrl", error)
onStageFailed("$stage prepare failed")
}
}
@@ -163,6 +351,7 @@ class PluriWaveAlarmService : Service() {
alarmId: String,
fallbackSound: String?,
volume: Float,
fadeInSegundos: Int,
reason: String
) {
cancelStationFallback()
@@ -170,14 +359,27 @@ class PluriWaveAlarmService : Service() {
player = null
val source = fallbackAssetPath(fallbackSound)
val startVolume = computeFadeVolume(
SystemClock.elapsedRealtime() - fadeAnchorElapsedMs,
fadeInSegundos * 1000L,
volume
)
try {
player = MediaPlayer().apply {
setAudioAttributes(alarmAudioAttributes())
isLooping = true
setVolume(volume, volume)
setVolume(startVolume, startVolume)
setFallbackAssetDataSource(this, fallbackSound)
setOnPreparedListener {
if (activeAlarmId != alarmId) return@setOnPreparedListener
// Recompute at prepare-time; see the matching comment in
// startStationAudio's setOnPreparedListener.
val current = computeFadeVolume(
SystemClock.elapsedRealtime() - fadeAnchorElapsedMs,
fadeInSegundos * 1000L,
volume
)
it.setVolume(current, current)
it.start()
Log.d(TAG, "alarm.service fallback started id=$alarmId source=$source reason=$reason")
}
@@ -196,20 +398,56 @@ class PluriWaveAlarmService : Service() {
private fun scheduleStationFallback(
alarmId: String,
fallbackSound: String?,
volume: Float
stage: String,
onStageFailed: (String) -> Unit
) {
cancelStationFallback()
val runnable = Runnable {
if (activeAlarmId == alarmId) {
Log.w(TAG, "alarm.service station timeout id=$alarmId; using fallback")
startFallbackAudio(alarmId, fallbackSound, volume, "station timeout")
Log.w(TAG, "alarm.service $stage timeout id=$alarmId; advancing audio chain")
onStageFailed("$stage timeout")
}
}
stationFallbackRunnable = runnable
mainHandler.postDelayed(runnable, STATION_START_TIMEOUT_MILLIS)
}
/**
* Single ring-anchored fade loop (Requirement: Exponential dB fade-in
* ceiling; design D1). Ticks every [FADE_TICK_MILLIS] and reads [player]
* FRESH on each tick -- not a captured MediaPlayer reference -- so the
* SAME loop survives the 3-stage source swap (station -> fallback
* station -> bundled WAV) instead of needing a fresh ramp per source.
* Guarded by [activeAlarmId] so a stale loop from a superseded ring can
* never touch a new one's player. Stops rescheduling once elapsed
* reaches the fade window; further ticks would be redundant since
* [computeFadeVolume] already clamps to the ceiling past that point.
*/
private fun startFadeLoop(alarmId: String, ceiling: Float, fadeInSegundos: Int) {
cancelFadeLoop()
if (fadeInSegundos <= 0) return
val fadeMs = fadeInSegundos * 1000L
val runnable = object : Runnable {
override fun run() {
if (activeAlarmId != alarmId) return
val elapsed = SystemClock.elapsedRealtime() - fadeAnchorElapsedMs
val current = computeFadeVolume(elapsed, fadeMs, ceiling)
runCatching { player?.setVolume(current, current) }
if (elapsed < fadeMs) {
mainHandler.postDelayed(this, FADE_TICK_MILLIS)
}
}
}
fadeLoopRunnable = runnable
mainHandler.postDelayed(runnable, FADE_TICK_MILLIS)
Log.d(TAG, "alarm.service fade loop started id=$alarmId seconds=$fadeInSegundos")
}
private fun cancelFadeLoop() {
fadeLoopRunnable?.let { mainHandler.removeCallbacks(it) }
fadeLoopRunnable = null
}
private fun cancelStationFallback() {
stationFallbackRunnable?.let { mainHandler.removeCallbacks(it) }
stationFallbackRunnable = null
@@ -223,20 +461,69 @@ class PluriWaveAlarmService : Service() {
private fun stopAlarm(alarmId: String?) {
Log.d(TAG, "alarm.service stop id=$alarmId active=$activeAlarmId")
// Scope the teardown to the alarm that is actually ringing: a stop
// request for a DIFFERENT id (e.g. a second alarm firing while this
// one rings — Dart hides the newcomer's notification, which routes
// through ACTION_STOP with the newcomer's id) must not kill the
// active ring, release its wake lock, or prematurely restore the
// device volume. Only the id-specific notification cancel below is
// honored for the mismatched id. A null alarmId (internal callers,
// onDestroy) keeps full-teardown semantics.
if (alarmId != null && activeAlarmId != null && alarmId != activeAlarmId) {
Log.d(
TAG,
"alarm.service stop ignored for id=$alarmId (active=$activeAlarmId)"
)
NotificationManagerCompat.from(this).cancel(
PluriWaveAlarmReceiver.fireNotificationIdForAlarm(alarmId)
)
// Orphaned firing record fix (RES-2): this mismatched id is not
// being torn down by stopEverything() below (that only tears down
// activeAlarmId), so its own firing record + auto-silence must be
// cleared here to avoid a false MISSED 10 minutes later.
val scheduler = AlarmScheduler(this)
scheduler.clearFiringRecord(alarmId)
scheduler.cancelAutoSilence(alarmId)
return
}
stopEverything()
}
/**
* Atomic full teardown (Decision 2, NA "Atomic Stop Coupling"): every stop
* entry point (ACTION_STOP id-match/null, ACTION_STOP_ACTIVE, ACTION_SNOOZE
* via [stopAlarm], onDestroy via [stopAlarm]) funnels through this ONE
* method so no path can perform a partial teardown. Id-agnostic by design:
* it always tears down whatever [activeAlarmId] currently is.
*/
private fun stopEverything() {
val stoppingId = activeAlarmId
cancelStationFallback()
cancelFadeLoop()
try {
player?.stop()
} catch (error: Throwable) {
Log.w(TAG, "alarm.service stop player failed", error)
}
player?.release()
try {
player?.release()
} catch (error: Throwable) {
// Non-atomic release fix (RES-4): a throw here must not abort the
// rest of the teardown below (state reset, wakelock, firing-record
// clear, stopForeground, stopSelf all still need to run).
Log.w(TAG, "alarm.service release player failed", error)
}
player = null
activeAlarmId = null
setActiveIds(null)
releaseWakeLock()
if (alarmId != null) {
abandonAlarmAudioFocus()
if (stoppingId != null) {
NotificationManagerCompat.from(this).cancel(
PluriWaveAlarmReceiver.fireNotificationIdForAlarm(alarmId)
PluriWaveAlarmReceiver.fireNotificationIdForAlarm(stoppingId)
)
val scheduler = AlarmScheduler(this)
scheduler.clearFiringRecord(stoppingId)
scheduler.cancelAutoSilence(stoppingId)
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
stopForeground(STOP_FOREGROUND_REMOVE)
@@ -254,8 +541,9 @@ class PluriWaveAlarmService : Service() {
snoozeMinutes: Int
) =
NotificationCompat.Builder(this, CHANNEL_ID)
.setSmallIcon(android.R.drawable.ic_lock_idle_alarm)
.setContentTitle("Alarma PluriWave")
.setSmallIcon(R.drawable.ic_stat_pluriwave)
.setColor(NotificationBrand.CYAN)
.setContentTitle(AlarmNotificationStrings.ringTitle(this))
.setContentText(
if (stationName.isNullOrBlank()) title else "$title - $stationName"
)
@@ -266,8 +554,8 @@ class PluriWaveAlarmService : Service() {
.setAutoCancel(false)
.setFullScreenIntent(openAlarmPendingIntent(alarmId, title, snoozeMinutes), true)
.setContentIntent(openAlarmPendingIntent(alarmId, title, snoozeMinutes))
.addAction(0, "Posponer", snoozePendingIntent(alarmId, snoozeMinutes))
.addAction(0, "Detener", stopPendingIntent(alarmId))
.addAction(0, AlarmNotificationStrings.snoozeLabel(this), snoozePendingIntent(alarmId, snoozeMinutes))
.addAction(0, AlarmNotificationStrings.stopLabel(this), stopPendingIntent(alarmId))
.build()
private fun openAlarmPendingIntent(
@@ -293,7 +581,10 @@ class PluriWaveAlarmService : Service() {
this,
requestCode(alarmId, 21),
Intent(this, PluriWaveAlarmService::class.java).apply {
action = ACTION_STOP
// Fail-safe fix (feedback item 1, SS-4a/NA-1a): the notification
// Stop action must route through the id-agnostic stop so it can
// never no-op a live ring; the extra id is kept only for logs.
action = ACTION_STOP_ACTIVE
putExtra(PluriWaveAlarmReceiver.EXTRA_ALARM_ID, alarmId)
},
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
@@ -332,6 +623,48 @@ class PluriWaveAlarmService : Service() {
wakeLock = null
}
/**
* Requests transient alarm-scoped audio focus (Requirement: Manual
* transient focus; no system volume writes; design D3). Manual instead
* of relying on MediaPlayer's implicit focus handling so the service
* keeps STREAM_ALARM audible without ever writing another app's stream
* volume. AUDIOFOCUS_GAIN_TRANSIENT signals "temporary, give it back
* when I'm done" -- the OS pauses/ducks other playback for the ring and
* resumes it automatically once focus is abandoned. No-op listener:
* this service never reacts to focus loss (an alarm should keep
* ringing regardless of what else wants focus).
*/
private fun requestAlarmAudioFocus() {
val audioManager = getSystemService(Context.AUDIO_SERVICE) as? AudioManager ?: return
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val request = AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN_TRANSIENT)
.setAudioAttributes(alarmAudioAttributes())
.setOnAudioFocusChangeListener(noopAudioFocusChangeListener)
.build()
audioFocusRequest = request
audioManager.requestAudioFocus(request)
} else {
@Suppress("DEPRECATION")
audioManager.requestAudioFocus(
noopAudioFocusChangeListener,
AudioManager.STREAM_ALARM,
AudioManager.AUDIOFOCUS_GAIN_TRANSIENT
)
}
}
/** Abandons the focus request from [requestAlarmAudioFocus]; a safe no-op if none is held. */
private fun abandonAlarmAudioFocus() {
val audioManager = getSystemService(Context.AUDIO_SERVICE) as? AudioManager ?: return
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
audioFocusRequest?.let { audioManager.abandonAudioFocusRequest(it) }
audioFocusRequest = null
} else {
@Suppress("DEPRECATION")
audioManager.abandonAudioFocus(noopAudioFocusChangeListener)
}
}
private fun setFallbackAssetDataSource(mediaPlayer: MediaPlayer, sound: String?) {
val path = fallbackAssetPath(sound)
try {
@@ -366,20 +699,76 @@ class PluriWaveAlarmService : Service() {
override fun onDestroy() {
stopAlarm(activeAlarmId)
if (instance === this) instance = null
super.onDestroy()
}
companion object {
private const val TAG = "PluriWave"
private const val CHANNEL_ID = "pluriwave_alarm_native"
private const val CHANNEL_ID = "pluriwave_alarm_fire_v3"
private const val LEGACY_CHANNEL_NATIVE = "pluriwave_alarm_native"
private const val LEGACY_CHANNEL_FIRE = "pluriwave_alarm_fire"
private const val LEGACY_CHANNEL_FIRE_V2 = "pluriwave_alarm_fire_v2"
private const val CHANNELS_PREFS = "pluriwave_alarm_channels"
private const val KEY_CHANNELS_MIGRATED_V3 = "channels_migrated_v3"
private const val NOTIFICATION_ID = 92841
const val ACTION_STOP = "es.freetimelab.pluriwave.alarm.STOP_NATIVE"
const val ACTION_STOP_ACTIVE = "es.freetimelab.pluriwave.alarm.STOP_ACTIVE_NATIVE"
const val ACTION_SNOOZE = "es.freetimelab.pluriwave.alarm.SNOOZE_NATIVE"
const val EXTRA_SNOOZE_MINUTES = "snoozeMinutes"
/**
* Same-process companion snapshot (Decision 1): `MainActivity` reads
* this synchronously (no service round-trip) to build a verified stop
* result. Always written together with the instance-scoped
* [activeAlarmId] through the paired [setActiveIds] helper (feedback
* item, READ-6) -- always the id ACTUALLY ringing, never a
* caller-supplied one. Set in [startAlarm]; cleared in TWO documented
* sites -- [stopEverything] (confirmed stop/teardown) AND
* [startAlarm]'s own startForeground-failure catch (feedback item,
* READ-5), which never reaches [stopEverything] but must still clear
* the ids for the ring that never actually started.
*/
@Volatile
var activeRingingId: String? = null
/**
* Same-process companion reference (feedback item 1, RISK-1/RES-1/REL-2):
* set in [onCreate], cleared in [onDestroy]. Lets [stopActiveVerified]
* call [stopEverything] synchronously instead of trusting an async
* startService dispatch to have completed before reporting a result.
*/
@Volatile
private var instance: PluriWaveAlarmService? = null
private const val STATION_START_TIMEOUT_MILLIS = 15_000L
private const val FADE_TICK_MILLIS = 50L
private const val FADE_RANGE_DB = 40.0f
/**
* DeskClock-style exponential fade curve (AOSP AsyncRingtonePlayer /
* VolumeShaper reference shape -- reimplemented here on a plain
* Handler tick since MediaPlayer.setVolume takes a linear [0,1] gain
* and this service targets API levels below VolumeShaper's API 26
* floor). Volume rises from near-silence to [ceiling] over [fadeMs]
* along a DECIBEL ramp, not a linear amplitude ramp, so the rise
* SOUNDS smooth: human loudness perception is logarithmic, and a
* linear amplitude ramp sounds like it "arrives late" and jumps at
* the end. At elapsedMs<=0 the gain is -40dB (~1% of ceiling); at
* elapsedMs>=fadeMs the gain is 0dB (exactly ceiling). Pure
* function -- no side effects -- so it is safe to call from a timer
* tick, a prepare-time recompute, or a construction-time seed alike.
*/
private fun computeFadeVolume(elapsedMs: Long, fadeMs: Long, ceiling: Float): Float {
if (fadeMs <= 0) return ceiling.coerceIn(0f, 1f)
val fraction = (elapsedMs.toFloat() / fadeMs.toFloat()).coerceIn(0f, 1f)
val gainDb = fraction * FADE_RANGE_DB - FADE_RANGE_DB
val curve = Math.pow(10.0, (gainDb / 20.0).toDouble()).toFloat()
return (ceiling * curve).coerceIn(0f, 1f)
}
fun start(context: Context, source: Intent) {
ensureChannel(context)
val alarmId = source.getStringExtra(PluriWaveAlarmReceiver.EXTRA_ALARM_ID)
val intent = Intent(context, PluriWaveAlarmService::class.java).apply {
action = PluriWaveAlarmReceiver.ACTION_FIRE
putExtras(source)
@@ -387,8 +776,27 @@ class PluriWaveAlarmService : Service() {
try {
ContextCompat.startForegroundService(context, intent)
Log.d(TAG, "alarm.service start requested")
if (!alarmId.isNullOrBlank()) {
NativeSchedulingFailures.clear(
context,
alarmId,
NativeSchedulingFailures.TYPE_FOREGROUND_SERVICE
)
}
} catch (error: Throwable) {
// Silent before this fix: a fire-and-forget call from the
// receiver's ACTION_FIRE branch -- if the OS refuses the
// foreground-service start (background-restricted app), the
// ring never happens and nothing surfaced it anywhere but
// logcat, "as if there were no alarm at all".
Log.e(TAG, "alarm.service start failed", error)
if (!alarmId.isNullOrBlank()) {
NativeSchedulingFailures.record(
context,
alarmId,
NativeSchedulingFailures.TYPE_FOREGROUND_SERVICE
)
}
}
}
@@ -410,21 +818,90 @@ class PluriWaveAlarmService : Service() {
}
}
/** Id-agnostic fail-safe stop (Decision 1): silences whatever is ringing. */
fun stopActive(context: Context) {
val intent = Intent(context, PluriWaveAlarmService::class.java).apply {
action = ACTION_STOP_ACTIVE
}
try {
context.startService(intent)
Log.d(TAG, "alarm.service stopActive action requested")
} catch (error: Throwable) {
Log.e(TAG, "alarm.service stopActive request failed", error)
try {
context.stopService(intent)
} catch (fallbackError: Throwable) {
Log.e(TAG, "alarm.service stopActive fallback failed", fallbackError)
}
}
}
/**
* Same-process VERIFIED stop (feedback item 1, RISK-1/RES-1/REL-2):
* fixes the hollow verification where [stopActive]'s async
* startService dispatch made the result a literal `true` decided
* before teardown ran. When a live [instance] exists, invokes
* [stopEverything] on it SYNCHRONOUSLY (the MethodChannel caller and
* this service both run on the main thread of the SAME process, so
* no round trip is needed) and returns whether teardown actually
* cleared [activeRingingId]. Falls back to the async [stopActive]
* dispatch only when no instance is alive -- nothing can be ringing
* without a live instance, so [activeRingingId] is already null and
* the fallback trivially succeeds.
*/
fun stopActiveVerified(context: Context): Boolean {
val current = instance
if (current != null) {
current.stopEverything()
return activeRingingId == null
}
stopActive(context)
return activeRingingId == null
}
private fun ensureChannel(context: Context) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
if (manager.getNotificationChannel(CHANNEL_ID) != null) return
migrateLegacyChannels(context, manager)
// Re-create each time (not early-returning when present) so the
// localized name/description refresh after a locale change. Android
// updates name + description on an existing channel; importance and
// sound stay fixed from first creation. setSound(null, null) is
// REQUIRED for silence: omitting the call leaves the platform
// DEFAULT notification sound on the channel (same reason the
// pre-notice channel calls it explicitly). This channel must be
// silent (Requirement: Fire notification posts with no sound) --
// the native MediaPlayer on STREAM_ALARM is the only audible
// source, so a channel sound would double it.
val channel = NotificationChannel(
CHANNEL_ID,
"Alarma musical",
AlarmNotificationStrings.fireChannelName(context),
NotificationManager.IMPORTANCE_HIGH
).apply {
description = "Sonido de alarma musical con pantalla apagada"
description = AlarmNotificationStrings.fireChannelDescription(context)
setSound(null, null)
enableVibration(true)
}
manager.createNotificationChannel(channel)
}
// Android locks channel sound/importance at creation time, so the
// only way to apply a changed shape (USAGE_ALARM in v2, silent in v3)
// on existing installs is deleting the legacy channels and recreating
// under a new versioned id. Runs once, guarded by a flag;
// deleteNotificationChannel is a safe no-op for an id that was never
// created (fresh installs) or already deleted (re-runs).
private fun migrateLegacyChannels(context: Context, manager: NotificationManager) {
val prefs = context.createDeviceProtectedStorageContext()
.getSharedPreferences(CHANNELS_PREFS, Context.MODE_PRIVATE)
if (prefs.getBoolean(KEY_CHANNELS_MIGRATED_V3, false)) return
runCatching { manager.deleteNotificationChannel(LEGACY_CHANNEL_NATIVE) }
runCatching { manager.deleteNotificationChannel(LEGACY_CHANNEL_FIRE) }
runCatching { manager.deleteNotificationChannel(LEGACY_CHANNEL_FIRE_V2) }
prefs.edit().putBoolean(KEY_CHANNELS_MIGRATED_V3, true).apply()
Log.d(TAG, "alarm.service legacy notification channels migrated to v3")
}
private fun requestCode(id: String, slot: Int): Int = 67 * id.hashCode() + slot
}
}
@@ -0,0 +1,325 @@
package es.freetimelab.pluriwave
import android.content.Context
import android.database.Cursor
import android.database.MatrixCursor
import android.net.Uri
import android.os.CancellationSignal
import android.os.ParcelFileDescriptor
import android.provider.DocumentsContract
import android.provider.DocumentsContract.Document
import android.provider.DocumentsContract.Root
import android.provider.DocumentsProvider
import android.util.Log
import android.webkit.MimeTypeMap
import java.io.File
import java.io.FileNotFoundException
/**
* Publishes the radio-recordings folder as a storage root the system file
* manager can browse, WITHOUT moving a single file out of app-private storage.
*
* Why this exists: the recordings live under
* `getApplicationDocumentsDirectory()/grabaciones`
* (`/data/user/0/es.freetimelab.pluriwave/app_flutter/grabaciones`). The Android
* sandbox forbids any other app -- including the system Files app -- from
* reading that path, so no `ACTION_VIEW` on a `file://` or `FileProvider` URI
* can ever open it. A `DocumentsProvider` is the only supported way to expose
* private files to the document framework: we stay the owner of the bytes and
* the system asks US for them, one document at a time.
*
* The root is browsable, readable, writable, renameable and deletable so the
* user can do whatever they want with their recordings (copy out, share, delete,
* open in another player) straight from the file manager.
*
* Static-review-only component: it runs in the app process but is driven
* entirely by the platform's document framework, so it has no Dart unit tests.
* See MainActivity.viewDirectory for the intents that open it.
*/
class RecordingsDocumentsProvider : DocumentsProvider() {
companion object {
private const val TAG = "PluriWave"
/** Root id and document id of the exposed folder itself. */
const val ROOT_ID = "recordings"
/**
* Remembers the folder Flutter is actually recording into. Written on
* every open-folder request so a user-configured path is honoured, and
* read back by [rootDirectory] when the platform enumerates roots (which
* can happen with no Activity alive).
*/
private const val PREFS = "pluriwave_recordings_root"
private const val KEY_PATH = "path"
/**
* Mirrors path_provider's `getApplicationDocumentsDirectory()` on
* Android (`context.getDir("flutter", MODE_PRIVATE)`) plus the
* `grabaciones` subfolder appended by
* `ServicioGrabacionRadio.directorioEfectivo()`. Used until Flutter has
* reported the effective path at least once.
*/
private fun defaultDirectory(context: Context): File =
File(context.getDir("flutter", Context.MODE_PRIVATE), "grabaciones")
fun authority(context: Context): String = "${context.packageName}.recordings"
/** `ACTION_VIEW` target that opens the file manager at this root. */
fun rootUri(context: Context): Uri =
DocumentsContract.buildRootUri(authority(context), ROOT_ID)
/** `ACTION_VIEW` target for the root folder as a document. */
fun rootDocumentUri(context: Context): Uri =
DocumentsContract.buildDocumentUri(authority(context), ROOT_ID)
/** `EXTRA_INITIAL_URI` target for the `ACTION_OPEN_DOCUMENT_TREE` fallback. */
fun rootTreeUri(context: Context): Uri =
DocumentsContract.buildTreeDocumentUri(authority(context), ROOT_ID)
/**
* Points the published root at [path] and tells the framework to
* refresh, so a folder change in Settings is reflected in the file
* manager. No-op when the path is unchanged.
*/
fun rememberRoot(context: Context, path: String) {
val app = context.applicationContext
val prefs = app.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
if (prefs.getString(KEY_PATH, null) == path) return
prefs.edit().putString(KEY_PATH, path).apply()
try {
app.contentResolver.notifyChange(
DocumentsContract.buildRootsUri(authority(app)),
null
)
} catch (error: Throwable) {
Log.w(TAG, "recordings_provider notifyChange failed", error)
}
}
/** The directory currently published as [ROOT_ID], created if missing. */
fun rootDirectory(context: Context): File {
val app = context.applicationContext
val stored = app.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
.getString(KEY_PATH, null)
?.takeIf { it.isNotBlank() }
val directory = if (stored != null) File(stored) else defaultDirectory(app)
if (!directory.exists()) directory.mkdirs()
return directory
}
private val ROOT_COLUMNS = arrayOf(
Root.COLUMN_ROOT_ID,
Root.COLUMN_DOCUMENT_ID,
Root.COLUMN_TITLE,
Root.COLUMN_SUMMARY,
Root.COLUMN_FLAGS,
Root.COLUMN_ICON,
)
private val DOCUMENT_COLUMNS = arrayOf(
Document.COLUMN_DOCUMENT_ID,
Document.COLUMN_DISPLAY_NAME,
Document.COLUMN_MIME_TYPE,
Document.COLUMN_SIZE,
Document.COLUMN_LAST_MODIFIED,
Document.COLUMN_FLAGS,
)
}
/**
* [DocumentsProvider.getContext] is nullable only before `onCreate`.
* Not named requireContext: ContentProvider.requireContext() is API 30 and
* minSdk is 24.
*/
private fun resolveContext(): Context =
requireNotNull(context) { "provider context unavailable" }
override fun onCreate(): Boolean = true
override fun queryRoots(projection: Array<out String>?): Cursor {
val cursor = MatrixCursor(projection ?: ROOT_COLUMNS)
val context = resolveContext()
// Ensure the folder exists before the file manager lists an empty root.
rootDirectory(context)
cursor.newRow().apply {
add(Root.COLUMN_ROOT_ID, ROOT_ID)
add(Root.COLUMN_DOCUMENT_ID, ROOT_ID)
// The file manager renders title as the primary label and summary
// below it, so the brand identifies the source and the localized
// folder name says what it holds.
add(Root.COLUMN_TITLE, appLabel(context))
add(Root.COLUMN_SUMMARY, AlarmNotificationStrings.recordingsRootTitle(context))
add(Root.COLUMN_ICON, R.mipmap.ic_launcher)
add(
Root.COLUMN_FLAGS,
Root.FLAG_LOCAL_ONLY or
Root.FLAG_SUPPORTS_CREATE or
Root.FLAG_SUPPORTS_IS_CHILD
)
}
return cursor
}
override fun queryDocument(documentId: String, projection: Array<out String>?): Cursor {
val cursor = MatrixCursor(projection ?: DOCUMENT_COLUMNS)
addRow(cursor, resolve(documentId), documentId)
return cursor
}
override fun queryChildDocuments(
parentDocumentId: String,
projection: Array<out String>?,
sortOrder: String?,
): Cursor {
val cursor = MatrixCursor(projection ?: DOCUMENT_COLUMNS)
val parent = resolve(parentDocumentId)
// Newest recording first: it is the one the user just made.
val children = parent.listFiles()?.sortedByDescending { it.lastModified() } ?: emptyList()
for (child in children) {
addRow(cursor, child, documentIdFor(child))
}
return cursor
}
override fun isChildDocument(parentDocumentId: String, documentId: String): Boolean =
documentId != parentDocumentId &&
documentId.startsWith(
if (parentDocumentId == ROOT_ID) "$ROOT_ID/" else "$parentDocumentId/"
)
override fun openDocument(
documentId: String,
mode: String,
signal: CancellationSignal?,
): ParcelFileDescriptor {
val file = resolve(documentId)
return ParcelFileDescriptor.open(file, ParcelFileDescriptor.parseMode(mode))
}
override fun createDocument(
parentDocumentId: String,
mimeType: String,
displayName: String,
): String {
val parent = resolve(parentDocumentId)
val target = uniqueChild(parent, displayName)
val created =
if (Document.MIME_TYPE_DIR == mimeType) target.mkdir() else target.createNewFile()
if (!created) {
throw FileNotFoundException("could not create $displayName in $parentDocumentId")
}
notifyParent(parentDocumentId)
return documentIdFor(target)
}
override fun deleteDocument(documentId: String) {
val file = resolve(documentId)
if (!file.deleteRecursively()) {
throw FileNotFoundException("could not delete $documentId")
}
notifyParent(parentDocumentIdOf(documentId))
}
override fun renameDocument(documentId: String, displayName: String): String {
val file = resolve(documentId)
val target = File(file.parentFile, displayName)
if (target.exists() || !file.renameTo(target)) {
throw FileNotFoundException("could not rename $documentId to $displayName")
}
notifyParent(parentDocumentIdOf(documentId))
return documentIdFor(target)
}
override fun getDocumentType(documentId: String): String = mimeTypeOf(resolve(documentId))
private fun appLabel(context: Context): String =
context.applicationInfo.loadLabel(context.packageManager).toString()
private fun addRow(cursor: MatrixCursor, file: File, documentId: String) {
val isDirectory = file.isDirectory
var flags =
if (isDirectory) Document.FLAG_DIR_SUPPORTS_CREATE else Document.FLAG_SUPPORTS_WRITE
flags = flags or Document.FLAG_SUPPORTS_DELETE or Document.FLAG_SUPPORTS_RENAME
cursor.newRow().apply {
add(Document.COLUMN_DOCUMENT_ID, documentId)
add(
Document.COLUMN_DISPLAY_NAME,
if (documentId == ROOT_ID) {
AlarmNotificationStrings.recordingsRootTitle(resolveContext())
} else {
file.name
}
)
add(Document.COLUMN_MIME_TYPE, mimeTypeOf(file))
add(Document.COLUMN_SIZE, file.length())
add(Document.COLUMN_LAST_MODIFIED, file.lastModified())
add(Document.COLUMN_FLAGS, flags)
}
}
/**
* Maps a document id back to a file, refusing anything that escapes the
* published root -- a caller-supplied id must never reach a sibling of the
* recordings folder via `..` segments.
*/
private fun resolve(documentId: String): File {
val root = rootDirectory(resolveContext())
if (documentId == ROOT_ID) return root
if (!documentId.startsWith("$ROOT_ID/")) {
throw FileNotFoundException("unknown document id $documentId")
}
val relative = documentId.removePrefix("$ROOT_ID/")
val target = File(root, relative).canonicalFile
val rootPath = root.canonicalPath
if (target.path != rootPath && !target.path.startsWith("$rootPath${File.separator}")) {
throw FileNotFoundException("document id escapes the root: $documentId")
}
if (!target.exists()) throw FileNotFoundException("missing document $documentId")
return target
}
private fun documentIdFor(file: File): String {
val rootPath = rootDirectory(resolveContext()).canonicalPath
val filePath = file.canonicalPath
if (filePath == rootPath) return ROOT_ID
return "$ROOT_ID/${filePath.removePrefix("$rootPath${File.separator}").replace(File.separatorChar, '/')}"
}
private fun parentDocumentIdOf(documentId: String): String =
documentId.substringBeforeLast('/', ROOT_ID).takeIf { it.isNotBlank() } ?: ROOT_ID
private fun notifyParent(parentDocumentId: String) {
try {
val ctx = resolveContext()
ctx.contentResolver.notifyChange(
DocumentsContract.buildChildDocumentsUri(authority(ctx), parentDocumentId),
null
)
} catch (error: Throwable) {
Log.w(TAG, "recordings_provider notifyChange failed parent=$parentDocumentId", error)
}
}
/** Appends ` (n)` before the extension until the name is free. */
private fun uniqueChild(parent: File, displayName: String): File {
var candidate = File(parent, displayName)
if (!candidate.exists()) return candidate
val dot = displayName.lastIndexOf('.')
val base = if (dot > 0) displayName.substring(0, dot) else displayName
val extension = if (dot > 0) displayName.substring(dot) else ""
var index = 1
while (candidate.exists()) {
candidate = File(parent, "$base ($index)$extension")
index++
}
return candidate
}
private fun mimeTypeOf(file: File): String {
if (file.isDirectory) return Document.MIME_TYPE_DIR
val extension = file.extension.lowercase()
return MimeTypeMap.getSingleton().getMimeTypeFromExtension(extension)
?: "application/octet-stream"
}
}
@@ -0,0 +1,44 @@
package es.freetimelab.pluriwave
/**
* Anchors the drawables that only Dart names, so the Android build cannot
* decide they are unused.
*
* These icons are handed to `audio_service` as plain strings
* (`MediaControl.custom(androidIcon: 'drawable/ic_auto_eq_on')`) and resolved
* at runtime through `getResources().getIdentifier(...)`. Nothing on the
* Android side of the build ever mentions them, so as far as the resource
* pipeline is concerned they are dead weight — and they were dropped from
* every release APK.
*
* The damage was not a missing icon. `getResourceId` returns 0 for a name it
* cannot find, `PlaybackStateCompat.CustomAction.Builder` throws on a 0 icon,
* and that throw aborts `AudioService.setState` before the media session is
* ever activated. Android Auto was left holding a frozen, inactive session:
* dead playback screen, a play button that never became pause, the app losing
* its pane to whichever app did have a live session, and audio that played
* "as if it were not the app". One absent file, four symptoms, from 31 July
* (commit 2540556) until this.
*
* Verified rather than assumed. Pulling the installed APK off the device and
* reading its resource table showed `ic_stat_pluriwave` present and both
* equalizer icons absent — and `ic_stat_pluriwave` is the one drawable of the
* three that Kotlin references directly (`R.drawable.ic_stat_pluriwave`, four
* call sites across the alarm notifications). That contrast is the whole
* diagnosis: a real `R.drawable` reference survives, a name that exists only
* inside a Dart string does not.
*
* So this object is not defensive tidiness — it is the reference that was
* missing. Any future drawable that Dart resolves by name must be added here
* AND to the resource guard in `.gitea/workflows/build.yml`, which reads the
* built APK's resource table and fails the build if one of them is gone.
*/
@Suppress("unused")
internal object RecursosResueltosPorNombre {
val anclados: IntArray =
intArrayOf(
R.drawable.ic_auto_eq_on,
R.drawable.ic_auto_eq_off,
R.drawable.ic_stat_pluriwave,
)
}
@@ -0,0 +1,3 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
<path android:fillColor="#FFFFFF" android:pathData="M3,5L21,5L21,7L3,7Z M14,3L17,3L17,9L14,9Z M3,11L21,11L21,13L3,13Z M7,9L10,9L10,15L7,15Z M3,17L21,17L21,19L3,19Z M17,15L20,15L20,21L17,21Z M2,20L4,22L22,4L20,2Z" />
</vector>
@@ -0,0 +1,3 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
<path android:fillColor="#FFFFFF" android:pathData="M3,5L21,5L21,7L3,7Z M14,3L17,3L17,9L14,9Z M3,11L21,11L21,13L3,13Z M7,9L10,9L10,15L7,15Z M3,17L21,17L21,19L3,19Z M17,15L20,15L20,21L17,21Z" />
</vector>
@@ -0,0 +1,3 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
<path android:fillColor="#FFFFFF" android:pathData="M7,18h2L9,6L7,6v12zM3,14h2v-4L3,10v4zM11,20h2L13,4h-2v16zM19,10v4h2v-4h-2zM15,18h2L17,6h-2v12z" />
</vector>
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

+33
View File
@@ -0,0 +1,33 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Protects the drawables that only Dart names from the resource shrinker.
Flutter's own Gradle plugin turns shrinking on for every release build
(FlutterPlugin.kt: `releaseBuildType.isMinifyEnabled = true` and
`isShrinkResources = true`), regardless of what app/build.gradle.kts says.
The shrinker keeps what it can see referenced — and it cannot see
`MediaControl.custom(androidIcon: 'drawable/ic_auto_eq_on')`, because that
is a string inside Dart, resolved at runtime via
`getResources().getIdentifier(...)`. So it removed both equalizer icons
from every release APK.
The consequence was not a blank button. `getResourceId` returns 0 for a
name it cannot resolve, `PlaybackStateCompat.CustomAction.Builder` throws
on a 0 icon, and that throw aborts `AudioService.setState` before the media
session is activated — leaving Android Auto with a frozen, inactive
session. Dead playback screen, play that never became pause, the app losing
its pane to any app with a live session, and audio playing "as if it were
not the app". One shrunk file, four symptoms, from 31 July (commit 2540556).
Proven, not assumed: the installed APK was pulled off the device and its
resource table read. `ic_stat_pluriwave` was present, both equalizer icons
were not — and `ic_stat_pluriwave` is the only one of the three that Kotlin
references as a real `R.drawable`, from the alarm notifications. A genuine
reference survives shrinking; a name living in a Dart string does not.
ANY new drawable that Dart resolves by name must be listed here, and in the
resource guard in .gitea/workflows/build.yml, which reads the built APK's
resource table and fails the build if one of them went missing.
-->
<resources xmlns:tools="http://schemas.android.com/tools"
tools:keep="@drawable/ic_auto_eq_on,@drawable/ic_auto_eq_off,@drawable/ic_stat_pluriwave,@drawable/station_art_*" />
@@ -0,0 +1,3 @@
<automotiveApp>
<uses name="media"/>
</automotiveApp>
@@ -3,6 +3,17 @@
<files-path
name="files"
path="." />
<!--
path_provider's getApplicationDocumentsDirectory() maps to
context.getDir("flutter") -> <data>/app_flutter, a sibling of files/ that
no FileProvider tag covers directly. Without this root,
getUriForFile() throws for every radio recording and "open last
recording" fails. FileProvider canonicalizes roots, so the ../ hop
resolves to <data>/app_flutter.
-->
<files-path
name="app_flutter"
path="../app_flutter/" />
<cache-path
name="cache"
path="." />
+4
View File
@@ -1,2 +1,6 @@
org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
android.useAndroidX=true
# This builtInKotlin flag was added automatically by Flutter migrator
android.builtInKotlin=false
# This newDsl flag was added automatically by Flutter migrator
android.newDsl=false
+280
View File
@@ -0,0 +1,280 @@
# Llevar PluriWave a Android Auto
Guía para un desarrollador que **nunca publicó una app en Play Store** y quiere que
PluriWave —instalada en el teléfono— se pueda **navegar y controlar desde la pantalla
del coche** vía Android Auto.
> **Alcance de esta guía.** Hablamos de **Android Auto proyectado**: la app corre en el
> móvil y se proyecta al coche. NO es *Android Automotive OS* (donde la app se instala
> dentro del sistema del vehículo). PluriWave es una app de audio → categoría
> **"media app"**. Todo lo de abajo es para esa combinación.
---
## TL;DR (lo importante primero)
1. **El 60% ya está hecho.** PluriWave usa `audio_service`, que ya expone un
`MediaBrowserService` + `MediaSession` (lo que Android Auto exige). No hay que
reescribir el motor de audio.
2. **Falta lo que un coche necesita de más que un teléfono:** un **árbol navegable**
de emisoras (para que el coche muestre una lista) y una **declaración en el manifest**
para que Android Auto descubra la app.
3. **Trabajo real de código:** ~1 archivo XML nuevo + 1 línea en el manifest +
implementar 3 métodos en `PluriWaveAudioHandler` (`getChildren`, `getMediaItem`,
`playFromMediaId`).
4. **Publicación:** Android Auto añade una **revisión extra de Google** contra las
*car app quality guidelines*. Es más estricta y más lenta que la de una app normal.
Tiempo realista para un principiante: **1–2 semanas** (código un par de días, el resto
es testing con el emulador de coche y la revisión de Google).
---
## Parte 0 — Cómo funciona (modelo mental)
Un coche con Android Auto **no ejecuta tu UI de Flutter**. En su lugar, le pide a tu app
dos cosas a través de un servicio estándar de Android:
| El coche pregunta | Tu app responde | En Android esto es |
|-------------------|-----------------|--------------------|
| "¿Qué contenido tenés para mostrar?" | Una lista de ítems (carpetas + emisoras) | `MediaBrowserService` → `getChildren()` |
| "El usuario tocó ESTE ítem, reproducilo" | Arrancás el stream | `MediaSession` → `playFromMediaId()` |
| "Mostrame play/pausa/título/carátula" | El estado actual | `PlaybackState` + `MediaItem` |
`audio_service` implementa el `MediaBrowserService` y el `MediaSession` por vos. Tu único
trabajo es **rellenar las respuestas** (la lista de emisoras y cómo reproducir cada una).
La UI del coche la dibuja **Android Auto**, no vos. Vos solo aportás datos y audio.
---
## Parte 1 — Qué ya tiene PluriWave (punto de partida)
Verificado en el código actual:
| Pieza | Dónde | Estado |
|-------|-------|--------|
| Dependencia `audio_service` `^0.18.15` | `pubspec.yaml` | ✅ |
| `MediaBrowserService` declarado en el manifest | `android/app/src/main/AndroidManifest.xml:47-54` | ✅ |
| `MediaButtonReceiver` (controles físicos/notificación) | `AndroidManifest.xml:62-68` | ✅ |
| Inicialización del handler | `lib/main.dart:38` (`AudioService.init`) | ✅ |
| Config del servicio | `lib/main.dart:21` (`AudioServiceConfig`) | ✅ |
| Handler propio | `lib/servicios/servicio_audio.dart:127` (`PluriWaveAudioHandler extends BaseAudioHandler`) | ✅ |
| Reproducir un ítem | `servicio_audio.dart:433` (`playMediaItem`) | ✅ |
| Mapear `MediaItem` ↔ `Emisora` | `servicio_audio.dart:705` (`_emisoraDesdeMediaItem`) | ✅ |
| `foregroundServiceType="mediaPlayback"` | `AndroidManifest.xml:49` | ✅ |
**Ventaja clave de la versión 0.18:** el handler corre en el **mismo isolate** que la app,
así que `getChildren()` puede leer directamente tu lista de emisoras/favoritos del estado
de la app. No hay que sincronizar entre isolates.
### Lo que NO está (el hueco a rellenar)
| Falta | Consecuencia hoy |
|-------|------------------|
| `res/xml/automotive_app_desc.xml` | Android Auto **no descubre** la app |
| `<meta-data com.google.android.gms.car.application>` en el manifest | idem |
| Override de `getChildren()` / `getMediaItem()` | El coche no tiene **ninguna lista** que mostrar |
| Override de `playFromMediaId()` | Tocar una emisora en el coche **no reproduce** nada |
| `MediaItem`s con carátula (`artUri`) por emisora | Google **rechaza** apps de media sin título+thumbnail por ítem |
Hoy PluriWave solo sabe reproducir un `MediaItem` que le pasa **su propia UI de Flutter**
(`playMediaItem`). El coche necesita el camino inverso: **pedir la lista** y **arrancar por id**.
---
## Parte 2 — Quick path (los pasos, en orden)
### Paso 1 · Declarar la app ante Android Auto
Crear `android/app/src/main/res/xml/automotive_app_desc.xml`:
```xml
<automotiveApp>
<uses name="media"/>
</automotiveApp>
```
Añadir dentro de `<application>` en `AndroidManifest.xml` (junto al resto de `<meta-data>`):
```xml
<meta-data
android:name="com.google.android.gms.car.application"
android:resource="@xml/automotive_app_desc"/>
```
> Con esto Android Auto ya "ve" la app, pero seguirá vacía hasta el Paso 2.
### Paso 2 · Construir el árbol navegable (el trabajo de fondo)
En `PluriWaveAudioHandler` (`lib/servicios/servicio_audio.dart`) implementar estos métodos.
Firmas **verificadas** contra el código de `audio_service`:
```dart
// Devuelve los hijos de una "carpeta". El root usa AudioService.browsableRootId.
Future<List<MediaItem>> getChildren(String parentMediaId,
[Map<String, dynamic>? options]);
// Metadatos de un ítem concreto (por si el coche los pide sueltos).
Future<MediaItem?> getMediaItem(String mediaId);
// El usuario tocó un ítem en la pantalla del coche → reproducirlo.
Future<void> playFromMediaId(String mediaId, [Map<String, dynamic>? extras]);
// (Opcional) búsqueda por voz "pon Radio X".
Future<List<MediaItem>> playFromSearch(String query, [Map<String, dynamic>? extras]);
```
Diseño de árbol sugerido para PluriWave:
```
root (AudioService.browsableRootId)
├── Favoritos (playable: false → carpeta)
│ ├── Emisora A (playable: true)
│ └── Emisora B (playable: true)
├── Todas las emisoras (playable: false)
│ └── ...
└── Mis emisoras (playable: false) // las custom del usuario
└── ...
```
Reglas de un `MediaItem`:
| Campo | Carpeta | Emisora reproducible |
|-------|---------|----------------------|
| `id` | id estable de la categoría | id estable de la emisora |
| `title` | nombre visible | nombre de la emisora **(obligatorio)** |
| `playable` | `false` | `true` |
| `artUri` | opcional | **carátula/logo (obligatorio para pasar la revisión)** |
Lógica a reutilizar: ya tenés `_emisoraDesdeMediaItem` (`servicio_audio.dart:705`) y
`playMediaItem` (`servicio_audio.dart:433`). En `playFromMediaId(id)` resolvés el id →
`Emisora` → construís el `MediaItem` real → llamás al mismo `playMediaItem` interno. **No
dupliques la lógica de reproducción**, enchufala.
### Paso 3 · Carátulas accesibles
`artUri` tiene que ser una URL/҇URI que el sistema pueda cargar (http(s) o `content://`).
Las emisoras que ya tienen logo remoto sirven directo. Para emisoras sin logo, definí una
carátula por defecto (asset empaquetado servido vía `content://` o un placeholder remoto).
### Paso 4 · (Opcional pero recomendado) Content style
Android Auto puede pintar los ítems como **lista** o **grid**. Se controla con hints en el
`extras`/config del root (constantes `CONTENT_STYLE_*` de la spec de MediaBrowser).
Para una app de radio, **grid** para emisoras (se ven los logos) queda mejor. Es pulido,
no bloquea la publicación.
---
## Parte 3 · Probar sin coche (DHU — Desktop Head Unit)
No necesitás un coche para testear. Google da un emulador de la pantalla del coche.
**Quick path del testeo:**
1. En **Android Studio → SDK Manager → SDK Tools**, instalá **Android Auto Desktop Head Unit**.
2. En el **teléfono**: instalá la app *Android Auto*, entrá en sus ajustes y tocá 10 veces
la versión para activar **modo desarrollador**; ahí activá **"Head unit server"**.
3. Conectá el teléfono por USB y lanzá el DHU:
```bash
cd "$ANDROID_HOME/extras/google/auto"
./desktop-head-unit # (desktop-head-unit.exe en Windows)
```
4. En la ventana del DHU deberías ver PluriWave en la sección de **media**. Navegá el árbol
y reproducí una emisora.
**Checklist de humo en el DHU:**
- [ ] La app aparece en la lista de apps de media del coche.
- [ ] Se ve el árbol (Favoritos / Todas / Mis emisoras).
- [ ] Cada emisora muestra **título + carátula**.
- [ ] Tocar una emisora **arranca el audio**.
- [ ] Play / pausa / stop responden desde la pantalla del coche.
- [ ] Al pausar en el coche, la app del teléfono refleja el mismo estado (y viceversa).
---
## Parte 4 · Publicar en Play Store (lo específico de un primer publicador)
Publicar una app **con Android Auto** no es igual que una app normal: dispara una
**revisión adicional** de Google contra las *car app quality guidelines*.
### Requisitos de calidad que Google verifica (media apps)
| Requisito | Qué significa para PluriWave |
|-----------|------------------------------|
| Integración con MediaSession | Ya lo da `audio_service` ✅ |
| Soportar play/pausa **o** stop | Ya lo tenés ✅ |
| **Título + thumbnail por cada ítem** | ← esto es lo que hay que asegurar (Paso 3) |
| Poder llegar a la vista de reproducción desde el browsing | Se cumple con el árbol bien armado |
| **Al menos 1 screenshot real, sin editar, de la experiencia en coche** | Sacala del DHU |
### Pasos en Play Console
1. **Cuenta de desarrollador** (pago único de ~25 USD, primera vez).
2. Subí primero a un **track de pruebas cerrado**, NO directo a producción.
> Importante: si el build va en un track de **testing** y no cumple, Google te avisa
> pero **igual lo aprueba** para ese track. Si el mismo build va a **producción** y no
> cumple, lo **rechaza**. Por eso: cerrado → arreglás → producción.
3. Completá la **ficha de la tienda** + el cuestionario de contenido/privacidad
(obligatorio para cualquier app nueva).
4. Subí la **screenshot de la experiencia en coche** (del DHU).
5. Enviá a revisión. La revisión de coche puede tardar **de unas horas hasta 7 días**
(a veces más), bastante más que una app solo-móvil.
### Gotchas para PluriWave concretamente
- **Muchos permisos sensibles.** El manifest pide localización, `RECORD_AUDIO`,
`SCHEDULE_EXACT_ALARM`, `SYSTEM_EXEMPTED`, etc. La revisión de coche mira con lupa; tené
a mano la justificación de cada permiso (la sección de *foreground service* de Play
Console te va a pedir el porqué de `mediaPlayback`).
- **`targetSdk`** sale de `flutter.targetSdkVersion` (`android/app/build.gradle`). Play
exige un target reciente para apps nuevas; verificá que cumpla el mínimo del año antes de
subir.
- **Streams que fallan.** Google prueba reproducir. Si una emisora del árbol está caída, da
mala impresión. Exponé en el árbol emisoras fiables (favoritos del usuario, o un set
curado) y manejá el error de stream con gracia (ya tenés `controlador_reconexion.dart`).
---
## Checklist maestro
**Código**
- [ ] `res/xml/automotive_app_desc.xml` creado (`<uses name="media"/>`).
- [ ] `<meta-data com.google.android.gms.car.application>` en el manifest.
- [ ] `getChildren()` devuelve el árbol (carpetas + emisoras).
- [ ] `getMediaItem()` resuelve un id suelto.
- [ ] `playFromMediaId()` reutiliza `playMediaItem` interno.
- [ ] Cada emisora expone `title` + `artUri`.
**Testing**
- [ ] Funciona en el DHU (navegar + reproducir + play/pausa).
- [ ] Estado sincronizado coche ↔ teléfono.
**Publicación**
- [ ] Cuenta de desarrollador creada.
- [ ] Screenshot de la experiencia en coche subida.
- [ ] Subido primero a track cerrado.
- [ ] Justificación de permisos preparada.
- [ ] Enviado a revisión.
---
## Referencias
- [Media apps for cars — overview (Android Developers)](https://developer.android.com/training/cars/media)
- [Add support for Android Auto to your media app](https://developer.android.com/training/cars/media/auto)
- [Car app quality guidelines](https://developer.android.com/docs/quality-guidelines/car-app-quality)
- [Distribute to cars (Play Console)](https://developer.android.com/training/cars/distribute)
- [audio_service (pub.dev)](https://pub.dev/packages/audio_service)
- [audio_service — repo y ejemplos (GitHub)](https://github.com/ryanheise/audio_service)
---
## Próximo paso sugerido
Empezar por el **Paso 1 + Paso 2 con un árbol mínimo** (solo "Favoritos" con 2–3 emisoras
hardcodeadas) y verlo en el **DHU**. Cuando eso reproduzca en el emulador de coche, recién
ahí ampliar el árbol y pulir carátulas. Es el bucle de feedback más corto para no
programar a ciegas.
+1
View File
@@ -12,5 +12,6 @@ import UIKit
func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) {
GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry)
AudioDevicesPlugin.register(with: engineBridge.pluginRegistry.registrar(forPlugin: "AudioDevicesPlugin")!)
}
}
+166
View File
@@ -0,0 +1,166 @@
import AVFoundation
import Flutter
/// Platform channel plugin that detects audio output devices on iOS via
/// AVAudioSession route change notifications.
///
/// Channel: "pluriwave/audio_devices"
/// - MethodChannel `getActiveDevice` → Dictionary with `id`, `type`, `name`
/// - EventChannel stream → same Dictionary on route change
///
/// Device ID format (matches spec):
/// "builtin_speaker" — AVAudioSession.Port.builtInSpeaker
/// "wired_headset" — AVAudioSession.Port.headphones / .headsetMic
/// "bt_a2dp:<uid|portName>" — AVAudioSession.Port.bluetoothA2DP
/// "usb_headset:<uid|name>" — AVAudioSession.Port.usbAudio
///
/// iOS uid fallback (spec scenario "iOS uid fallback on uid instability"):
/// Use portType+uid as the primary key; fall back to portType+portName
/// if uid is empty or nil to guarantee a non-empty, non-null key.
class AudioDevicesPlugin: NSObject, FlutterPlugin, FlutterStreamHandler {
private static let channelName = "pluriwave/audio_devices"
// Type int constants matching the Dart-side protocol (same as Android).
private static let typeBuiltinSpeaker = 2
private static let typeWiredHeadset = 3
private static let typeBluetoothA2dp = 8
private static let typeUsbHeadset = 14
private static let typeUnknown = 0
private var eventSink: FlutterEventSink?
// MARK: - Plugin registration
static func register(with registrar: FlutterPluginRegistrar) {
let messenger = registrar.messenger()
let instance = AudioDevicesPlugin()
let methodChannel = FlutterMethodChannel(name: channelName, binaryMessenger: messenger)
registrar.addMethodCallDelegate(instance, channel: methodChannel)
let eventChannel = FlutterEventChannel(name: channelName, binaryMessenger: messenger)
eventChannel.setStreamHandler(instance)
registrar.addApplicationDelegate(instance)
}
// MARK: - MethodChannel
func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) {
switch call.method {
case "getActiveDevice":
result(currentDeviceMap())
default:
result(FlutterMethodNotImplemented)
}
}
// MARK: - EventChannel (FlutterStreamHandler)
func onListen(withArguments arguments: Any?, eventSink events: @escaping FlutterEventSink) -> FlutterError? {
self.eventSink = events
NotificationCenter.default.addObserver(
self,
selector: #selector(routeChanged(_:)),
name: AVAudioSession.routeChangeNotification,
object: nil
)
return nil
}
func onCancel(withArguments arguments: Any?) -> FlutterError? {
NotificationCenter.default.removeObserver(
self,
name: AVAudioSession.routeChangeNotification,
object: nil
)
self.eventSink = nil
return nil
}
// MARK: - Route change notification
@objc private func routeChanged(_ notification: Notification) {
guard let sink = eventSink else { return }
sink(currentDeviceMap())
}
// MARK: - Helpers
/// Returns a dictionary representing the current active audio output route.
private func currentDeviceMap() -> [String: Any] {
let session = AVAudioSession.sharedInstance()
let outputs = session.currentRoute.outputs
// Priority order: BT A2DP > USB > wired > built-in speaker > unknown
let priority: [AVAudioSession.Port] = [
.bluetoothA2DP,
.usbAudio,
.headphones,
.headsetMic,
.builtInSpeaker,
]
let best = outputs.min { a, b in
let ia = priority.firstIndex(of: a.portType) ?? Int.max
let ib = priority.firstIndex(of: b.portType) ?? Int.max
return ia < ib
}
return portToMap(best)
}
/// Converts an AVAudioSessionPortDescription to the channel map format.
private func portToMap(_ port: AVAudioSessionPortDescription?) -> [String: Any] {
guard let port = port else {
return ["id": "builtin_speaker", "type": AudioDevicesPlugin.typeBuiltinSpeaker, "name": "Speaker"]
}
switch port.portType {
case .builtInSpeaker:
return ["id": "builtin_speaker",
"type": AudioDevicesPlugin.typeBuiltinSpeaker,
"name": port.portName]
case .headphones, .headsetMic:
return ["id": "wired_headset",
"type": AudioDevicesPlugin.typeWiredHeadset,
"name": port.portName]
case .bluetoothA2DP:
let key = stableKey(prefix: "bt_a2dp", port: port)
return ["id": key,
"type": AudioDevicesPlugin.typeBluetoothA2dp,
"name": port.portName]
case .usbAudio:
let key = stableKey(prefix: "usb_headset", port: port)
return ["id": key,
"type": AudioDevicesPlugin.typeUsbHeadset,
"name": port.portName]
default:
// Unknown type — use portType string as part of id to avoid empty key.
let id = "unknown:\(port.portType.rawValue)"
return ["id": id,
"type": AudioDevicesPlugin.typeUnknown,
"name": port.portName]
}
}
/// Derives a stable device key using uid (preferred) or portName as fallback.
///
/// Per spec scenario "iOS uid fallback on uid instability": uid may differ
/// across sessions on some BT devices. In that case, use portType+portName.
private func stableKey(prefix: String, port: AVAudioSessionPortDescription) -> String {
let uid = port.uid
if !uid.isEmpty {
return "\(prefix):\(uid)"
}
// uid is empty — fall back to portType+portName (must not be empty per spec).
let name = port.portName.isEmpty ? "unknown" : port.portName
return "\(prefix):\(name)"
}
}
+235 -298
View File
@@ -1,14 +1,26 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'estado/estado_busqueda.dart';
import 'estado/estado_ecualizador.dart';
import 'estado/estado_entitlement.dart';
import 'estado/estado_grabacion.dart';
import 'estado/estado_radio.dart';
import 'estado/estado_alarmas.dart';
import 'estado/estado_idioma.dart';
import 'estado/estado_navegacion.dart';
import 'servicios/servicio_anuncios.dart';
import 'servicios/servicio_compras.dart';
import 'widgets/banner_anuncio_superior.dart';
import 'l10n/display_names.dart';
import 'l10n/gen/app_localizations.dart';
import 'modelos/alarma_musical.dart';
import 'pantallas/pantalla_alarmas.dart';
import 'pantallas/pantalla_alarma_sonando.dart';
import 'pantallas/pantalla_bienvenida.dart';
import 'pantallas/pantalla_inicio.dart';
import 'pantallas/pantalla_tutorial_ayuda.dart';
import 'pantallas/pantalla_buscar.dart';
import 'pantallas/pantalla_favoritos.dart';
import 'pantallas/pantalla_ajustes.dart';
@@ -19,18 +31,109 @@ import 'widgets/pluri_layout.dart';
import 'widgets/pluri_onboarding_dialog.dart';
import 'widgets/pluri_wave_scaffold.dart';
import 'package:pluriwave/widgets/mini_reproductor.dart';
import 'servicios/navegacion_auto.dart';
import 'servicios/servicio_alarmas_android.dart';
import 'servicios/servicio_dispositivo_audio.dart';
/// Extracted out of `_PaginaPrincipalState.build` (FIX 1, code review) so
/// the banner + status-bar-inset composition is unit-testable in isolation
/// — `_PaginaPrincipal` itself is library-private and constructs real
/// platform-backed services (see `app_test.dart`'s own comments), so it
/// cannot be safely widget-tested directly. Mirrors this file's existing
/// `@visibleForTesting` top-level extraction convention
/// (`main.dart`'s `orientacionesPara`/`aplicarPoliticaOrientacion`).
///
/// `BannerAnuncioSuperior` owns its OWN top `SafeArea` internally now (see
/// `banner_anuncio_superior.dart`) — this function deliberately does NOT
/// wrap it in one, since `SafeArea` reserves `MediaQuery.padding.top` even
/// around a zero-size collapsed child, which used to leave a permanent
/// blank status-bar-height strip for premium users and for free users
/// before the first ad finished loading.
@visibleForTesting
Widget construirCuerpoPrincipal({required Widget contenido}) {
return Column(
children: [
const BannerAnuncioSuperior(),
Expanded(child: SafeArea(top: false, child: contenido)),
],
);
}
class PluriWaveApp extends StatelessWidget {
const PluriWaveApp({super.key});
const PluriWaveApp({super.key, this.prefs, this.fuenteAuto, this.compras});
/// Single SharedPreferences instance resolved in main() (S3-R4) and
/// injected into every state/service.
final SharedPreferences? prefs;
/// Android Auto browse source (Design "Data Flow" — cold-bind local read
/// available before EstadoRadio builds). Optional: defaults to `null`,
/// same as every other existing caller/test that constructs
/// [PluriWaveApp] without it.
final FuenteEmisorasAuto? fuenteAuto;
/// Purchase I/O port (iap-freemium-unlock, Design ADR-2). Optional and
/// `null` by default — mirrors [fuenteAuto]'s injection shape, so every
/// pre-existing test that constructs [PluriWaveApp] without it never
/// touches the real `in_app_purchase` plugin channel. `main.dart` wires
/// the real [ServicioComprasPlayBilling].
final PuertoCompras? compras;
@override
Widget build(BuildContext context) {
return MultiProvider(
providers: [
ChangeNotifierProvider(create: (_) => EstadoRadio()),
ChangeNotifierProvider(create: (_) => EstadoAlarmas()),
ChangeNotifierProvider(create: (_) => EstadoIdioma()),
// iap-freemium-unlock (Design ADR-3): registered FIRST so every
// provider below can read it via `context.read` inside a lazy
// `esPremium` closure — `MultiProvider` nests top-to-bottom, so only
// a provider ABOVE a given one is reachable from its own `create`.
ChangeNotifierProvider(
create: (_) => EstadoEntitlement(prefs: prefs, compras: compras),
),
ChangeNotifierProvider(
create:
(context) => EstadoRadio(
prefs: prefs,
dispositivoAudio: ServicioDispositivoAudioReal(),
fuenteAuto: fuenteAuto,
esPremium: () => context.read<EstadoEntitlement>().esPremium,
),
),
// Domain notifiers (S4-R1/R2/R3). Created and disposed by EstadoRadio
// (they need its services and callbacks at construction); these
// providers only expose the instances, so they declare no dispose
// callback.
ListenableProvider<EstadoEcualizador>(
create: (context) => context.read<EstadoRadio>().ecualizador,
),
ListenableProvider<EstadoGrabacion>(
create: (context) => context.read<EstadoRadio>().grabacion,
),
ListenableProvider<EstadoBusqueda>(
create: (context) => context.read<EstadoRadio>().busqueda,
),
ChangeNotifierProvider(
create:
(context) => EstadoAlarmas(
prefs: prefs,
esPremium: () => context.read<EstadoEntitlement>().esPremium,
),
),
ChangeNotifierProvider(
create: (_) => EstadoIdioma(sharedPreferences: prefs),
),
// Design ADR-8: root-to-root navigation state. `_PaginaPrincipal`
// watches this instead of owning `_indice` locally.
ChangeNotifierProvider(create: (_) => EstadoNavegacionRaiz()),
// iap-freemium-unlock (Design "Interfaces / Contracts", ADR-6): a
// plain (non-notifier) `Provider` — session-scoped ad state, never
// rebuilds the widget tree itself.
Provider<ServicioAnuncios>(
create:
(context) => ServicioAnuncios(
esPremium: () => context.read<EstadoEntitlement>().esPremium,
),
),
],
child: Consumer<EstadoIdioma>(
builder:
@@ -57,17 +160,20 @@ class _PaginaPrincipal extends StatefulWidget {
State<_PaginaPrincipal> createState() => _PaginaPrincipalState();
}
class _PaginaPrincipalState extends State<_PaginaPrincipal> {
static const _volumenInicialFadeInAlarmas = 0.05;
int _indice = 0;
class _PaginaPrincipalState extends State<_PaginaPrincipal>
with WidgetsBindingObserver {
StreamSubscription<String>? _errorSubscription;
StreamSubscription<EventoAlarmaAndroid>? _alarmaSubscription;
StreamSubscription<AlarmaMusical>? _alarmaVencidaSubscription;
EstadoRadio? _estadoSuscrito;
bool _alarmaInicialProcesada = false;
bool _alarmaSonandoActiva = false;
bool _onboardingInicialSolicitado = false;
// WU17b: renamed from `_onboardingInicialSolicitado` — this single guard
// now covers the whole first-launch sequence (welcome screen, then the
// pre-existing what's-new dialog), not only the dialog.
bool _flujoPrimerLanzamientoSolicitado = false;
String? _alarmaSonandoId;
Locale? _localeAlarmasConfigurado;
static const _paginas = [
PantallaInicio(),
@@ -85,9 +191,34 @@ class _PaginaPrincipalState extends State<_PaginaPrincipal> {
PluriNavItem(glyph: PluriIconGlyph.settings, label: l10n.navSettings),
];
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
if (state != AppLifecycleState.resumed) return;
// Fix "stale green dot": on return to foreground the Activity may have
// been recreated over the cached engine, leaving the device event channel
// without a live native sink. Re-subscribe and re-seed the active device
// (no-op when multi-device EQ is off).
unawaited(context.read<EstadoEcualizador>().refrescarDispositivoActual());
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
// S3-R3 / Decision 3.2: keep the alarm bridge l10n in sync, once per
// locale change (this hook re-runs when Localizations changes).
final locale = Localizations.localeOf(context);
if (_localeAlarmasConfigurado != locale) {
_localeAlarmasConfigurado = locale;
context.read<EstadoAlarmas>().configurarLocalizaciones(
AppLocalizations.of(context),
);
}
final estado = context.read<EstadoRadio>();
if (identical(_estadoSuscrito, estado) && _errorSubscription != null) {
return;
@@ -123,14 +254,15 @@ class _PaginaPrincipalState extends State<_PaginaPrincipal> {
_alarmaInicialProcesada = true;
unawaited(_procesarAlarmaInicial(alarmas));
}
if (!_onboardingInicialSolicitado) {
_onboardingInicialSolicitado = true;
unawaited(_mostrarOnboardingInicial());
if (!_flujoPrimerLanzamientoSolicitado) {
_flujoPrimerLanzamientoSolicitado = true;
unawaited(_mostrarFlujoPrimerLanzamiento());
}
}
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
_errorSubscription?.cancel();
_alarmaSubscription?.cancel();
_alarmaVencidaSubscription?.cancel();
@@ -140,21 +272,21 @@ class _PaginaPrincipalState extends State<_PaginaPrincipal> {
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final navegacion = context.watch<EstadoNavegacionRaiz>();
final indice = navegacion.indice;
return PluriWaveScaffold(
appBar: AppBar(
title: Text(l10n.appTitle),
actions: [
IconButton(
icon: const Icon(Icons.bedtime_outlined),
tooltip: l10n.sleepTimer,
onPressed: () => _mostrarTimerDialog(context),
),
],
),
body: SafeArea(
top: false,
child: AnimatedSwitcher(
// ad-display spec "Persistent Top Banner, Never Overlapping Content"
// (design.md ADR-6): a `Column` sibling, NEVER a `Stack`/overlay — the
// banner RESERVES its own space above the existing body instead of
// covering any of it. `BannerAnuncioSuperior` itself collapses to
// `SizedBox.shrink()` (zero layout impact) for premium/unloaded, and
// (FIX 1, code review) owns its OWN top `SafeArea` internally — this
// level no longer wraps it in an unconditional `SafeArea`, which used
// to reserve `MediaQuery.padding.top` even for a zero-size collapsed
// child, leaving a permanent blank status-bar-height strip.
body: construirCuerpoPrincipal(
contenido: AnimatedSwitcher(
duration: context.pluriMotion.normal,
switchInCurve: Curves.easeOutCubic,
switchOutCurve: Curves.easeInCubic,
@@ -170,27 +302,34 @@ class _PaginaPrincipalState extends State<_PaginaPrincipal> {
),
),
child: KeyedSubtree(
key: ValueKey<int>(_indice),
child: _paginas[_indice],
key: ValueKey<int>(indice),
child: _paginas[indice],
),
),
),
bottomNavigationBar: SafeArea(
top: false,
minimum: const EdgeInsets.only(bottom: PluriLayout.compactGap),
child: Padding(
padding: const EdgeInsets.fromLTRB(8, 0, 8, 0),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const MiniReproductor(),
PluriBottomNavigation(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// Item 22 / audit 3.6 (t4:185 left:0;right:0): the mini player
// is full-bleed — it does NOT share the balloon bar's 8px side
// margin below. ADR-7(b): hidden on Escuchar (index 0) only —
// its embedded hero already shows the same station. Stays
// mounted (visible: false renders SizedBox.shrink(), not tree
// removal) so its didChangeDependencies side effect (S3-R3)
// keeps running.
MiniReproductor(visible: indice != RaizPluriWave.escuchar.index),
Padding(
padding: const EdgeInsets.fromLTRB(8, 0, 8, 0),
child: PluriBottomNavigation(
items: _navItems(l10n),
selectedIndex: _indice,
onSelected: (i) => setState(() => _indice = i),
selectedIndex: indice,
onSelected: (i) => navegacion.irA(RaizPluriWave.values[i]),
),
],
),
),
],
),
),
);
@@ -203,6 +342,29 @@ class _PaginaPrincipalState extends State<_PaginaPrincipal> {
}
}
// WU17b: runs the welcome screen's once-ever check BEFORE the recurring
// what's-new dialog, so the two never show at the same time. The welcome
// screen (PantallaBienvenida) is the genuine first-run surface; the
// pre-existing PluriOnboardingDialog is an unrelated "what's new"/help
// modal that keeps its own independent per-version due-or-not logic,
// completely unchanged by this sequencing.
//
// The 9-screen help/tutorial carousel (PantallaTutorialAyuda) runs
// BETWEEN the two: after the welcome screen (fresh installs only) and
// before the what's-new dialog. Unlike the welcome screen, the tutorial
// shows once to EVERY install -- fresh AND existing -- via its own plain
// one-time flag (ServicioTutorialAyuda), which is what makes an
// already-installed app show it once after updating to this version.
Future<void> _mostrarFlujoPrimerLanzamiento() async {
if (mounted) {
await PantallaBienvenida.mostrarSiProcede(context);
}
if (mounted) {
await PantallaTutorialAyuda.mostrarSiProcede(context);
}
await _mostrarOnboardingInicial();
}
Future<void> _mostrarOnboardingInicial() async {
await Future<void>.delayed(const Duration(milliseconds: 900));
if (!mounted || _alarmaSonandoActiva) return;
@@ -210,6 +372,17 @@ class _PaginaPrincipalState extends State<_PaginaPrincipal> {
}
Future<void> _abrirAlarmaSonando(EventoAlarmaAndroid evento) async {
if (evento.accion == EventoAlarmaAndroid.accionSnoozed) {
// EstadoAlarmas records native snoozes itself (Decision 2.1); there is
// nothing to open for this event.
return;
}
if (evento.accion == EventoAlarmaAndroid.accionMissed) {
// EstadoAlarmas' own native-event listener already recorded this
// transition (RES-1); the ring already ended, so opening the ringing
// screen here would only show a stale, already-silent alarm.
return;
}
final estado = context.read<EstadoAlarmas>();
if (estado.alarmas.isEmpty) {
await estado.cargarPersistidasSinRecalcular();
@@ -230,13 +403,13 @@ class _PaginaPrincipalState extends State<_PaginaPrincipal> {
if (evento.accion.endsWith('.SKIP_NEXT')) {
await estado.saltarProxima(alarma.id);
if (!mounted) return;
setState(() => _indice = 3);
context.read<EstadoNavegacionRaiz>().irA(RaizPluriWave.alarmas);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
AppLocalizations.of(
context,
).skipCurrentAlarmExecution(alarma.nombre),
AppLocalizations.of(context).skipCurrentAlarmExecution(
localizedAlarmName(AppLocalizations.of(context), alarma.nombre),
),
),
),
);
@@ -253,18 +426,24 @@ class _PaginaPrincipalState extends State<_PaginaPrincipal> {
ejecucion,
);
if (!mounted) return;
setState(() => _indice = 3);
context.read<EstadoNavegacionRaiz>().irA(RaizPluriWave.alarmas);
// posponerProximaDesdePreaviso no longer throws on a native scheduling
// failure — it records the failure into EstadoAlarmas.error instead.
// Branch on it here so the user sees the real outcome instead of an
// always-success message.
final error = estado.error;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
AppLocalizations.of(context).alarmPostponedCurrentExecution,
error ??
AppLocalizations.of(context).alarmPostponedCurrentExecution,
),
),
);
return;
}
if (evento.accion.endsWith('.PRE_NOTICE')) {
setState(() => _indice = 3);
context.read<EstadoNavegacionRaiz>().irA(RaizPluriWave.alarmas);
return;
}
await _mostrarAlarmaSonando(alarma);
@@ -282,7 +461,17 @@ class _PaginaPrincipalState extends State<_PaginaPrincipal> {
debugPrint(
'[PluriWave][alarmas] alarma ignorada porque ya hay una activa id=${alarma.id} activa=$_alarmaSonandoId',
);
await alarmas.android.ocultarNotificacionAlarma(alarma.id);
// A duplicate delivery of the SAME ring's own fire event (the live
// eventosAlarma stream and the one-shot obtenerEventoInicial() read
// the same native event and can both reach here) must be a no-op.
// When a genuinely DIFFERENT alarm fired while this one is active
// (single-ring-at-a-time by design), hide ONLY its notification
// (RES-1): ocultarNotificacionAlarma -> dismissAlarmNotification
// unconditionally stops PluriWaveAlarmService, which would silently
// kill the OTHER alarm's ring if it is the one genuinely sounding.
if (alarma.id != _alarmaSonandoId) {
await alarmas.android.ocultarSoloNotificacion(alarma.id);
}
return;
}
@@ -290,15 +479,10 @@ class _PaginaPrincipalState extends State<_PaginaPrincipal> {
_alarmaSonandoId = alarma.id;
try {
await _prearrancarAudioAlarma(alarma);
if (!mounted) return;
await Navigator.of(context).push(
MaterialPageRoute<void>(
builder:
(_) => PantallaAlarmaSonando(
alarma: alarma,
audioPrearrancado: alarma.emisora != null,
),
builder: (_) => PantallaAlarmaSonando(alarma: alarma),
fullscreenDialog: true,
),
);
@@ -309,251 +493,4 @@ class _PaginaPrincipalState extends State<_PaginaPrincipal> {
}
}
}
Future<void> _prearrancarAudioAlarma(AlarmaMusical alarma) async {
final emisora = alarma.emisora;
if (emisora == null) return;
final radio = context.read<EstadoRadio>();
debugPrint(
'[PluriWave][alarmas] prearrancar emisora alarma id=${alarma.id} emisora=${emisora.nombre}',
);
await radio.audio.setVolumen(_volumenInicialFadeInAlarmas);
unawaited(radio.reproducir(emisora));
}
void _mostrarTimerDialog(BuildContext context) {
showModalBottomSheet(
context: context,
showDragHandle: true,
builder:
(ctx) => Consumer<EstadoRadio>(
builder:
(ctx, estado, _) => SafeArea(
child: Padding(
padding: PluriLayout.sheetPadding,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
AppLocalizations.of(ctx).sleepTimer,
style: Theme.of(ctx).textTheme.titleLarge,
),
const SizedBox(height: PluriLayout.sectionGap),
Text(
AppLocalizations.of(ctx).sleepTimerDescription,
style: Theme.of(ctx).textTheme.bodySmall,
),
const SizedBox(height: PluriLayout.panelGap),
if (estado.timer.activo)
StreamBuilder<Duration>(
stream: estado.timer.tiempoRestanteStream,
builder: (ctx, snap) {
final restante =
snap.data ?? estado.timer.tiempoRestante;
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
_formatearDuracionTimer(restante),
style:
Theme.of(ctx).textTheme.headlineMedium,
),
const SizedBox(
height: PluriLayout.compactGap,
),
FilledButton.tonal(
onPressed: () {
estado.cancelarTimer();
Navigator.pop(ctx);
},
child: Text(
AppLocalizations.of(ctx).cancelTimer,
),
),
],
);
},
)
else
Wrap(
spacing: PluriLayout.compactGap,
runSpacing: PluriLayout.compactGap,
children: [
for (final segundos
in estado.timerSuenoPresetsSegundos)
ActionChip(
label: Text(
_formatearDuracionTimer(
Duration(seconds: segundos),
),
),
onPressed: () {
estado.iniciarTimerDuracion(
Duration(seconds: segundos),
);
Navigator.pop(ctx);
},
),
ActionChip(
avatar: const Icon(
Icons.tune_rounded,
size: 18,
),
label: Text(
AppLocalizations.of(ctx).optionOther,
),
onPressed: () async {
final duracion =
await _pedirDuracionPersonalizada(ctx);
if (duracion == null || !ctx.mounted) return;
estado.iniciarTimerDuracion(duracion);
Navigator.pop(ctx);
},
),
],
),
],
),
),
),
),
);
}
Future<Duration?> _pedirDuracionPersonalizada(BuildContext context) {
return showModalBottomSheet<Duration>(
context: context,
isScrollControlled: true,
showDragHandle: true,
builder: (ctx) => const _TimerPersonalizadoSheet(),
);
}
}
String _formatearDuracionTimer(Duration duracion) {
final horas = duracion.inHours;
final minutos = duracion.inMinutes.remainder(60);
final segundos = duracion.inSeconds.remainder(60);
if (horas > 0) {
return '${horas}h ${minutos.toString().padLeft(2, '0')}m ${segundos.toString().padLeft(2, '0')}s';
}
if (minutos > 0) {
return segundos == 0 ? '$minutos min' : '${minutos}m ${segundos}s';
}
return '$segundos s';
}
class _TimerPersonalizadoSheet extends StatefulWidget {
const _TimerPersonalizadoSheet();
@override
State<_TimerPersonalizadoSheet> createState() =>
_TimerPersonalizadoSheetState();
}
class _TimerPersonalizadoSheetState extends State<_TimerPersonalizadoSheet> {
final _horasCtrl = TextEditingController();
final _minutosCtrl = TextEditingController(text: '15');
final _segundosCtrl = TextEditingController();
bool _guardarPreset = true;
@override
void dispose() {
_horasCtrl.dispose();
_minutosCtrl.dispose();
_segundosCtrl.dispose();
super.dispose();
}
int _leer(TextEditingController ctrl) => int.tryParse(ctrl.text.trim()) ?? 0;
Future<void> _confirmar() async {
final duracion = Duration(
hours: _leer(_horasCtrl),
minutes: _leer(_minutosCtrl),
seconds: _leer(_segundosCtrl),
);
if (duracion <= Duration.zero) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(AppLocalizations.of(context).durationGreaterThanZero),
),
);
return;
}
if (_guardarPreset) {
await context.read<EstadoRadio>().agregarTimerSuenoPreset(duracion);
}
if (mounted) Navigator.pop(context, duracion);
}
@override
Widget build(BuildContext context) {
final bottom = MediaQuery.viewInsetsOf(context).bottom;
return SafeArea(
child: Padding(
padding: EdgeInsets.fromLTRB(18, 0, 18, 18 + bottom),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
AppLocalizations.of(context).customDurationTitle,
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: PluriLayout.sectionGap),
Row(
children: [
Expanded(
child: _campoTiempo(
_horasCtrl,
AppLocalizations.of(context).hoursLabel,
),
),
const SizedBox(width: PluriLayout.compactGap),
Expanded(
child: _campoTiempo(
_minutosCtrl,
AppLocalizations.of(context).minutesLabel,
),
),
const SizedBox(width: PluriLayout.compactGap),
Expanded(
child: _campoTiempo(
_segundosCtrl,
AppLocalizations.of(context).secondsLabel,
),
),
],
),
const SizedBox(height: PluriLayout.compactGap),
SwitchListTile.adaptive(
contentPadding: EdgeInsets.zero,
title: Text(AppLocalizations.of(context).saveQuickAccess),
value: _guardarPreset,
onChanged: (value) => setState(() => _guardarPreset = value),
),
const SizedBox(height: PluriLayout.sectionGap),
FilledButton.icon(
icon: const Icon(Icons.bedtime_rounded),
label: Text(AppLocalizations.of(context).startTimer),
onPressed: _confirmar,
),
],
),
),
);
}
Widget _campoTiempo(TextEditingController controller, String label) {
return TextField(
controller: controller,
keyboardType: TextInputType.number,
decoration: InputDecoration(
labelText: label,
border: const OutlineInputBorder(),
),
);
}
}
+711 -32
View File
@@ -1,18 +1,45 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../l10n/gen/app_localizations.dart';
import '../modelos/alarma_musical.dart';
import '../servicios/servicio_alarmas.dart';
import '../servicios/servicio_alarmas_android.dart';
import '../servicios/servicio_programacion_alarmas.dart';
/// Distinct "limit reached" signal (Design ADR-5, freemium-gating spec
/// "Alarm Count Cap At 5"): kept SEPARATE from [EstadoAlarmas.error], which
/// stays reserved for native scheduling failures — overloading it would
/// surface a free-tier limit as a scheduling failure in `app.dart`'s global
/// snackbar path.
enum ResultadoGuardarAlarma { guardada, limiteAlcanzado }
class EstadoAlarmas extends ChangeNotifier {
EstadoAlarmas({
ServicioAlarmas? servicio,
PuertoAlarmasAndroid? android,
SharedPreferences? prefs,
bool iniciarAutomaticamente = true,
}) : servicio = servicio ?? ServicioAlarmas(),
android = android ?? ServicioAlarmasAndroid() {
// iap-freemium-unlock (Design ADR-3): entitlement query, mirroring
// `EstadoGrabacion`'s `emisoraActual` callback-injection shape rather
// than a direct `EstadoEntitlement` dependency (this notifier must stay
// constructible with zero widget-tree/Provider context). REQUIRED on
// purpose: an optional parameter with any default lets a forgotten
// wiring compile and silently pick a tier, and no test can catch that.
// Callers must state the entitlement source explicitly.
required bool Function() esPremium,
}) : servicio = servicio ?? ServicioAlarmas(prefs: prefs),
android = android ?? ServicioAlarmasAndroid(),
_prefs = prefs,
_esPremium = esPremium {
// Decision 2.1 (snooze sync): the native layer reports its own snoozes
// back through alarmFired/snoozed; record them here so the Flutter
// config stays the single source of truth.
_eventosNativosSub = this.android.eventosAlarma.listen(
_alRecibirEventoNativo,
);
if (iniciarAutomaticamente) {
inicializar();
}
@@ -20,6 +47,12 @@ class EstadoAlarmas extends ChangeNotifier {
final ServicioAlarmas servicio;
final PuertoAlarmasAndroid android;
final SharedPreferences? _prefs;
final bool Function() _esPremium;
static const _keyExencionBateriaSolicitada = 'bateria_exencion_solicitada';
/// Free-tier alarm cap (freemium-gating spec "Alarm Count Cap At 5").
static const maxAlarmasFree = 5;
List<AlarmaMusical> _alarmas = [];
List<RangoVacaciones> _vacaciones = [];
@@ -27,13 +60,24 @@ class EstadoAlarmas extends ChangeNotifier {
DiagnosticoAlarmasAndroid? _diagnostico;
Timer? _refresco;
Timer? _vigilancia;
StreamSubscription<EventoAlarmaAndroid>? _eventosNativosSub;
final _alarmasVencidasController =
StreamController<AlarmaMusical>.broadcast();
final Set<String> _ejecucionesEmitidas = {};
static const _margenDisparoLocal = Duration(seconds: 45);
// Bounds for _ejecucionesEmitidas (S3-R6): entries older than the
// retention window are pruned; the set never exceeds the cap.
static const _retencionEjecucionesEmitidas = Duration(hours: 24);
@visibleForTesting
static const maxEjecucionesEmitidas = 200;
bool _cargando = false;
String? _error;
/// Last alarm id recorded as MISSED (RES-1): lets the ringing screen
/// detect an external end-of-ring for its own alarm and reconcile.
String? ultimaAlarmaPerdidaId;
List<AlarmaMusical> get alarmas => List.unmodifiable(_alarmas);
List<RangoVacaciones> get vacaciones => List.unmodifiable(_vacaciones);
List<ExcepcionAlarma> get excepciones => List.unmodifiable(_excepciones);
@@ -66,6 +110,7 @@ class EstadoAlarmas extends ChangeNotifier {
);
await _sincronizarTodas();
await cargarDiagnostico();
await cargarFallosNativos();
_activarRefresco();
} catch (e) {
_error = 'No se pudieron cargar las alarmas: $e';
@@ -76,10 +121,33 @@ class EstadoAlarmas extends ChangeNotifier {
}
}
Future<void> guardarAlarma(AlarmaMusical alarma) async {
/// Pure query (freemium-gating spec "Alarm Count Cap At 5"): whether a NEW
/// alarm may be created right now. Counts ALL alarms regardless of
/// `activa` (Spec "6th alarm creation is blocked" — "any enabled state").
/// Always `true` for premium (no cap). Editing an existing id is never
/// subject to this — see [guardarAlarma]'s own new-vs-edit check.
bool puedeCrearAlarma() => _esPremium() || _alarmas.length < maxAlarmasFree;
Future<ResultadoGuardarAlarma> guardarAlarma(AlarmaMusical alarma) async {
// Gate BEFORE any native scheduling attempt (freemium-gating spec "6th
// alarm creation is blocked": "no native scheduling is attempted").
// Editing an alarm that already exists (by id) is NEVER capped — only
// genuinely NEW creation counts against the limit (Spec "Editing an
// existing alarm is unaffected", grandfathering).
final esAlarmaNueva = !_alarmas.any((a) => a.id == alarma.id);
if (esAlarmaNueva && !puedeCrearAlarma()) {
debugPrint(
'[PluriWave][alarmas] guardar bloqueado por limite free id=${alarma.id}',
);
return ResultadoGuardarAlarma.limiteAlcanzado;
}
debugPrint(
'[PluriWave][alarmas] guardar id=${alarma.id} activa=${alarma.activa} hora=${alarma.hora}:${alarma.minuto} tipo=${alarma.tipoProgramacion.name}',
);
// Mutation-while-ringing stop guard (SS-1a/SS-1b): fires BEFORE the save
// persists so an edit/toggle-off of the currently-ringing alarm always
// silences it first.
await _detenerSiEstaSonando(alarma.id);
final config = await servicio.guardarAlarma(alarma);
_aplicar(config);
try {
@@ -89,10 +157,14 @@ class EstadoAlarmas extends ChangeNotifier {
'[PluriWave][alarmas] guardada id=${guardada.id} proxima=${guardada.proximaEjecucion?.toIso8601String()}',
);
await android.programar(guardada);
await _limpiarFalloProgramacion(guardada.id);
await _verificarRegistroNativo(guardada.id);
} catch (e) {
_error = 'Alarma guardada, pero Android no pudo programarla todavía: $e';
await _registrarFalloProgramacion(alarma.id);
}
notifyListeners();
return ResultadoGuardarAlarma.guardada;
}
Future<void> refrescarProgramacion() async {
@@ -116,21 +188,143 @@ class EstadoAlarmas extends ChangeNotifier {
final proxima = alarma.proximaProgramable;
if (proxima == null) return;
final key = '${alarma.id}:${proxima.millisecondsSinceEpoch}';
_ejecucionesEmitidas.add(key);
_registrarEjecucionEmitida(key);
debugPrint(
'[PluriWave][alarmas] ejecucion gestionada id=${alarma.id} proxima=${proxima.toIso8601String()}',
);
}
@visibleForTesting
int get ejecucionesEmitidasLength => _ejecucionesEmitidas.length;
/// Forwards the UI localizations to the native bridge so alarm and station
/// names sent to Android follow the app locale (Decision 3.2 — replaces
/// the old static `ServicioAlarmasAndroid.configurarLocalizaciones`).
void configurarLocalizaciones(AppLocalizations l10n) {
android.configurarLocalizaciones(l10n);
}
Future<void> eliminarAlarma(String id) async {
debugPrint('[PluriWave][alarmas] eliminar id=$id');
final config = await servicio.eliminarAlarma(id);
_aplicar(config);
await android.detenerSonidoNativo(id);
// Deleting the ringing alarm stops audio (SS-1c, regression lock): the
// centralized guard runs before cancelar, same as guardarAlarma.
await _detenerSiEstaSonando(id);
await android.cancelar(id);
notifyListeners();
}
/// Centralized mutation-while-ringing stop guard (Decision 5): every
/// mutation of the currently-ringing alarm routes through this ONE check
/// instead of per-call-site logic, so a mutation of a DIFFERENT (non-
/// ringing) alarm never touches the live ring (SS-1d).
Future<void> _detenerSiEstaSonando(String id) async {
try {
final sonando = await android.alarmaSonandoId();
if (sonando == id) {
await android.detenerSonidoActivo();
}
} catch (e) {
debugPrint('[PluriWave][alarmas] detenerSiEstaSonando ERROR $e');
// Fail-toward-silence (Finding 2, eliminarAlarma regression): a failed
// query must not silently skip the stop when the alarm might genuinely
// be ringing. Fall back to the id-scoped legacy stop (the native side
// no-ops safely on a mismatch) inside its own try/catch so this outer
// flow (guardarAlarma/eliminarAlarma) always proceeds regardless.
try {
await android.detenerSonidoNativo(id);
} catch (fallbackError) {
debugPrint(
'[PluriWave][alarmas] detenerSiEstaSonando fallback ERROR $fallbackError',
);
}
}
}
/// Records a main-alarm scheduling failure per-alarm (fix/alarmas-fallos-
/// silenciosos): before this, a failed `android.programar` call only set
/// the transient, alarm-agnostic [_error] string — the alarms list had no
/// way to mark the SPECIFIC card affected, so a failed alarm rendered
/// exactly like a working one. Never rethrows: a failure recording its own
/// failure must not mask the ORIGINAL scheduling error already captured in
/// [_error].
Future<void> _registrarFalloProgramacion(
String alarmaId, {
String tipo = ExcepcionAlarma.tipoFalloProgramacion,
}) async {
try {
final alarma = _buscarAlarma(alarmaId);
final ejecucion = alarma?.proximaProgramable ?? servicio.ahora();
final config = await servicio.registrarFalloProgramacion(
alarmaId,
ejecucion,
tipo,
);
_aplicar(config);
} catch (e) {
debugPrint('[PluriWave][alarmas] registrar fallo programacion ERROR $e');
}
}
/// Clears a previously recorded scheduling failure once a later attempt
/// for the same alarm succeeds (D5-style recovery, mirroring how [_error]
/// itself already clears on a successful retry). Type-scoped: a
/// successful `android.programar` call only proves the MAIN alarm
/// registration (and, transitively, that any stale post-boot reschedule
/// failure no longer applies) -- it says nothing about the pre-notice or
/// foreground-service subsystems, so those are left untouched here.
Future<void> _limpiarFalloProgramacion(String alarmaId) async {
try {
var config = await servicio.limpiarFalloProgramacion(
alarmaId,
ExcepcionAlarma.tipoFalloProgramacion,
);
_aplicar(config);
config = await servicio.limpiarFalloProgramacion(
alarmaId,
ExcepcionAlarma.tipoFalloReprogramacionArranque,
);
_aplicar(config);
} catch (e) {
debugPrint('[PluriWave][alarmas] limpiar fallo programacion ERROR $e');
}
}
/// Verifies the OS genuinely registered [alarmaId] after a successful
/// `android.programar` call (fix/alarmas-fallos-silenciosos, item 3): a
/// scheduling call that returns without throwing is not proof enough by
/// itself -- this cross-check against the native pending-alarm count is
/// exactly what would have caught the reported "alarm never rings, no
/// exception anywhere" case. Compares a FRESH native count against how
/// many alarms Dart believes are currently active-with-a-next-run; a
/// native count that falls short is recorded as a failure for the alarm
/// the user just interacted with. Never overrides an already-caught
/// programar() exception (this only runs on ITS success path).
Future<void> _verificarRegistroNativo(String alarmaId) async {
try {
final alarma = _buscarAlarma(alarmaId);
if (alarma == null ||
!alarma.activa ||
alarma.proximaProgramable == null) {
return;
}
final diag = await android.diagnostico();
_diagnostico = diag;
final esperadas =
_alarmas
.where((a) => a.activa && a.proximaProgramable != null)
.length;
if (diag.alarmasNativasPendientes < esperadas) {
_error =
'Alarma guardada, pero el sistema no confirma que quedó registrada.';
await _registrarFalloProgramacion(alarmaId);
}
} catch (e) {
debugPrint('[PluriWave][alarmas] verificar registro nativo ERROR $e');
}
}
Future<void> cambiarActiva(AlarmaMusical alarma, bool activa) async {
await guardarAlarma(alarma.copyWith(activa: activa));
}
@@ -162,9 +356,74 @@ class EstadoAlarmas extends ChangeNotifier {
notifyListeners();
}
/// The occurrence that is ACTUALLY ringing right now — the anchor both
/// ring-screen actions (Posponer and Detener) must close.
///
/// It is NEVER a future occurrence. When the native fire works, the
/// fire-time sync advances `proximaEjecucion` to the next one before the
/// user can even reach the ring screen, so taking `proximaEjecucion`
/// unguarded closes an occurrence that has not happened yet. For snooze
/// that showed up as "posponer 3" arming a full day out (observed
/// on-device: tomorrow 23:02). For stop it was worse and silent: the
/// future occurrence was recorded in `ultimaEjecucionGestionada`, which
/// `ServicioProgramacionAlarmas._esValida` then rejects for real — so a
/// Monday-only alarm stopped today simply never rang next Monday, and
/// every sibling alarm outranked it in the "next alarm" banner.
///
/// The candidates, newest first, each gated on "not meaningfully in the
/// future": [AlarmaMusical.snoozeOrigen] (a re-snooze keeps the original
/// anchor), then [AlarmaMusical.proximaEjecucion] (watchdog path: still
/// today's just-due occurrence), then
/// [AlarmaMusical.ultimaEjecucionGestionada] (native-fire path: the sync
/// recorded the ringing occurrence there), then now.
///
/// ONE helper for BOTH callers on purpose. This guard was written for
/// `posponerAlarma` alone (`9c7cf4e`) while `finalizarEjecucion` sat ten
/// lines below with the identical hazard and no guard, and it stayed that
/// way until a user lost a whole week of alarms. Do not re-inline it.
DateTime _ocurrenciaSonando(AlarmaMusical? alarma) =>
_ocurrenciaValida(alarma);
/// How far ahead the PRE-NOTICE notification's occurrence may legitimately
/// sit: it is armed exactly this far before the alarm, so between the
/// reminder appearing and the user tapping it, the occurrence has not
/// happened yet and rejecting it would be wrong.
///
/// Mirrors `AlarmScheduler.PRE_NOTICE_MILLIS` (30 min). Both sides must
/// agree or one of them starts discarding perfectly good anchors.
static const ventanaPreaviso = Duration(minutes: 30);
/// [_ocurrenciaSonando] generalized with a forward allowance, and with an
/// externally-supplied [propuesta] taking priority when it survives the
/// same check.
///
/// [propuesta] is what the NATIVE side reported as the occurrence its
/// notification was about. It is trusted first — it is better evidence than
/// anything reconstructed here — but only after being validated, because it
/// can arrive as a fallback the caller invented (`app.dart` substitutes
/// `alarma.proximaEjecucion` when the native event carries no occurrence,
/// and that field may already point at tomorrow).
DateTime _ocurrenciaValida(
AlarmaMusical? alarma, {
DateTime? propuesta,
Duration margen = Duration.zero,
}) {
final ahora = servicio.ahora();
final limite = ahora.add(
margen + ServicioProgramacionAlarmas.toleranciaDisparoInminente,
);
DateTime? sonando(DateTime? candidata) =>
candidata != null && !candidata.isAfter(limite) ? candidata : null;
return sonando(propuesta) ??
sonando(alarma?.snoozeOrigen) ??
sonando(alarma?.proximaEjecucion) ??
sonando(alarma?.ultimaEjecucionGestionada) ??
ahora;
}
Future<void> posponerAlarma(AlarmaMusical alarma, int minutos) async {
final ejecucion =
alarma.snoozeOrigen ?? alarma.proximaEjecucion ?? DateTime.now();
_error = null;
final ejecucion = _ocurrenciaSonando(alarma);
debugPrint(
'[PluriWave][alarmas] posponer id=${alarma.id} minutos=$minutos ejecucion=${ejecucion.toIso8601String()}',
);
@@ -176,54 +435,132 @@ class EstadoAlarmas extends ChangeNotifier {
);
_aplicar(config);
final actualizada = _buscarAlarma(alarma.id);
if (actualizada != null) {
await android.programar(actualizada);
try {
if (actualizada != null) {
await _solicitarPermisosNecesariosParaAlarma();
await android.programar(actualizada);
await _limpiarFalloProgramacion(alarma.id);
}
} catch (e) {
_error =
'Alarma pospuesta, pero Android no pudo reprogramarla todavía: $e';
await _registrarFalloProgramacion(alarma.id);
}
notifyListeners();
}
/// "Posponer" on the PRE-NOTICE notification.
///
/// Reported on-device: this left the alarm snoozed for 1400+ minutes — a
/// whole day — instead of the configured few. The native lane got its guard
/// in 7054a4c, but Dart runs AFTERWARDS on this path (the receiver's
/// `postponeNext` fires, then `startActivity`, then this) and persists +
/// reschedules, so whatever it computes is the value that survives. It was
/// the last snooze path in the codebase with NO occurrence guard at all:
/// it took [ejecucion] on faith and turned it straight into the next alarm.
///
/// And [ejecucion] is not trustworthy: `app.dart` falls back to
/// `alarma.proximaEjecucion` whenever the native event carries no
/// occurrence, and that field can already point at tomorrow.
///
/// Validated through [_ocurrenciaValida] with a [ventanaPreaviso]
/// allowance — unlike the ringing-screen paths this occurrence legitimately
/// has NOT arrived yet, which is exactly why `_ocurrenciaSonando` could not
/// simply be reused here.
Future<void> posponerProximaDesdePreaviso(
AlarmaMusical alarma,
int minutos,
DateTime ejecucion,
) async {
_error = null;
final seguros = _snoozeSeguro(minutos);
final snoozeHasta = ejecucion.add(Duration(minutes: seguros));
final ocurrencia = _ocurrenciaValida(
alarma,
propuesta: ejecucion,
margen: ventanaPreaviso,
);
final snoozeHasta = ocurrencia.add(Duration(minutes: seguros));
debugPrint(
'[PluriWave][alarmas] posponer desde preaviso id=${alarma.id} minutos=$seguros ejecucion=${ejecucion.toIso8601String()} hasta=${snoozeHasta.toIso8601String()}',
'[PluriWave][alarmas] posponer desde preaviso id=${alarma.id} minutos=$seguros propuesta=${ejecucion.toIso8601String()} ocurrencia=${ocurrencia.toIso8601String()} hasta=${snoozeHasta.toIso8601String()}',
);
await android.ocultarNotificacionAlarma(alarma.id);
final config = await servicio.posponerEjecucionHasta(
alarma.id,
ejecucion,
// The VALIDATED occurrence, not the raw parameter: this becomes both
// `snoozeOrigen` and `ultimaEjecucionGestionada`, so passing the
// unchecked value here would poison the very state a9da855/0430059
// exist to keep clean.
ocurrencia,
snoozeHasta,
);
_aplicar(config);
final actualizada = _buscarAlarma(alarma.id);
if (actualizada != null) {
await android.programar(actualizada);
try {
if (actualizada != null) {
await _solicitarPermisosNecesariosParaAlarma();
await android.programar(actualizada);
await _limpiarFalloProgramacion(alarma.id);
}
} catch (e) {
_error =
'Alarma pospuesta, pero Android no pudo reprogramarla todavía: $e';
await _registrarFalloProgramacion(alarma.id);
}
notifyListeners();
}
Future<void> finalizarEjecucion(String alarmaId) async {
debugPrint('[PluriWave][alarmas] finalizar ejecucion id=$alarmaId');
_error = null;
final alarma = _buscarAlarma(alarmaId);
final ejecucion =
alarma?.snoozeOrigen ??
alarma?.proximaEjecucion ??
alarma?.snoozeHasta ??
DateTime.now();
// Same anchor as posponerAlarma, through the same helper: closing a
// future occurrence here marks it handled, and _esValida then skips it
// for real -- the alarm silently never rings that day. See
// [_ocurrenciaSonando].
final ejecucion = _ocurrenciaSonando(alarma);
await android.ocultarNotificacionAlarma(alarmaId);
// Stop/Snooze Result Verification (SS-2a/SS-2b): the Stop path calls the
// id-agnostic fail-safe stop directly (it always targets whatever is
// ringing). `detenido` reflects the VERIFIED native teardown state
// (activeRingingId cleared same-process after a synchronous stop), not a
// literal dispatch acknowledgement, so a genuine failure is never
// swallowed.
final resultado = await android.detenerSonidoActivo();
if (!resultado.detenido) {
_error = 'No se pudo confirmar que la alarma dejo de sonar.';
}
final config = await servicio.completarEjecucion(alarmaId, ejecucion);
_aplicar(config);
await _sincronizarTodas();
notifyListeners();
}
Future<void> crearRangoVacaciones(RangoVacaciones rango) async {
/// Retryable force-stop affordance (SS-3b): re-invokes the same fail-safe
/// stop; success clears the recorded failure, another failure keeps it.
Future<void> forzarDetencion(String alarmaId) async {
debugPrint('[PluriWave][alarmas] forzar detencion id=$alarmaId');
final resultado = await android.detenerSonidoActivo();
_error =
resultado.detenido
? null
: 'No se pudo detener la alarma. Intentalo de nuevo.';
notifyListeners();
}
/// Full premium gate (freemium-gating spec "Gated Feature Set (Exactly
/// 4)" — alarm vacations, unlike the alarm cap above, are gated entirely,
/// not counted): returns `false` without persisting anything when the
/// caller is free tier.
Future<bool> crearRangoVacaciones(RangoVacaciones rango) async {
if (!_esPremium()) {
debugPrint(
'[PluriWave][alarmas] crear vacaciones bloqueado (free) id=${rango.id}',
);
return false;
}
final nuevos = [..._vacaciones, rango];
await guardarVacaciones(nuevos);
return true;
}
Future<void> eliminarRangoVacaciones(String id) async {
@@ -231,6 +568,82 @@ class EstadoAlarmas extends ChangeNotifier {
await guardarVacaciones(nuevos);
}
/// Issue 1 (feedback-pruebas): replaces the range with the same [id] in
/// place -- the counterpart `crearRangoVacaciones`/`eliminarRangoVacaciones`
/// were missing before this fix, leaving no way to fix a mistake in an
/// already-saved range (including the currently ACTIVE one, since a
/// freshly created range starts active immediately).
Future<void> editarRangoVacaciones(RangoVacaciones rango) async {
final nuevos = [
for (final actual in _vacaciones)
if (actual.id == rango.id) rango else actual,
];
await guardarVacaciones(nuevos);
}
// ── Vacation queries (design ADR-6, WU9) ──────────────────────────────
// Four PURE queries: none writes, none reschedules, none touches the
// native bridge. Read-only over `_alarmas`/`_vacaciones`. Every method
// takes `{DateTime? ahora}` (clock injection) defaulting to
// `DateTime.now()` so tests can pass a fixed instant.
/// Currently-active vacation range, if today falls within one.
/// Delegates to the existing `RangoVacaciones.contiene(fecha)` — which
/// already handles the `activo` flag and day granularity — rather than
/// reimplementing date math (a second implementation is a second set of
/// off-by-one bugs). Callers derive "days remaining" themselves from the
/// returned range's `finDia`, the same way WU8's summary row already
/// does.
RangoVacaciones? rangoVacacionesActivo({DateTime? ahora}) {
final fecha = ahora ?? DateTime.now();
for (final rango in _vacaciones) {
if (rango.contiene(fecha)) return rango;
}
return null;
}
/// Ranges that have not yet started (`inicio > hoy`), soonest-first.
List<RangoVacaciones> vacacionesProximas({DateTime? ahora}) {
final fecha = ahora ?? DateTime.now();
final hoy = DateTime(fecha.year, fecha.month, fecha.day);
return _vacaciones.where((rango) => rango.inicioDia.isAfter(hoy)).toList()
..sort((a, b) => a.inicioDia.compareTo(b.inicioDia));
}
/// Ranges whose end date has already passed (`fin < hoy`), most-recently-
/// ended first.
List<RangoVacaciones> vacacionesPasadas({DateTime? ahora}) {
final fecha = ahora ?? DateTime.now();
final hoy = DateTime(fecha.year, fecha.month, fecha.day);
return _vacaciones.where((rango) => rango.finDia.isBefore(hoy)).toList()
..sort((a, b) => b.finDia.compareTo(a.finDia));
}
/// Per-alarm pause impact for [rango]. Mirrors
/// `ServicioProgramacionAlarmas`'s own pause predicate EXACTLY
/// (`servicio_programacion_alarmas.dart`:
/// `!alarma.sonarEnVacaciones && estaEnVacaciones(candidato, vacaciones)`)
/// — if these two ever diverge, the Vacaciones screen lies about which
/// alarms are paused. [rango] is accepted for API symmetry with the
/// other 3 queries above; the predicate itself needs no dates because it
/// only makes sense to call this for a range that IS currently active —
/// any alarm actually paused by it already has `sonarEnVacaciones ==
/// false`, which is exactly what the scheduler itself would have used to
/// skip that alarm's candidate occurrence.
ImpactoVacaciones impactoDeRango(RangoVacaciones rango) {
final pausadas = <AlarmaMusical>[];
final noAfectadas = <AlarmaMusical>[];
for (final alarma in _alarmas) {
if (!alarma.activa) continue;
if (alarma.sonarEnVacaciones) {
noAfectadas.add(alarma);
} else {
pausadas.add(alarma);
}
}
return ImpactoVacaciones(pausadas: pausadas, noAfectadas: noAfectadas);
}
ExcepcionAlarma? ultimaExcepcionPara(String alarmaId) {
final candidatas =
_excepciones.where((e) => e.alarmaId == alarmaId).toList()
@@ -248,21 +661,228 @@ class EstadoAlarmas extends ChangeNotifier {
notifyListeners();
}
/// Drains the failures the NATIVE side recorded on its own and turns each
/// into a per-alarm exception, so the card can mark it.
///
/// These three paths used to log to logcat and stop there: a pre-notice
/// that could not be armed, a refused foreground-service start when the
/// alarm should have rung, and a per-alarm reschedule that failed after a
/// reboot. None of them run inside a Dart call, so nothing on this side
/// ever learned they happened — an alarm could sit switched on in the
/// list having never reached the OS. Reading them at startup is what
/// makes the reported "as if there were no alarm" visible.
///
/// Deliberately tolerant: a failed read is logged and swallowed, never
/// surfaced as an alarm error, because a diagnostics gap must not look
/// like a scheduling problem.
Future<void> cargarFallosNativos() async {
try {
final fallos = await android.fallosNativosProgramacion();
for (final fallo in fallos) {
await _registrarFalloProgramacion(fallo.alarmaId, tipo: fallo.tipo);
}
if (fallos.isNotEmpty) {
debugPrint(
'[PluriWave][alarmas] fallos nativos recogidos=${fallos.length}',
);
}
} catch (e) {
debugPrint('[PluriWave][alarmas] cargar fallos nativos ERROR $e');
}
}
/// Records a snooze the native layer performed by itself (Decision 2.1).
/// The native scheduler already re-registered setAlarmClock, so this only
/// persists the canonical state — it MUST NOT call android.programar again.
Future<void> _alRecibirEventoNativo(EventoAlarmaAndroid evento) async {
if (evento.accion == EventoAlarmaAndroid.accionSnoozeCancelled) {
await _registrarCancelacionSnoozeNativa(evento);
return;
}
if (evento.accion == EventoAlarmaAndroid.accionMissed) {
await _registrarEjecucionPerdida(evento);
return;
}
if (evento.accion != EventoAlarmaAndroid.accionSnoozed) return;
if (evento.alarmaId.isEmpty || evento.snoozeUntilMillis <= 0) return;
final hasta = DateTime.fromMillisecondsSinceEpoch(evento.snoozeUntilMillis);
final origen =
evento.occurrenceAtMillis > 0
? DateTime.fromMillisecondsSinceEpoch(evento.occurrenceAtMillis)
: hasta.subtract(Duration(minutes: evento.snoozeMinutes));
debugPrint(
'[PluriWave][alarmas] snooze nativo id=${evento.alarmaId} hasta=${hasta.toIso8601String()}',
);
try {
final config = await servicio.posponerEjecucionHasta(
evento.alarmaId,
origen,
hasta,
);
_aplicar(config);
notifyListeners();
} catch (e) {
debugPrint('[PluriWave][alarmas] snooze nativo ERROR $e');
}
}
/// Mirrors a native snooze cancellation ("Detener" on the countdown
/// notification). The native scheduler already advanced to the next normal
/// occurrence, so this only clears the snooze in the canonical config and
/// MUST NOT call android.programar again (would double-schedule).
Future<void> _registrarCancelacionSnoozeNativa(
EventoAlarmaAndroid evento,
) async {
if (evento.alarmaId.isEmpty) return;
final origen =
evento.occurrenceAtMillis > 0
? DateTime.fromMillisecondsSinceEpoch(evento.occurrenceAtMillis)
: DateTime.now();
debugPrint(
'[PluriWave][alarmas] snooze cancelado nativo id=${evento.alarmaId} origen=${origen.toIso8601String()}',
);
try {
final config = await servicio.completarEjecucion(evento.alarmaId, origen);
_aplicar(config);
notifyListeners();
} catch (e) {
debugPrint('[PluriWave][alarmas] cancelar snooze nativo ERROR $e');
}
}
/// Records a native auto-silence (MISSED) transition (Phase 6): the native
/// scheduler already rearmed the next occurrence (repeating) or left it
/// disabled (one-shot) at fire time, so this only marks the occurrence
/// handled -- it MUST NOT call android.programar again.
Future<void> _registrarEjecucionPerdida(EventoAlarmaAndroid evento) async {
if (evento.alarmaId.isEmpty) return;
final origen =
evento.occurrenceAtMillis > 0
? DateTime.fromMillisecondsSinceEpoch(evento.occurrenceAtMillis)
: DateTime.now();
debugPrint(
'[PluriWave][alarmas] ejecucion perdida id=${evento.alarmaId} origen=${origen.toIso8601String()}',
);
try {
final config = await servicio.completarEjecucion(evento.alarmaId, origen);
_aplicar(config);
ultimaAlarmaPerdidaId = evento.alarmaId;
notifyListeners();
} catch (e) {
debugPrint('[PluriWave][alarmas] ejecucion perdida ERROR $e');
}
}
Future<void> _sincronizarEjecucionesGestionadasPorAndroid() async {
try {
final ejecuciones = await android.obtenerEjecucionesNativasGestionadas();
if (ejecuciones.isEmpty) return;
final config = await servicio.sincronizarEjecucionesNativas({
for (final ejecucion in ejecuciones)
ejecucion.alarmaId: ejecucion.gestionadaEn,
});
_aplicar(config);
debugPrint(
'[PluriWave][alarmas] sincronizadas ejecuciones nativas count=${ejecuciones.length}',
);
if (ejecuciones.isNotEmpty) {
final config = await servicio.sincronizarEjecucionesNativas({
for (final ejecucion in ejecuciones)
ejecucion.alarmaId: ejecucion.gestionadaEn,
});
_aplicar(config);
debugPrint(
'[PluriWave][alarmas] sincronizadas ejecuciones nativas count=${ejecuciones.length}',
);
}
} catch (e) {
debugPrint('[PluriWave][alarmas] sincronizar nativas ERROR $e');
}
await _importarSnoozesNativosActivos();
await _importarFallosProgramacionNativos();
}
/// Cold-start sync (fix/alarmas-fallos-silenciosos, item 2): imports
/// scheduling-reliability failures the NATIVE side recorded on its own --
/// a pre-notice `SecurityException`, a refused foreground-service start,
/// or a per-alarm reschedule failure after boot/unlock -- none of which
/// ever go through a Dart method-channel call that could throw. Without
/// this sync, these three failures stayed invisible forever (only
/// logcat), even after this app-launch fix reads them.
Future<void> _importarFallosProgramacionNativos() async {
try {
final fallos = await android.obtenerFallosProgramacionNativos();
final reportadoPorAlarma = {
for (final fallo in fallos) fallo.alarmaId: fallo,
};
// Reconcile stale copies: the native side clears its OWN record the
// next time that specific subsystem succeeds (pre-notice/foreground-
// service), so an alarm previously imported with one of those tipos
// that is no longer reported here means it already recovered --
// without this, the card would keep showing a problem that fixed
// itself. `tipoFalloProgramacion`/`tipoFalloReprogramacionArranque`
// are NOT reconciled here -- those already clear on the Dart side's
// own successful `android.programar` calls.
for (final alarma in _alarmas) {
final actual = ultimaExcepcionPara(alarma.id);
final esTipoReconciliable =
actual != null &&
(actual.tipo == ExcepcionAlarma.tipoFalloPreaviso ||
actual.tipo == ExcepcionAlarma.tipoFalloServicioSonido);
if (esTipoReconciliable && !reportadoPorAlarma.containsKey(alarma.id)) {
final config = await servicio.limpiarFalloProgramacion(
alarma.id,
actual.tipo,
);
_aplicar(config);
}
}
for (final fallo in fallos) {
final config = await servicio.registrarFalloProgramacion(
fallo.alarmaId,
fallo.ocurridoEn,
fallo.tipo,
);
_aplicar(config);
}
if (fallos.isNotEmpty) {
debugPrint(
'[PluriWave][alarmas] fallos nativos importados count=${fallos.length}',
);
}
} catch (e) {
debugPrint('[PluriWave][alarmas] importar fallos nativos ERROR $e');
}
}
/// Cold-start half of Decision 2.1: imports snoozes the native scheduler
/// performed while the Flutter engine was dead, before any recalculation
/// could erase them.
Future<void> _importarSnoozesNativosActivos() async {
try {
final snoozes = await android.obtenerEstadoSnoozeNativo();
if (snoozes.isEmpty) return;
final ahora = DateTime.now();
var config = await servicio.cargar();
var huboCambios = false;
for (final snooze in snoozes) {
if (!snooze.snoozeHasta.isAfter(ahora)) continue;
AlarmaMusical? alarma;
for (final candidata in config.alarmas) {
if (candidata.id == snooze.alarmaId) {
alarma = candidata;
break;
}
}
if (alarma == null || !alarma.activa) continue;
if (alarma.snoozeHasta == snooze.snoozeHasta) continue;
config = await servicio.posponerEjecucionHasta(
snooze.alarmaId,
snooze.snoozeOrigen,
snooze.snoozeHasta,
);
huboCambios = true;
}
if (huboCambios) {
_aplicar(config);
debugPrint(
'[PluriWave][alarmas] snoozes nativos importados count=${snoozes.length}',
);
}
} catch (e) {
debugPrint('[PluriWave][alarmas] importar snoozes nativos ERROR $e');
}
}
Future<void> _solicitarPermisosNecesariosParaAlarma() async {
@@ -278,11 +898,21 @@ class EstadoAlarmas extends ChangeNotifier {
if (!diag.puedeUsarPantallaCompleta) {
await android.solicitarPermisoPantallaCompleta();
}
if (!diag.ignoraOptimizacionBateria) {
await _solicitarExencionBateriaUnaVez();
}
} catch (e) {
debugPrint('[PluriWave][alarmas] permisos android ERROR $e');
}
}
Future<void> _solicitarExencionBateriaUnaVez() async {
final prefs = _prefs ?? await SharedPreferences.getInstance();
if (prefs.getBool(_keyExencionBateriaSolicitada) ?? false) return;
await android.solicitarExencionBateria();
await prefs.setBool(_keyExencionBateriaSolicitada, true);
}
Future<void> _sincronizarTodas() async {
debugPrint(
'[PluriWave][alarmas] sincronizar todas count=${_alarmas.length}',
@@ -290,8 +920,24 @@ class EstadoAlarmas extends ChangeNotifier {
if (_alarmas.any((alarma) => alarma.activa)) {
await _solicitarPermisosNecesariosParaAlarma();
}
// Per-alarm try/catch (fix/alarmas-fallos-silenciosos): before this, a
// SINGLE alarm's `programar` throw aborted the whole loop, so every
// sibling AFTER the failing one in `_alarmas` silently never reached
// `android.programar` on this pass -- on a fresh launch (`inicializar`)
// that meant some active alarms were never (re)armed with the OS at all,
// with nothing to show for it beyond a generic load error. Each alarm
// now gets its own outcome recorded, and one failure never blocks its
// siblings.
for (final alarma in _alarmas) {
await android.programar(alarma);
try {
await android.programar(alarma);
await _limpiarFalloProgramacion(alarma.id);
} catch (e) {
debugPrint(
'[PluriWave][alarmas] sincronizar todas ERROR id=${alarma.id} $e',
);
await _registrarFalloProgramacion(alarma.id);
}
}
}
@@ -325,6 +971,7 @@ class EstadoAlarmas extends ChangeNotifier {
void _vigilarAlarmasVencidas() {
final ahora = DateTime.now();
_depurarEjecucionesEmitidas(ahora);
for (final alarma in _alarmas) {
final proxima = alarma.proximaProgramable;
if (!alarma.activa || proxima == null) continue;
@@ -332,13 +979,13 @@ class EstadoAlarmas extends ChangeNotifier {
final key = '${alarma.id}:${proxima.millisecondsSinceEpoch}';
final retraso = ahora.difference(proxima);
if (retraso > _margenDisparoLocal) {
_ejecucionesEmitidas.add(key);
_registrarEjecucionEmitida(key);
debugPrint(
'[PluriWave][alarmas] vencida local ignorada por antigua id=${alarma.id} proxima=${proxima.toIso8601String()} retraso=${retraso.inSeconds}s',
);
continue;
}
if (_ejecucionesEmitidas.add(key)) {
if (_registrarEjecucionEmitida(key)) {
debugPrint(
'[PluriWave][alarmas] vencida local id=${alarma.id} proxima=${proxima.toIso8601String()}',
);
@@ -347,10 +994,42 @@ class EstadoAlarmas extends ChangeNotifier {
}
}
/// Adds a `alarmId:millis` key and keeps the set bounded (S3-R6).
/// Returns whether the key was newly added (fire-dedup contract).
bool _registrarEjecucionEmitida(String key) {
final agregada = _ejecucionesEmitidas.add(key);
_depurarEjecucionesEmitidas(DateTime.now());
return agregada;
}
void _depurarEjecucionesEmitidas(DateTime ahora) {
final limite =
ahora.subtract(_retencionEjecucionesEmitidas).millisecondsSinceEpoch;
_ejecucionesEmitidas.removeWhere((key) => _millisDeEjecucion(key) < limite);
if (_ejecucionesEmitidas.length <= maxEjecucionesEmitidas) return;
// Still over the cap: evict the oldest occurrences first. Pruned keys
// cannot re-fire because occurrences beyond _margenDisparoLocal are
// ignored by _vigilarAlarmasVencidas anyway.
final ordenadas =
_ejecucionesEmitidas.toList()..sort(
(a, b) => _millisDeEjecucion(a).compareTo(_millisDeEjecucion(b)),
);
_ejecucionesEmitidas.removeAll(
ordenadas.take(_ejecucionesEmitidas.length - maxEjecucionesEmitidas),
);
}
int _millisDeEjecucion(String key) {
final separador = key.lastIndexOf(':');
if (separador < 0) return 0;
return int.tryParse(key.substring(separador + 1)) ?? 0;
}
@override
void dispose() {
_refresco?.cancel();
_vigilancia?.cancel();
_eventosNativosSub?.cancel();
_alarmasVencidasController.close();
super.dispose();
}
+256
View File
@@ -0,0 +1,256 @@
import 'dart:ui' show Locale, PlatformDispatcher;
import 'package:flutter/foundation.dart';
import 'package:geocoding/geocoding.dart';
import 'package:geolocator/geolocator.dart';
import '../l10n/gen/app_localizations.dart';
import '../modelos/emisora.dart';
import '../modelos/pais_radio.dart';
import '../servicios/servicio_radio.dart';
import 'orden_emisoras.dart';
/// Search state extracted from `EstadoRadio` (S4-R3).
///
/// Owns the search query/filters, paged results, the nearby-stations lookup
/// and every loading flag. Notifies ONLY its own listeners so search activity
/// never rebuilds `EstadoRadio` consumers (S4-R5).
class EstadoBusqueda extends ChangeNotifier {
EstadoBusqueda({
required this.radio,
OrdenEmisoras Function()? ordenListas,
AppLocalizations Function()? textos,
void Function(String mensaje)? alError,
}) : _ordenListas = ordenListas ?? (() => OrdenEmisoras.calidad),
_textos = textos ?? (() => lookupAppLocalizations(const Locale('es'))),
_alError = alError;
static const int _tamanoPagina = 30;
static const int _maxResultadosEnMemoria = 180;
final ServicioRadio radio;
/// Current list ordering, owned by EstadoRadio (user preference).
final OrdenEmisoras Function() _ordenListas;
final AppLocalizations Function() _textos;
/// User-visible error sink (EstadoRadio routes it to its snackbar stream).
final void Function(String mensaje)? _alError;
List<Emisora> _resultados = [];
List<Emisora> _cercanas = [];
bool _cargando = false;
bool _cargandoMas = false;
bool _hayMas = true;
bool _cargandoCercanas = false;
String? _paisCercanoDetectado;
String? _errorCercanas;
int _offset = 0;
String? _ultimoNombre;
String? _ultimoPais;
String? _ultimoIdioma;
String? _ultimoTag;
int? _ultimoMinBitrate;
List<PaisRadio> _paises = [];
bool _cargandoPaises = false;
final _memoResultados = MemoLista<Emisora>();
final _memoCercanas = MemoLista<Emisora>();
List<Emisora> get resultados => _memoResultados.obtener([
_resultados,
_ordenListas(),
], () => ordenarEmisoras(_resultados, _ordenListas()));
List<Emisora> get cercanas => _memoCercanas.obtener([
_cercanas,
_ordenListas(),
], () => ordenarEmisoras(_cercanas, _ordenListas()));
bool get cargando => _cargando;
bool get cargandoMas => _cargandoMas;
bool get hayMas => _hayMas;
bool get cargandoCercanas => _cargandoCercanas;
String? get paisCercanoDetectado => _paisCercanoDetectado;
String? get errorCercanas => _errorCercanas;
List<PaisRadio> get paises => _paises;
bool get cargandoPaises => _cargandoPaises;
/// Re-renders sorted views after the user changes the list ordering
/// (called by EstadoRadio, which owns that preference).
void notificarCambioOrden() => notifyListeners();
Future<void> buscar({
String? nombre,
String? pais,
String? idioma,
String? tag,
int? minBitrate,
}) async {
_ultimoNombre = nombre;
_ultimoPais = pais;
_ultimoIdioma = idioma;
_ultimoTag = tag;
_ultimoMinBitrate = minBitrate;
_offset = 0;
_hayMas = true;
_cargando = true;
_resultados = [];
notifyListeners();
try {
final pagina = await _buscarPaginaFiltrada(
nombre: nombre,
pais: pais,
idioma: idioma,
tag: tag,
minBitrate: minBitrate,
);
_resultados = pagina;
} catch (_) {
_alError?.call(_textos().radioSearchError);
} finally {
_cargando = false;
notifyListeners();
}
}
Future<void> cargarMas() async {
if (_cargando || _cargandoMas || !_hayMas) return;
_cargandoMas = true;
notifyListeners();
try {
final pagina = await _buscarPaginaFiltrada(
nombre: _ultimoNombre,
pais: _ultimoPais,
idioma: _ultimoIdioma,
tag: _ultimoTag,
minBitrate: _ultimoMinBitrate,
);
final porUuid = <String, Emisora>{
for (final emisora in _resultados) emisora.uuid: emisora,
};
for (final emisora in pagina) {
porUuid[emisora.uuid] = emisora;
}
var nuevaLista = porUuid.values.toList();
if (nuevaLista.length > _maxResultadosEnMemoria) {
nuevaLista = nuevaLista.sublist(
nuevaLista.length - _maxResultadosEnMemoria,
);
}
_resultados = nuevaLista;
// _buscarPaginaFiltrada actualiza offset/hayMas usando páginas crudas.
_hayMas = _hayMas && pagina.isNotEmpty;
} catch (_) {
_alError?.call(_textos().radioLoadMoreStationsError);
} finally {
_cargandoMas = false;
notifyListeners();
}
}
Future<List<Emisora>> _buscarPaginaFiltrada({
String? nombre,
String? pais,
String? idioma,
String? tag,
int? minBitrate,
}) async {
final acumuladas = <Emisora>[];
var intentos = 0;
while (intentos < 4 && acumuladas.isEmpty && _hayMas) {
final pagina = await radio.buscar(
nombre: nombre,
pais: pais,
idioma: idioma,
tag: tag,
limit: _tamanoPagina,
offset: _offset,
);
_offset += pagina.length;
_hayMas = pagina.length == _tamanoPagina;
acumuladas.addAll(_filtrarMinBitrate(pagina, minBitrate));
intentos++;
}
return acumuladas;
}
List<Emisora> _filtrarMinBitrate(List<Emisora> emisoras, int? minBitrate) {
if (minBitrate == null || minBitrate <= 0) return emisoras;
return emisoras.where((e) => (e.bitrate ?? 0) >= minBitrate).toList();
}
Future<void> cargarEmisorasCercanas() async {
_cargandoCercanas = true;
_errorCercanas = null;
notifyListeners();
try {
var pais = PlatformDispatcher.instance.locale.countryCode;
final servicioActivo = await Geolocator.isLocationServiceEnabled();
if (servicioActivo) {
var permiso = await Geolocator.checkPermission();
if (permiso == LocationPermission.denied) {
permiso = await Geolocator.requestPermission();
}
if (permiso == LocationPermission.always ||
permiso == LocationPermission.whileInUse) {
final posicion = await Geolocator.getCurrentPosition(
locationSettings: const LocationSettings(
accuracy: LocationAccuracy.low,
timeLimit: Duration(seconds: 8),
),
);
final marcas = await placemarkFromCoordinates(
posicion.latitude,
posicion.longitude,
);
if (marcas.isNotEmpty) {
pais = marcas.first.isoCountryCode ?? pais;
}
}
}
if (pais == null || pais.isEmpty) {
throw StateError('nearby-region-not-detected');
}
_paisCercanoDetectado = pais;
_cercanas = _filtrarMinBitrate(
await radio.buscar(pais: pais, limit: 30),
_ultimoMinBitrate,
);
} catch (_) {
_errorCercanas = _textos().radioNearbyStationsError;
_cercanas = [];
} finally {
_cargandoCercanas = false;
notifyListeners();
}
}
/// Fetches the Países browser's country list (WU7, `station-discovery-browse`
/// spec). In-memory cache guard: once populated, re-entering the screen
/// does not refetch — this is deliberately NOT time-based invalidation,
/// since the country/station-count universe changes on a scale of days,
/// not per app session.
///
/// Sorted once here (case-insensitive by name, same convention as
/// `ordenarEmisoras`'s `OrdenEmisoras.nombre` case) since the API orders by
/// raw byte order, not proper collation (design ADR-4).
Future<void> cargarPaises() async {
if (_paises.isNotEmpty || _cargandoPaises) return;
_cargandoPaises = true;
notifyListeners();
try {
// Defensive copy: `radio.obtenerPaises()` makes no growable/mutable
// guarantee about the list it returns (tests may hand back a `const`
// list) — sorting in place would throw on an unmodifiable list.
final ordenados = List<PaisRadio>.of(await radio.obtenerPaises())..sort(
(a, b) => a.nombre.toLowerCase().compareTo(b.nombre.toLowerCase()),
);
_paises = ordenados;
} catch (_) {
_alError?.call(_textos().radioCountriesError);
} finally {
_cargandoPaises = false;
notifyListeners();
}
}
}
+790
View File
@@ -0,0 +1,790 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import '../modelos/dispositivo_audio.dart';
import '../modelos/preset_ecualizador.dart';
import '../servicios/servicio_audio.dart';
import '../servicios/servicio_dispositivo_audio.dart';
import '../servicios/servicio_ecualizador.dart';
import '../servicios/servicio_presets_personalizados.dart';
/// Equalizer state extracted from `EstadoRadio` (S4-R1).
///
/// Owns the main preset, the per-station preset map, the current (applied)
/// preset and the enabled flag, plus their persistence through
/// [ServicioEcualizador] and their application through [ServicioAudio].
/// Notifies ONLY its own listeners — EQ changes must not rebuild
/// `EstadoRadio` consumers (S4-R1-A, S4-R5).
///
/// Multi-device EQ extension (Phase 5): when [eqMultiDeviceEnabled] is true,
/// resolves the active preset through a 4-level hierarchy:
/// 1. presetsMatriz["stationUuid:deviceId"]
/// 2. presetsEmisoraMap[stationUuid]
/// 3. presetsDispositivo[deviceId]
/// 4. presetPrincipal
///
/// When the toggle is false, resolution falls back to the original 2-level
/// hierarchy (station → global) — zero behavioral change vs. prior releases.
class EstadoEcualizador extends ChangeNotifier {
EstadoEcualizador({
required this.audio,
ServicioEcualizador? servicio,
ServicioDispositivoAudio? dispositivoAudio,
ServicioPresetsPersonalizados? presetsPersonalizadosService,
String? Function()? emisoraActualUuid,
}) : servicio = servicio ?? ServicioEcualizador(),
_presetsPersonalizadosService =
presetsPersonalizadosService ?? ServicioPresetsPersonalizados(),
_dispositivoAudio = dispositivoAudio,
_emisoraActualUuid = emisoraActualUuid ?? (() => null) {
_escucharCambiosEqDesdeHandler();
}
final ServicioAudio audio;
final ServicioEcualizador servicio;
final ServicioDispositivoAudio? _dispositivoAudio;
/// Persistence for user-named custom presets (design ADR-5 hazard box).
/// Deliberately a SEPARATE service/key from [servicio] — see
/// [cargarPresetsPersonalizados] for why its load is not folded into
/// [cargarPersistido].
final ServicioPresetsPersonalizados _presetsPersonalizadosService;
/// Callback into the owner (EstadoRadio) for the currently playing station;
/// keeps this notifier free of any station-list coupling.
final String? Function() _emisoraActualUuid;
final Map<String, PresetEcualizador> _presetsEmisoraMap = {};
/// Per-device presets: deviceId → PresetEcualizador.
final Map<String, PresetEcualizador> _presetsDispositivo = {};
/// Matrix presets: "stationUuid:deviceId" → PresetEcualizador.
final Map<String, PresetEcualizador> _presetsMatriz = {};
/// Custom display names for devices: deviceId → custom name.
final Map<String, String> _nombresDispositivos = {};
/// Last-seen platform (Bluetooth/productName) name per deviceId.
///
/// In-memory only (bt-device-identity ADR-4) — NOT persisted. Devices
/// re-report their name on every enumeration, so this cache self-heals
/// every session without needing a SharedPreferences key or migration.
final Map<String, String> _nombresPlataforma = {};
/// User-named custom presets (WU13). Loaded explicitly via
/// [cargarPresetsPersonalizados], not as part of [cargarPersistido] —
/// see that method's doc for why.
List<PresetEcualizador> _presetsPersonalizados = [];
PresetEcualizador _presetPrincipal = PresetEcualizador.flat;
PresetEcualizador _presetActual = PresetEcualizador.flat;
bool _activo = true;
bool _eqMultiDeviceEnabled = false;
String? _dispositivoActualId;
StreamSubscription<DispositivoAudio>? _deviceSub;
Future<void>? _refrescoEnCurso;
/// Catches a car/notification-initiated EQ change that bypasses this
/// class entirely (eq-sync-superficies): `accionEqToggle` calls
/// `PluriWaveAudioHandler.setEcualizadorActivo` directly, and
/// `seleccionarPresetEqPorMediaId` calls `aplicarPreset` directly — both
/// mutate ONLY the handler's own `_ecualizadorActivo`/`_presetActual`
/// fields, never [audio]'s owner ([EstadoEcualizador]). Mirrors the exact
/// shape `EstadoRadio._escucharErroresReproduccion` already uses for the
/// equivalent `playFromMediaId` gap: on every [ServicioAudio.estadoStream]
/// tick (which the handler already re-emits on any EQ change via
/// `_actualizarControlesEq()`, regardless of who triggered it), compare
/// the handler's current EQ state against our cached copy and adopt it on
/// divergence.
///
/// Since eq-estado-unico this is a DISPLAY concern only. The handler owns
/// the flag and persists it itself, so this subscription no longer closes
/// a persistence gap — it just keeps the phone's toggle showing what the
/// engine is really doing. It also cannot be the fix on its own: it exists
/// only while an [EstadoEcualizador] does, and the headless Android Auto
/// engine that produced the bug report never builds one.
StreamSubscription<EstadoReproduccion>? _suscripcionEstadoAudioEq;
PresetEcualizador get presetActual => _presetActual;
PresetEcualizador get presetPrincipal => _presetPrincipal;
bool get activo => _activo;
bool get disponible => audio.ecualizadorDisponible;
bool get eqMultiDeviceEnabled => _eqMultiDeviceEnabled;
String? get dispositivoActualId => _dispositivoActualId;
Map<String, String> get nombresDispositivos =>
Map.unmodifiable(_nombresDispositivos);
Map<String, PresetEcualizador> get presetsPorEmisora =>
Map.unmodifiable(_presetsEmisoraMap);
Map<String, PresetEcualizador> get presetsDispositivo =>
Map.unmodifiable(_presetsDispositivo);
Map<String, PresetEcualizador> get presetsMatriz =>
Map.unmodifiable(_presetsMatriz);
List<PresetEcualizador> get presetsPersonalizados =>
List.unmodifiable(_presetsPersonalizados);
bool get emisoraActualTienePresetPropio {
final uuid = _emisoraActualUuid();
if (uuid == null) return false;
return tienePresetPorEmisora(uuid);
}
bool tienePresetPorEmisora(String uuid) =>
_presetsEmisoraMap.containsKey(uuid);
PresetEcualizador? presetPorEmisora(String uuid) => _presetsEmisoraMap[uuid];
PresetEcualizador presetParaEmisora(String uuid) =>
_presetsEmisoraMap[uuid] ?? _presetPrincipal;
/// 4-level resolution hierarchy (ADR-4, spec requirement).
///
/// When [eqMultiDeviceEnabled] is false, falls back to 2-level (station → global).
PresetEcualizador presetEfectivo({
required String stationUuid,
required String deviceId,
}) {
if (!_eqMultiDeviceEnabled) {
// Original 2-level behavior: station → global.
return _presetsEmisoraMap[stationUuid] ?? _presetPrincipal;
}
// Level 1: station × device matrix.
final matrizKey = '$stationUuid:$deviceId';
if (_presetsMatriz.containsKey(matrizKey)) {
return _presetsMatriz[matrizKey]!;
}
// Level 2: station-only.
if (_presetsEmisoraMap.containsKey(stationUuid)) {
return _presetsEmisoraMap[stationUuid]!;
}
// Level 3: device-only.
if (_presetsDispositivo.containsKey(deviceId)) {
return _presetsDispositivo[deviceId]!;
}
// Level 4: global fallback.
return _presetPrincipal;
}
/// Resolves the effective preset for the current station and device.
PresetEcualizador _resolverPresetActivo() {
final uuid = _emisoraActualUuid();
if (!_eqMultiDeviceEnabled) {
return uuid != null
? (_presetsEmisoraMap[uuid] ?? _presetPrincipal)
: _presetPrincipal;
}
final deviceId = _dispositivoActualId;
if (uuid == null || deviceId == null) {
return _presetPrincipal;
}
return presetEfectivo(stationUuid: uuid, deviceId: deviceId);
}
/// Loads the persisted EQ configuration and applies it to the audio engine.
Future<void> cargarPersistido() async {
try {
final config = await servicio.cargar();
_presetPrincipal = config.principal;
_activo = config.activo;
_eqMultiDeviceEnabled = config.eqMultiDeviceEnabled;
_presetsEmisoraMap
..clear()
..addAll(config.porEmisora);
_presetsDispositivo
..clear()
..addAll(config.presetsDispositivo);
_presetsMatriz
..clear()
..addAll(config.presetsMatriz);
_nombresDispositivos
..clear()
..addAll(config.nombresDispositivos);
// Resolve active preset and apply it.
_presetActual = _resolverPresetActivo();
await audio.setEcualizadorActivo(_activo);
await audio.aplicarPreset(_presetActual);
// Subscribe to device changes only when toggle is on.
_configurarSuscripcionDispositivo();
// Seed current device immediately so presets resolve before first event.
if (_eqMultiDeviceEnabled) {
await _sembrarNombresEmparejados();
await _sembrarDispositivoActual();
}
} catch (_) {
_presetPrincipal = PresetEcualizador.flat;
_presetActual = PresetEcualizador.flat;
_activo = true;
_eqMultiDeviceEnabled = false;
_presetsEmisoraMap.clear();
_presetsDispositivo.clear();
_presetsMatriz.clear();
_nombresDispositivos.clear();
}
}
/// Fills [_nombresPlataforma] from the system's paired-device list.
///
/// A Bluetooth device only reports its own name while it is connected, so
/// without this a device the user never renamed shows its raw id whenever it
/// is switched off — which is most of the time. The bond list is the system's
/// own record and survives disconnection.
///
/// Seeded BEFORE [_sembrarDispositivoActual] so a live enumeration name (the
/// fresher of the two) overwrites the paired one rather than the reverse.
/// Never throws: a device with no resolvable name just falls back to its id.
Future<void> _sembrarNombresEmparejados() async {
final svc = _dispositivoAudio;
if (svc == null) return;
try {
final emparejados = await svc.obtenerNombresEmparejados();
for (final entry in emparejados.entries) {
if (entry.value.isEmpty) continue;
_nombresPlataforma['bt_a2dp:${entry.key}'] = entry.value;
}
} catch (_) {
// Permission denied or no adapter: keep whatever names we already have.
}
}
/// Queries the current device and seeds [_dispositivoActualId] without
/// waiting for a stream event. Falls back to `'builtin_speaker'` on error.
Future<void> _sembrarDispositivoActual() async {
final svc = _dispositivoAudio;
if (svc == null) return;
try {
final dispositivo = await svc.obtenerDispositivoActual();
await _onDispositivoCambiado(dispositivo);
} catch (_) {
_dispositivoActualId = 'builtin_speaker';
}
}
/// Re-syncs the active device after a possibly-missed native resync
/// (activity recreation over the cached engine, return to foreground,
/// opening the settings section): re-subscribes the platform event channel
/// via [ServicioDispositivoAudio.resubscribir] — re-registering the native
/// callback on the CURRENT activity — and re-seeds [_dispositivoActualId]
/// with a fresh query. No-op when the multi-device toggle is off or no
/// device service is injected; safe to call repeatedly. Concurrent calls
/// (app-resume observer + settings initState) share the same in-flight
/// refresh instead of racing [ServicioDispositivoAudio.resubscribir],
/// which would leak a native AudioDeviceCallback.
Future<void> refrescarDispositivoActual() {
final enCurso = _refrescoEnCurso;
if (enCurso != null) return enCurso;
final refresco = _refrescarDispositivoActual().whenComplete(() {
_refrescoEnCurso = null;
});
_refrescoEnCurso = refresco;
return refresco;
}
Future<void> _refrescarDispositivoActual() async {
if (!_eqMultiDeviceEnabled) return;
final svc = _dispositivoAudio;
if (svc == null) return;
try {
await svc.resubscribir();
} catch (_) {
// A failed resubscribe must never block the fresh-query re-seed below.
}
// Re-read the bond list too: the user may have paired or renamed a device
// in system settings since the app started, and this runs right as the
// device list becomes visible.
await _sembrarNombresEmparejados();
await _sembrarDispositivoActual();
}
/// Subscribes to the device change stream if multi-device is enabled.
void _configurarSuscripcionDispositivo() {
_deviceSub?.cancel();
_deviceSub = null;
final svc = _dispositivoAudio;
if (!_eqMultiDeviceEnabled || svc == null) return;
_deviceSub = svc.onDispositivoCambiado.listen(_onDispositivoCambiado);
}
/// Called when a device change event arrives.
Future<void> _onDispositivoCambiado(DispositivoAudio dispositivo) async {
if (!_eqMultiDeviceEnabled) return;
// Cache updates on every event regardless of whether a preset entry
// gets created below (bt-device-identity ADR-4).
_nombresPlataforma[dispositivo.id] = dispositivo.nombre;
_dispositivoActualId = dispositivo.id;
// First-seen device: copy the current resolved preset as its starting
// point. builtin_speaker is excluded: it must always fall through to
// the hierarchy (L4 global) instead of being pinned by a forced L3
// device-level copy, otherwise a later global-preset change would be
// masked by this stale entry for the base device. Composite-placeholder
// ids are also excluded (bt-device-identity ADR-6): they are transient
// fallback ids for a device whose real MAC is not yet known, so
// persisting a preset entry for them would create dead noise that never
// resolves to the eventual real-MAC id.
// Matched by id AND by type: the native layer historically reported the
// phone-speaker id for output types it could not name (LE Audio, car bus,
// dock), which arrive here as `desconocido` and would otherwise slip past a
// type-only check and persist an entry that hijacks the active-device
// indicator forever.
final esBase =
dispositivo.tipo == TipoDispositivo.altavozInterno ||
dispositivo.id == idAltavozInterno;
final esPlaceholderCompuesto = dispositivo.id.startsWith(
prefijoPlaceholderBtName,
);
if (!esBase &&
!esPlaceholderCompuesto &&
!_presetsDispositivo.containsKey(dispositivo.id)) {
final presetBase = _resolverPresetActivo();
_presetsDispositivo[dispositivo.id] = presetBase;
await servicio.guardarPresetDispositivo(dispositivo.id, presetBase);
}
// Re-resolve and apply the preset for the new device.
final resuelto = _resolverPresetActivo();
_presetActual = resuelto;
await audio.aplicarPreset(resuelto);
notifyListeners();
}
/// Subscribes to [ServicioAudio.estadoStream] to catch a
/// car/notification-initiated EQ change (see [_suscripcionEstadoAudioEq]
/// doc for the full rationale).
void _escucharCambiosEqDesdeHandler() {
_suscripcionEstadoAudioEq = audio.estadoStream.listen((_) {
unawaited(_resincronizarConHandler());
});
}
/// Compares the handler's live EQ state ([ServicioAudio.ecualizadorActivo],
/// [ServicioAudio.presetActual]) against our cached [_activo]/
/// [_presetActual] and adopts the handler's value on divergence.
///
/// Deliberately never calls back into [audio] here (no
/// `setEcualizadorActivo`/`aplicarPreset`): doing so would re-trigger the
/// handler's own `_actualizarControlesEq()` re-push, which would tick
/// [ServicioAudio.estadoStream] again and re-enter this method forever.
/// Only a local field write and [notifyListeners] happen here, so a
/// divergence is resolved in a single pass.
///
/// It is now a PURE UI ADOPT — it does not persist (eq-estado-unico item
/// B). `PluriWaveAudioHandler` writes its own toggle through the port
/// `registrarHandler` injects, so the value is saved on every engine
/// rather than only on one that happens to have built a widget tree. This
/// method could never have been the owner of that fact: it only runs while
/// an [EstadoEcualizador] exists, and on the headless Android Auto engine
/// behind the bug report none ever does.
///
/// Wrapped in try/catch like every other handler-facing read in this
/// class (e.g. [_sembrarDispositivoActual]): a test double or an
/// unexpected platform state that makes [audio]'s EQ getters unavailable
/// must never crash the stream subscription — it just skips this tick.
Future<void> _resincronizarConHandler() async {
try {
final activoHandler = audio.ecualizadorActivo;
final presetHandler = audio.presetActual;
final activoDiverge = activoHandler != _activo;
final presetDiverge = presetHandler != _presetActual;
if (!activoDiverge && !presetDiverge) return;
if (activoDiverge) {
// Display-only adopt: the handler already persisted this value
// through its own write port before it ever reached us. See the
// doc above.
_activo = activoHandler;
}
if (presetDiverge) {
_presetActual = presetHandler;
}
notifyListeners();
} catch (_) {
// See doc above — never let a resync failure crash the app.
}
}
/// Applies [preset] to the audio engine and tracks it as current
/// WITHOUT persisting it (used when switching stations).
Future<void> aplicarPresetActivo(PresetEcualizador preset) async {
_presetActual = preset;
await audio.aplicarPreset(preset);
}
/// Enables or disables the multi-device EQ feature toggle.
///
/// When disabled, the device stream subscription is cancelled and resolution
/// immediately collapses to 2-level (station → global).
Future<void> cambiarMultiDeviceEnabled(
bool habilitado, {
bool notificar = true,
}) async {
_eqMultiDeviceEnabled = habilitado;
await servicio.guardarToggleMultiDispositivo(habilitado);
_configurarSuscripcionDispositivo();
// Re-seed the active device from a fresh query instead of leaving a
// stale _dispositivoActualId from before the toggle flip (it would
// otherwise only self-correct on the next native device-change event).
if (_eqMultiDeviceEnabled) {
await _sembrarDispositivoActual();
}
if (notificar) notifyListeners();
}
/// Requests `BLUETOOTH_CONNECT` (API 31+) at the point the
/// device-management UI is opened (bt-device-identity ADR-1).
///
/// Thin passthrough to the injected device service so callers don't need
/// [ServicioDispositivoAudio] wired as its own top-level `Provider` (Task
/// 4.8 — `ServicioDispositivoAudio` is only ever constructed inside
/// `EstadoRadio` today, not exposed via the provider tree; routing through
/// here avoids adding wiring the tree doesn't already have). Returns false
/// when no device service is injected.
Future<bool> solicitarPermisoBluetooth() async {
final svc = _dispositivoAudio;
if (svc == null) return false;
return svc.solicitarPermisoBluetooth();
}
Future<void> cambiarPresetPrincipal(
PresetEcualizador preset, {
bool notificar = true,
}) async {
_presetPrincipal = preset;
await servicio.guardarPrincipal(preset);
final uuid = _emisoraActualUuid();
final puedeAplicarAhora =
uuid == null || !_presetsEmisoraMap.containsKey(uuid);
if (puedeAplicarAhora) {
await aplicarPresetActivo(preset);
}
if (notificar) notifyListeners();
}
Future<void> guardarPresetPorEmisora(
String uuid,
PresetEcualizador preset, {
bool notificar = true,
}) async {
_presetsEmisoraMap[uuid] = preset;
await servicio.guardarPorEmisora(uuid, preset);
if (_emisoraActualUuid() == uuid) {
await aplicarPresetActivo(preset);
}
if (notificar) notifyListeners();
}
Future<void> habilitarPresetPorEmisora(
String uuid, {
PresetEcualizador? base,
bool notificar = true,
}) async {
final presetBase = base ?? _presetsEmisoraMap[uuid] ?? _presetPrincipal;
await guardarPresetPorEmisora(uuid, presetBase, notificar: notificar);
}
Future<void> deshabilitarPresetPorEmisora(
String uuid, {
bool notificar = true,
}) async {
_presetsEmisoraMap.remove(uuid);
await servicio.eliminarPorEmisora(uuid);
if (_emisoraActualUuid() == uuid) {
await aplicarPresetActivo(_presetPrincipal);
}
if (notificar) notifyListeners();
}
Future<void> cambiarModoEmisoraActual({required bool usarPropio}) async {
final uuid = _emisoraActualUuid();
if (uuid == null) return;
if (usarPropio) {
await habilitarPresetPorEmisora(uuid);
} else {
await deshabilitarPresetPorEmisora(uuid);
}
}
/// Loads the persisted custom-preset list (WU13, `eq-custom-presets`
/// spec — the Settings EQ screen's preset chip row).
///
/// Deliberately NOT part of [cargarPersistido]: that method is exercised
/// roughly 30 times by `estado_ecualizador_test.dart` — one of this
/// change's protected EQ test files, required to pass **unmodified** —
/// via Fakes for [servicio]/[_dispositivoAudio] only, with no
/// SharedPreferences awareness anywhere in that file. Folding a third,
/// always-real-by-default collaborator into [cargarPersistido] would
/// introduce a real SharedPreferences call into every one of those
/// cases. Called explicitly by the Settings EQ screen instead, the same
/// way `refrescarDispositivoActual` is already called from screen
/// `initState`, not from [cargarPersistido].
Future<void> cargarPresetsPersonalizados() async {
_presetsPersonalizados = await _presetsPersonalizadosService.listar();
notifyListeners();
}
/// Saves the CURRENT effective preset's bands (`presetActual`) as a new
/// named custom preset (spec "Custom Preset Save").
///
/// Returns `false` — and persists nothing — when [nombre] is empty or
/// whitespace-only (spec "Custom Preset Naming Validates Non-Empty
/// Input"), the same non-crashing validate-before-persist shape
/// [renombrarDispositivo] already uses elsewhere in this class.
Future<bool> guardarPresetPersonalizado(String nombre) async {
final nombreValido = nombre.trim();
if (nombreValido.isEmpty) return false;
final preset = PresetEcualizador(
nombre: nombreValido,
bandas: List<double>.from(_presetActual.bandas),
);
await _presetsPersonalizadosService.guardar(preset);
_presetsPersonalizados = await _presetsPersonalizadosService.listar();
notifyListeners();
return true;
}
/// Removes the custom preset named [nombre], if present.
Future<void> eliminarPresetPersonalizado(String nombre) async {
await _presetsPersonalizadosService.eliminar(nombre);
_presetsPersonalizados = await _presetsPersonalizadosService.listar();
notifyListeners();
}
/// Persists a custom display name for [deviceId].
///
/// Empty names are silently ignored so the existing name is preserved.
/// No-op when the multi-device toggle is off.
Future<void> renombrarDispositivo(String deviceId, String nombre) async {
if (!_eqMultiDeviceEnabled) return;
final nombreTrimmed = nombre.trim();
if (nombreTrimmed.isEmpty) return;
_nombresDispositivos[deviceId] = nombreTrimmed;
await servicio.guardarNombresDispositivos(
Map.unmodifiable(_nombresDispositivos),
);
notifyListeners();
}
/// Persists a per-device EQ preset for [deviceId].
///
/// Guards on [_eqMultiDeviceEnabled]: no-op when toggle is off.
/// If [deviceId] is the currently active device, re-resolves and
/// applies the effective preset immediately.
Future<void> guardarPresetDispositivo(
String deviceId,
PresetEcualizador preset,
) async {
if (!_eqMultiDeviceEnabled) return;
_presetsDispositivo[deviceId] = preset;
await servicio.guardarPresetDispositivo(deviceId, preset);
if (_dispositivoActualId == deviceId) {
// Re-resolve using the full hierarchy (station/matrix may override).
final resuelto = _resolverPresetActivo();
_presetActual = resuelto;
await audio.aplicarPreset(resuelto);
}
notifyListeners();
}
/// Forgets [deviceId] completely: its device preset, its custom name and
/// every matrix entry that targets it.
///
/// Lets the user clear stale or duplicate rows from the known-devices list.
/// The device is NOT prevented from coming back: if it connects again it is
/// re-registered from scratch, which is exactly how a user recovers from a
/// bad entry. When the removed device is the active one, the effective preset
/// is re-resolved so playback immediately follows the remaining hierarchy
/// instead of keeping the deleted preset applied.
Future<void> eliminarDispositivo(String deviceId) async {
_presetsDispositivo.remove(deviceId);
_nombresDispositivos.remove(deviceId);
_nombresPlataforma.remove(deviceId);
_presetsMatriz.removeWhere((clave, _) {
final separador = clave.indexOf(':');
if (separador == -1) return false;
return clave.substring(separador + 1) == deviceId;
});
await servicio.eliminarDispositivo(deviceId);
if (_dispositivoActualId == deviceId) {
final resuelto = _resolverPresetActivo();
_presetActual = resuelto;
await audio.aplicarPreset(resuelto);
}
notifyListeners();
}
/// Returns the stored custom name for [deviceId], or an empty string if none.
String obtenerNombreDispositivo(String deviceId) =>
_nombresDispositivos[deviceId] ?? '';
/// Returns the last-seen platform name for [deviceId], or an empty string
/// if none has been observed yet (bt-device-identity ADR-4).
String nombrePlataforma(String deviceId) =>
_nombresPlataforma[deviceId] ?? '';
/// Resolves the display name for [deviceId] using the fallback chain:
/// custom name → [platformName] → raw [deviceId].
String nombreVisible(String deviceId, String platformName) {
final custom = _nombresDispositivos[deviceId];
if (custom != null && custom.isNotEmpty) return custom;
if (platformName.isNotEmpty) return platformName;
return deviceId;
}
/// Enables or disables the equalizer.
///
/// Engine FIRST, disk last. The previous order persisted before telling the
/// engine, so two quick taps raced on a disk write: when the first write
/// resolved last, the engine received the FIRST tap's value after the second
/// one and the checkbox read enabled while the sound stayed flat. Issuing the
/// engine call before any `await` means overlapping taps reach the engine in
/// tap order, so the last tap always wins.
///
/// Each step then re-checks [_activo]: a newer tap that landed mid-flight
/// owns the outcome, and this superseded call must not apply a preset or
/// persist a value the user has already changed their mind about.
///
/// The handler can also REFUSE the change: when the native `setEnabled`
/// throws, `PluriWaveAudioHandler._aplicarEcualizadorActivo` rolls its own
/// flag back and skips its persistence write, so the value we optimistically
/// published never happened. Reading [ServicioAudio.ecualizadorActivo] back
/// (the handler is the single owner of the flag — eq-estado-unico) is how we
/// learn that: on divergence we adopt the handler's real value and return
/// WITHOUT persisting, instead of showing a lie and writing a rejected value
/// to disk that would resurrect it on the next start. The supersede check
/// runs FIRST so a newer tap still owns the outcome; the read-back only
/// speaks for a call nobody overtook.
Future<void> cambiarActivo(bool activo) async {
_activo = activo;
notifyListeners();
await audio.setEcualizadorActivo(activo);
if (_activo != activo) return;
final aceptado = audio.ecualizadorActivo;
if (aceptado != activo) {
_activo = aceptado;
notifyListeners();
return;
}
if (activo) {
await audio.aplicarPreset(_presetActual);
if (_activo != activo) return;
}
await servicio.guardarActivo(activo);
}
Future<void> cambiarPreset(
PresetEcualizador preset, {
bool guardarPorEmisora = true,
}) async {
final uuid = _emisoraActualUuid();
final usarPresetPropio =
guardarPorEmisora &&
uuid != null &&
_presetsEmisoraMap.containsKey(uuid);
if (usarPresetPropio) {
await guardarPresetPorEmisora(uuid, preset);
return;
}
await cambiarPresetPrincipal(preset);
}
Future<void> cambiarBanda(int index, double db) async {
final bandas = List<double>.from(_presetActual.bandas);
if (index < 0 || index >= bandas.length) return;
bandas[index] = db;
final modificado = PresetEcualizador(
nombre: 'Personalizado',
bandas: bandas,
);
await cambiarPreset(modificado);
}
/// Replaces the whole EQ configuration (backup import path): persists it,
/// re-applies the preset effective for the current station and notifies.
///
/// [activo] is the imported on/off toggle (S4-R4/eq-export-toggle). When
/// `null` — an old backup with no `ecualizadorActivo` field — the CURRENT
/// toggle is left untouched: an absent flag must never flip the user's live
/// setting to an arbitrary value. When non-null, applies it through
/// [cambiarActivo], the same path a manual toggle uses, so the import
/// persists it AND pushes it to the live audio engine instead of just
/// updating [_activo] in memory.
Future<void> importarConfiguracion({
required PresetEcualizador principal,
required Map<String, PresetEcualizador> porEmisora,
Map<String, PresetEcualizador>? presetsDispositivo,
Map<String, PresetEcualizador>? presetsMatriz,
bool? eqMultiDeviceEnabled,
bool? activo,
}) async {
_presetPrincipal = principal;
_presetsEmisoraMap
..clear()
..addAll(porEmisora);
if (presetsDispositivo != null) {
_presetsDispositivo
..clear()
..addAll(presetsDispositivo);
}
if (presetsMatriz != null) {
_presetsMatriz
..clear()
..addAll(presetsMatriz);
}
if (eqMultiDeviceEnabled != null) {
_eqMultiDeviceEnabled = eqMultiDeviceEnabled;
}
await servicio.guardarConfiguracion(
ConfiguracionEcualizador(
principal: _presetPrincipal,
porEmisora: _presetsEmisoraMap,
activo: _activo,
eqMultiDeviceEnabled: _eqMultiDeviceEnabled,
presetsDispositivo: _presetsDispositivo,
presetsMatriz: _presetsMatriz,
),
);
final uuid = _emisoraActualUuid();
final presetEfectivoActual =
uuid == null ? _presetPrincipal : _resolverPresetActivo();
await aplicarPresetActivo(presetEfectivoActual);
if (activo != null) {
await cambiarActivo(activo);
}
notifyListeners();
}
@override
void dispose() {
_deviceSub?.cancel();
_suscripcionEstadoAudioEq?.cancel();
super.dispose();
}
}
+193
View File
@@ -0,0 +1,193 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../servicios/servicio_audio.dart' show invalidarArbolAuto;
import '../servicios/servicio_compras.dart';
/// Versioned persistence key (Design ADR-1) for the permanent, non-consumable
/// premium unlock. Older builds that predate this key simply never read it —
/// no migration needed (Rollout "Versioned key ... is ignored by older
/// builds").
const _keyPremium = 'compra_premium_v1';
/// Headless-safe entitlement read (Design ADR-1, Spec "Headless-Safe
/// Entitlement Read"): resolves the persisted premium flag directly from
/// prefs, with NO `BuildContext`/`Provider` dependency. Mirrors
/// `FuenteMusicaLocalAutoImpl._resolverPrefs()`'s
/// inject-or-`getInstance()` convention (`musica_local_auto.dart:163`) —
/// this is what `PluriWaveAudioHandler` calls, since it registers before
/// `runApp` and no widget tree (therefore no `Provider`) exists yet.
///
/// Absent key = free tier (Rollout "Additive and prefs-backed; absent key =
/// free"). Never throws — a `SharedPreferences.getInstance()` failure would
/// propagate here exactly like the persisted read failing, which the caller
/// (Design ADR-2 "fail-open") must treat as "trust the last known state",
/// not this function's job to catch.
Future<bool> esPremiumPersistido({SharedPreferences? prefs}) async {
final resueltas = prefs ?? await SharedPreferences.getInstance();
return resueltas.getBool(_keyPremium) ?? false;
}
/// User-facing, non-error-text outcomes [EstadoEntitlement] can expose (FIX
/// 3, code review): the UI layer (`hoja_premium.dart`) has no BuildContext
/// here, so this file never carries localized/user-facing STRINGS itself —
/// only this typed signal, mapped to a localized message by the widget.
/// Cleared back to `null` once consumed ([EstadoEntitlement.consumirResultadoUsuario]).
enum ResultadoEntitlementUsuario {
/// A purchase or restore attempt failed (network, billing error, product
/// not yet available in the store, etc). This NEVER carries the raw
/// exception/developer string from [EventoCompra.mensaje] — the UI maps
/// this enum value to ONE generic localized message, never the internal
/// diagnostic text.
error,
/// [EstadoEntitlement.restaurar] completed successfully but found nothing
/// to restore. Distinct from [error]: an expected, non-error outcome
/// (Spec "Restore Purchases" — "finds nothing -> stays free tier with a
/// clear non-error result").
restauracionSinCompras,
}
/// Cross-cutting entitlement notifier (Design ADR-1), idiomatic
/// `EstadoIdioma`-shaped `ChangeNotifier`: UI layers `context.watch`/`read`
/// this; headless callers (Android Auto) use [esPremiumPersistido] instead,
/// since no `Provider` exists on that path.
class EstadoEntitlement extends ChangeNotifier {
EstadoEntitlement({SharedPreferences? prefs, PuertoCompras? compras})
: _prefs = prefs,
_compras = compras {
final flujo = _compras;
if (flujo != null) {
_comprasSub = flujo.eventos.listen(_alRecibirEvento);
}
_cargar();
}
/// The single non-consumable product id (Design "Interfaces / Contracts"),
/// re-exported here so UI/paywall code depends on ONE canonical constant
/// rather than reaching into `servicio_compras.dart` for it.
static const idProducto = ServicioComprasPlayBilling.idProducto;
final SharedPreferences? _prefs;
final PuertoCompras? _compras;
StreamSubscription<EventoCompra>? _comprasSub;
bool _esPremium = false;
bool _compraEnCurso = false;
ResultadoEntitlementUsuario? _resultadoUsuario;
bool get esPremium => _esPremium;
bool get compraEnCurso => _compraEnCurso;
/// FIX 3 (code review): the user-facing signal for a failed purchase/
/// restore, or a restore that found nothing. `null` when there is nothing
/// to show — see [consumirResultadoUsuario].
ResultadoEntitlementUsuario? get resultadoUsuario => _resultadoUsuario;
/// Clears [resultadoUsuario] once the UI has consumed/displayed it.
/// A no-op (no extra notification) if there is nothing to clear.
void consumirResultadoUsuario() {
if (_resultadoUsuario == null) return;
_resultadoUsuario = null;
notifyListeners();
}
Future<void> _cargar() async {
final prefs = await _resolverPrefs();
final premium = prefs.getBool(_keyPremium) ?? false;
if (premium != _esPremium) {
_esPremium = premium;
}
notifyListeners();
}
Future<SharedPreferences> _resolverPrefs() async =>
_prefs ?? SharedPreferences.getInstance();
/// Starts the purchase flow (Spec "Successful purchase"). A no-op when
/// already premium (Spec "Already-purchased attempt is idempotent") — no
/// duplicate charge is even attempted.
Future<void> comprar() async {
if (_esPremium) return;
final compras = _compras;
if (compras == null) return;
_compraEnCurso = true;
// FIX 3 (code review): a fresh attempt clears any stale result left over
// from a previous failed attempt, so the UI never shows an outdated
// error/confirmation across two unrelated attempts.
_resultadoUsuario = null;
notifyListeners();
await compras.comprar();
}
/// Re-queries Play Billing for a prior purchase (Spec "Restore Purchases").
Future<void> restaurar() async {
final compras = _compras;
if (compras == null) return;
_compraEnCurso = true;
_resultadoUsuario = null;
notifyListeners();
await compras.restaurar();
}
Future<void> _alRecibirEvento(EventoCompra evento) async {
switch (evento.tipo) {
case TipoEventoCompra.comprada:
case TipoEventoCompra.restaurada:
await _desbloquear();
case TipoEventoCompra.cancelada:
// Spec "Purchase cancelled or failed": a user-INITIATED cancel
// stays free tier with no error surfaced — just stop the in-flight
// spinner. Not a failure, so no [resultadoUsuario] either.
_compraEnCurso = false;
notifyListeners();
case TipoEventoCompra.noEncontrada:
// FIX 3 (code review): "Restore finds nothing" is an expected,
// NON-error outcome (Spec "Restore Purchases") but `hoja_premium.dart`
// had zero feedback for it — the spinner just stopped with no
// confirmation. Distinct signal from [TipoEventoCompra.error].
_compraEnCurso = false;
_resultadoUsuario = ResultadoEntitlementUsuario.restauracionSinCompras;
notifyListeners();
case TipoEventoCompra.error:
// Fail-open (Design ADR-2): an error NEVER writes `false` over an
// already-premium flag, and never invents a `true` for a free user
// either — the persisted flag from `_cargar()` is left untouched.
//
// FIX 3 (code review): [EventoCompra.mensaje] (raw exception/
// developer text, e.g. "Producto no encontrado en Play Console") is
// DELIBERATELY discarded here — only the typed enum crosses into
// [resultadoUsuario], never the raw string. `hoja_premium.dart` maps
// it to ONE generic localized message.
_compraEnCurso = false;
_resultadoUsuario = ResultadoEntitlementUsuario.error;
notifyListeners();
case TipoEventoCompra.pendiente:
_compraEnCurso = true;
notifyListeners();
}
}
Future<void> _desbloquear() async {
final yaEraPremium = _esPremium;
_esPremium = true;
_compraEnCurso = false;
final prefs = await _resolverPrefs();
await prefs.setBool(_keyPremium, true);
notifyListeners();
if (!yaEraPremium) {
// Orchestrator-resolved open question (design.md): actively
// invalidate the Android Auto browse cache on the free -> premium
// transition, rather than waiting for the head unit's own re-bind.
invalidarArbolAuto();
}
}
@override
void dispose() {
_comprasSub?.cancel();
super.dispose();
}
}
+211
View File
@@ -0,0 +1,211 @@
import 'dart:async';
import 'dart:io';
import 'dart:ui' show Locale;
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'package:url_launcher/url_launcher.dart';
import '../l10n/gen/app_localizations.dart';
import '../modelos/archivo_grabacion.dart';
import '../modelos/emisora.dart';
import '../servicios/servicio_grabacion_radio.dart';
/// Recording state extracted from `EstadoRadio` (S4-R2).
///
/// Owns [ServicioGrabacionRadio] and the recording-state subscription, and
/// notifies ONLY its own listeners — recording progress must not rebuild
/// `EstadoRadio` consumers (S4-R5). Playback orchestration (stop recording on
/// pause/stop/station switch) stays in `EstadoRadio`, which keeps a reference
/// to this notifier.
/// Whether [emisora] is something the recorder can actually capture: a live
/// network stream.
///
/// The recorder opens the URL as an HTTP stream and writes the bytes to disk,
/// so anything else fails inside the HTTP client with a message no user can
/// act on ("Unsupported scheme 'content' in URI content://...").
///
/// This is not hypothetical tidiness. `PluriWaveAudioHandler._cambiarFuente`
/// sets `emisoraActual` for EVERY source it plays, so a local MP3 shows up
/// here as an `Emisora` whose `url` is the `content://` document URI it was
/// opened from. Recording a local file makes no sense anyway — it is already
/// on the device.
bool esEmisoraGrabable(Emisora emisora) {
final esquema = Uri.tryParse(emisora.url)?.scheme.toLowerCase();
return esquema == 'http' || esquema == 'https';
}
/// Distinct outcome signal for [EstadoGrabacion.iniciar] (freemium-gating
/// spec "Recording Start Gated"): [requierePremium] is NOT surfaced through
/// [EstadoGrabacion.iniciar]'s existing `alError` sink — the caller must
/// react by opening the paywall, a different UI than a plain error snackbar.
enum ResultadoIniciarGrabacion { iniciada, requierePremium, error }
class EstadoGrabacion extends ChangeNotifier {
EstadoGrabacion({
ServicioGrabacionRadio? servicio,
Emisora? Function()? emisoraActual,
void Function(String mensaje)? alError,
// iap-freemium-unlock (Design ADR-3): entitlement query, mirroring
// [_emisoraActual]'s callback-injection shape. REQUIRED on purpose: an
// optional parameter with any default lets a forgotten wiring compile
// and silently pick a tier, and no test can catch that. Callers must
// state the entitlement source explicitly.
required bool Function() esPremium,
}) : servicio = servicio ?? ServicioGrabacionRadio(),
_emisoraActual = emisoraActual ?? (() => null),
_alError = alError,
_esPremium = esPremium {
_suscripcion = this.servicio.estadoStream.listen((estado) {
if (estado.tipo == EstadoGrabacionRadioTipo.error &&
estado.error != null) {
_alError?.call(_textos.radioRecordingError(estado.error!));
}
notifyListeners();
});
}
static const MethodChannel _fileActionsChannel = MethodChannel(
'pluriwave/file_actions',
);
final ServicioGrabacionRadio servicio;
/// Callback into the owner (EstadoRadio) for the currently playing station;
/// keeps this notifier free of any station-list coupling.
final Emisora? Function() _emisoraActual;
/// User-visible error sink (EstadoRadio routes it to its snackbar stream).
final void Function(String mensaje)? _alError;
final bool Function() _esPremium;
StreamSubscription<EstadoGrabacionRadio>? _suscripcion;
AppLocalizations? _l10n;
AppLocalizations get _textos {
final actual = _l10n;
if (actual != null) return actual;
return lookupAppLocalizations(const Locale('es'));
}
void configurarLocalizaciones(AppLocalizations l10n) {
_l10n = l10n;
servicio.configurarLocalizaciones(l10n);
}
Future<void> inicializar() => servicio.inicializar();
EstadoGrabacionRadio get estado => servicio.estado;
bool get activa => servicio.estado.activa;
String? get directorioConfigurado => servicio.directorioConfigurado;
int get maxBytes => servicio.maxBytes;
File? get ultimoArchivo => servicio.ultimoArchivo;
Future<ResultadoIniciarGrabacion> iniciar({Duration? duracion}) async {
// Freemium gate (freemium-gating spec "Free user starts a new
// recording"): the AUTHORITATIVE check, before touching the service at
// all. Management of already-existing recordings is untouched — this
// method only governs STARTING a new one.
if (!_esPremium()) {
return ResultadoIniciarGrabacion.requierePremium;
}
final actual = _emisoraActual();
// `emisoraActual` is set by `_cambiarFuente` for EVERY source, local
// tracks included -- a local file becomes an `Emisora` whose `url` is the
// SAF `content://` URI it was opened from. Handing that to the recorder
// produced "Unsupported scheme 'content' in URI content://..." on screen,
// and it started happening only once local music playback existed: before
// that, whatever was playing was always a real station.
if (actual == null || !esEmisoraGrabable(actual)) {
_alError?.call(_textos.recordingSelectStationFirst);
return ResultadoIniciarGrabacion.error;
}
try {
await servicio.iniciar(actual, duracion: duracion);
return ResultadoIniciarGrabacion.iniciada;
} catch (e) {
_alError?.call(_textos.recordingStartError(e.toString()));
return ResultadoIniciarGrabacion.error;
}
}
Future<void> detener() => servicio.detener();
Future<void> cambiarMaxBytes(int bytes) async {
await servicio.guardarMaxBytes(bytes);
notifyListeners();
}
Future<void> cambiarDirectorio(String path) async {
await servicio.guardarDirectorio(path);
notifyListeners();
}
Future<void> restaurarDirectorio() async {
await servicio.limpiarDirectorioConfigurado();
notifyListeners();
}
Future<String> directorioEfectivo() => servicio.directorioEfectivo();
/// WU15, recordings-library: browsable listing of recording files
/// already on disk. Thin delegate — no additional logic.
Future<List<ArchivoGrabacion>> listarGrabaciones() =>
servicio.listarGrabaciones();
/// WU15: deletes [ruta] and notifies listeners so the library screen's
/// row disappears.
Future<void> eliminarGrabacion(String ruta) async {
await servicio.eliminarGrabacion(ruta);
notifyListeners();
}
/// WU15: renames the recording at [ruta] to [nuevoNombre] and notifies
/// listeners so the library screen reflects the new name.
Future<void> renombrarGrabacion(String ruta, String nuevoNombre) async {
await servicio.renombrarGrabacion(ruta, nuevoNombre);
notifyListeners();
}
Future<bool> abrirDirectorio() async {
final ruta = await directorioEfectivo();
await Directory(ruta).create(recursive: true);
if (!kIsWeb && Platform.isAndroid) {
final abierto = await _fileActionsChannel.invokeMethod<bool>(
'viewDirectory',
{'path': ruta},
);
return abierto ?? false;
}
final uri = Uri.directory(ruta);
return launchUrl(uri, mode: LaunchMode.externalApplication);
}
Future<bool> abrirUltimaGrabacion() async {
final archivo = ultimoArchivo;
if (archivo == null || !await archivo.exists()) {
debugPrint('[PluriWave][recordings] last recording missing');
return false;
}
debugPrint('[PluriWave][recordings] opening last file: ${archivo.path}');
if (!kIsWeb && Platform.isAndroid) {
final abierto = await _fileActionsChannel.invokeMethod<bool>('openFile', {
'path': archivo.path,
'mimeType': 'audio/*',
});
return abierto ?? false;
}
return launchUrl(
Uri.file(archivo.path),
mode: LaunchMode.externalApplication,
);
}
@override
void dispose() {
_suscripcion?.cancel();
unawaited(servicio.dispose());
super.dispose();
}
}
+2 -3
View File
@@ -49,9 +49,8 @@ class EstadoIdioma extends ChangeNotifier {
final partes = value.split('_');
final languageCode = partes.first;
if (languageCode.isEmpty) return null;
final countryCode = partes.length > 1 && partes[1].isNotEmpty
? partes[1]
: null;
final countryCode =
partes.length > 1 && partes[1].isNotEmpty ? partes[1] : null;
return Locale.fromSubtags(
languageCode: languageCode,
countryCode: countryCode,
+24
View File
@@ -0,0 +1,24 @@
import 'package:flutter/foundation.dart';
/// The five root tabs, in their declared (and displayed) order. Declaration
/// order IS the tab order — the single source shared by `_paginas` and
/// `_navItems` in app.dart (Design ADR-8).
enum RaizPluriWave { escuchar, buscar, favoritos, alarmas, ajustes }
/// Root-to-root navigation state (Design ADR-8). The single source of truth
/// for which of the 5 root tabs is active. Switching roots is a plain
/// notifier update — it never pushes a route.
class EstadoNavegacionRaiz extends ChangeNotifier {
RaizPluriWave _actual = RaizPluriWave.escuchar;
RaizPluriWave get actual => _actual;
/// Declaration order doubles as the bottom-nav index.
int get indice => _actual.index;
void irA(RaizPluriWave raiz) {
if (raiz == _actual) return;
_actual = raiz;
notifyListeners();
}
}
File diff suppressed because it is too large Load Diff
+65
View File
@@ -0,0 +1,65 @@
import '../modelos/emisora.dart';
/// User-selectable ordering for every station list in the app.
///
/// WU6 adds [popularidad] to the Buscar "Ordenar" control (design ADR-4),
/// backed by fields the model already carries (`votes`, `clickcount`) —
/// no new API surface, no server-side `order` parameter.
enum OrdenEmisoras { nombre, calidad, popularidad }
/// Returns a sorted COPY of [emisoras] according to [orden].
List<Emisora> ordenarEmisoras(List<Emisora> emisoras, OrdenEmisoras orden) {
final ordenadas = List<Emisora>.from(emisoras);
switch (orden) {
case OrdenEmisoras.nombre:
ordenadas.sort(
(a, b) => a.nombre.toLowerCase().compareTo(b.nombre.toLowerCase()),
);
case OrdenEmisoras.calidad:
ordenadas.sort((a, b) {
final porBitrate = (b.bitrate ?? 0).compareTo(a.bitrate ?? 0);
if (porBitrate != 0) return porBitrate;
return 0;
});
case OrdenEmisoras.popularidad:
ordenadas.sort((a, b) {
final porVotos = b.votes.compareTo(a.votes);
if (porVotos != 0) return porVotos;
return b.clickcount.compareTo(a.clickcount);
});
}
return ordenadas;
}
/// Identity-memoized derived list (S4-R5).
///
/// Derived-list getters used to return a fresh copy on every read, which made
/// `context.select` rebuild on EVERY notification (lists compare by identity).
/// This memo recomputes only when one of the source [claves] changes identity,
/// so unrelated notifications (e.g. audio buffer events) stop rebuilding the
/// screens that select these lists.
class MemoLista<T> {
List<Object?>? _claves;
List<T>? _resultado;
List<T> obtener(List<Object?> claves, List<T> Function() calcular) {
final anteriores = _claves;
final resultado = _resultado;
if (anteriores != null &&
resultado != null &&
anteriores.length == claves.length) {
var iguales = true;
for (var i = 0; i < claves.length; i++) {
if (!identical(anteriores[i], claves[i])) {
iguales = false;
break;
}
}
if (iguales) return resultado;
}
final nuevo = calcular();
_claves = List<Object?>.of(claves);
_resultado = nuevo;
return nuevo;
}
}
+749 -249
View File
File diff suppressed because it is too large Load Diff
+749 -249
View File
File diff suppressed because it is too large Load Diff
+718 -218
View File
File diff suppressed because it is too large Load Diff
+504 -4
View File
@@ -1,7 +1,7 @@
{
"@@locale": "en",
"appTitle": "PluriWave",
"navHome": "Home",
"navHome": "Listen",
"navSearch": "Search",
"navFavorites": "Favorites",
"navAlarms": "Alarms",
@@ -16,6 +16,33 @@
"hoursLabel": "Hours",
"minutesLabel": "Minutes",
"secondsLabel": "Seconds",
"durationHoursMinutesSeconds": "{hours} h {minutes} min {seconds} s",
"@durationHoursMinutesSeconds": {
"placeholders": {
"hours": {},
"minutes": {},
"seconds": {}
}
},
"durationMinutesSeconds": "{minutes} min {seconds} s",
"@durationMinutesSeconds": {
"placeholders": {
"minutes": {},
"seconds": {}
}
},
"durationMinutesOnly": "{minutes} min",
"@durationMinutesOnly": {
"placeholders": {
"minutes": {}
}
},
"durationSecondsOnly": "{seconds} s",
"@durationSecondsOnly": {
"placeholders": {
"seconds": {}
}
},
"saveQuickAccess": "Save as quick access",
"startTimer": "Start timer",
"skipCurrentAlarmExecution": "Skipped this execution of {alarmName}.",
@@ -26,6 +53,11 @@
},
"settingsTitle": "Settings",
"settingsSubtitle": "Fine-grained sound control, backups, and custom stations.",
"settingsGroupAudioTitle": "AUDIO",
"settingsGroupStationsTitle": "STATIONS",
"settingsGroupRecordingsTitle": "RECORDINGS & MUSIC",
"settingsGroupApplicationTitle": "APPLICATION",
"infoSectionTitle": "Info",
"languageSectionTitle": "Language",
"languageSectionDescription": "Choose how the app language is displayed.",
"languageSystemDefault": "System",
@@ -63,6 +95,18 @@
"equalizerPerStationTitle": "Use custom EQ for this favorite",
"equalizerPerStationActive": "Active for {stationName}",
"equalizerPerStationMain": "Using main EQ for {stationName}",
"equalizerBaseExplainer": "This is the base equalizer: it applies to every station without its own setting. A station's own EQ is set from its own playback screen and overrides this one.",
"equalizerActiveOutputLabel": "Active output",
"equalizerActiveOutputDefault": "This device's speaker",
"equalizerStationsWithOwnEqTitle": "Stations with their own EQ",
"equalizerStationsWithOwnEqSubtitle": "Ignore this base equalizer",
"equalizerStationsWithOwnEqEmpty": "No stations have their own EQ yet.",
"equalizerSaveAsPresetAction": "Save as preset",
"equalizerResetToFlatAction": "Reset to flat",
"equalizerSavePresetDialogTitle": "Save as preset",
"equalizerSavePresetNameLabel": "Preset name",
"equalizerSavePresetEmptyNameError": "Enter a name for the preset.",
"equalizerSavePresetConfirm": "Save",
"preferredStationTitle": "Preferred station",
"preferredStationDescription": "Preselected for new alarms and available for quick playback.",
"preferredStationNoStationsTitle": "No stations available yet",
@@ -79,6 +123,7 @@
"deleteAction": "Delete",
"addStationTitle": "Add station",
"stationNameLabel": "Name *",
"unnamedStation": "Unnamed station",
"requiredField": "Required field",
"streamUrlLabel": "Stream URL *",
"invalidUrl": "Invalid URL",
@@ -176,9 +221,34 @@
}
}
},
"recordingsLibraryTitle": "My recordings",
"recordingsLibraryStorageCaption": "{usedMb} MB of {totalMb} MB used",
"@recordingsLibraryStorageCaption": {
"placeholders": {
"usedMb": {
"type": "int"
},
"totalMb": {
"type": "int"
}
}
},
"recordingsLibraryStorageFolderCaption": "Music/PluriWave · purges oldest at limit",
"recordingsLibraryEmptyTitle": "No recordings yet",
"recordingsLibraryEmptySubtitle": "Recordings you save will appear here.",
"recordingActionRename": "Rename",
"recordingActionShare": "Share",
"recordingActionDelete": "Delete",
"recordingRenameDialogTitle": "Rename recording",
"recordingRenameLabel": "Name",
"recordingRenameEmptyError": "Enter a name",
"recordingDeleteConfirmTitle": "Delete recording?",
"recordingDeleteConfirmMessage": "This can't be undone.",
"recordingsLibrarySettingsTooltip": "Recording settings",
"stationOrderTitle": "Station order",
"stationOrderByName": "By name",
"stationOrderByQuality": "By quality",
"stationOrderByPopularity": "By popularity",
"stationOrderScopeDescription": "Applies to favorites, searches, nearby stations and quick lists.",
"favoriteGroupsTitle": "Favorite lists",
"favoriteGroupsDescription": "Create short lists to organize your saved stations.",
@@ -226,6 +296,18 @@
"stationName": {}
}
},
"favoritesFilterAllLabel": "All",
"favoriteGroupsChipLabel": "{groupName} · {count}",
"@favoriteGroupsChipLabel": {
"placeholders": {
"groupName": {},
"count": {
"type": "int"
}
}
},
"favoriteGroupsManage": "Manage lists",
"customStationsAddCta": "Add custom station",
"alarmPostponedCurrentExecution": "Alarm postponed for this occurrence.",
"searchScreenTitle": "Search signal",
"searchScreenSubtitle": "Find stations by name, country, or language with fast filters and high contrast.",
@@ -234,10 +316,20 @@
"searchCountryFilterLabel": "Country",
"searchLanguageFilterLabel": "Language",
"searchMinQualityFilterLabel": "Minimum quality",
"searchLoadingStationsLabel": "SEARCHING FOR STATIONS…",
"searchEmptyTitle": "Search for a station",
"searchNoResultsTitle": "No results",
"searchNoResultsForQueryTitle": "No results for \"{query}\"",
"@searchNoResultsForQueryTitle": {"placeholders": {"query": {}}},
"searchEmptySubtitle": "Use the top bar or chips to discover stations from around the world.",
"searchNoResultsSubtitle": "Try removing filters or typing another name to find an active station.",
"searchResultsCount": "{count, plural, =1{1 result} other{{count} results}}",
"searchClearFiltersAction": "{count, plural, =1{Clear filter} other{Clear {count} filters}}",
"countriesScreenTitle": "Countries",
"countriesSearchHint": "Country or code...",
"countriesYourLanguagesTitle": "Your languages",
"countriesAllTitle": "All countries",
"radioCountriesError": "We couldn't load the countries.",
"countrySpain": "Spain",
"countryUsa": "USA",
"countryMexico": "Mexico",
@@ -268,8 +360,14 @@
}
},
"qualityHd": "HD quality",
"yourStationsTitle": "Your stations",
"seeAllAction": "See all",
"openFullPlayerTooltip": "Open full player",
"nowListeningLabel": "Now listening",
"nothingPlayingTitle": "Nothing playing yet",
"nothingPlayingSubtitle": "Pick a station from Your stations or search to start.",
"nearYou": "Near you",
"nearYouInCountry": "Near you ? {country}",
"nearYouInCountry": "Near you · {country}",
"@nearYouInCountry": {
"placeholders": {
"country": {}
@@ -278,6 +376,13 @@
"detectAction": "Detect",
"liveRadar": "Live radar",
"genresTitle": "Genres",
"exploreByTitle": "Browse by",
"exploreTrendingTitle": "Trending",
"exploreTrendingSubtitle": "Today",
"exploreNewTitle": "New",
"exploreNewSubtitle": "This week",
"popularNowTitle": "Popular now",
"offlineBannerTitle": "No connection",
"retryAction": "Retry",
"noStationsAvailable": "No stations available",
"noStationsAvailableSubtitle": "Try refreshing or choosing another genre to capture a signal again.",
@@ -342,6 +447,9 @@
"editAction": "Edit",
"skipNextAction": "Skip next",
"deleteTooltip": "Delete",
"alarmHeroSkipAction": "Skip",
"alarmDeleteConfirmTitle": "Delete alarm?",
"alarmDeleteConfirmMessage": "This can't be undone.",
"alarmSkippedNoNextSnackbar": "Alarm skipped. There is no next occurrence left.",
"alarmSkippedReturnsSnackbar": "Alarm skipped. It will return on {date}.",
"@alarmSkippedReturnsSnackbar": {
@@ -394,6 +502,19 @@
"soundDigitalPulse": "Digital pulse",
"favoriteStationLabel": "Favorite station",
"noStationUseInternalSound": "No station: use internal sound",
"alarmFallbackStationLabel": "Backup station",
"alarmStationPickerSearchHint": "Search for a station by name",
"alarmSnoozeDurationTitle": "Snooze duration",
"snoozeAction": "Snooze",
"alarmSnoozeOptionLabel": "{minutes} min",
"@alarmSnoozeOptionLabel": {
"placeholders": {
"minutes": {
"type": "int"
}
}
},
"alarmSnoozeUsualLabel": "usual",
"saveFavoritesAlarmHint": "Save stations in Favorites to use them as a music alarm.",
"useCurrentStationAction": "Use current station",
"playDuringVacations": "Play during vacations",
@@ -403,7 +524,7 @@
"androidReliabilityReview": "Review Android reliability",
"statusOk": "OK",
"statusPending": "pending",
"androidReliabilityStatus": "Reliability: exact {exact} ? notifications {notifications} ? screen {screen}",
"androidReliabilityStatus": "Reliability: exact {exact} · notifications {notifications} · screen {screen}",
"@androidReliabilityStatus": {
"placeholders": {
"exact": {},
@@ -416,8 +537,52 @@
"vacationRangesHint": "If an alarm is set to \"Paused during vacations\", it automatically skips these ranges.",
"noVacationRangesLoaded": "No ranges loaded.",
"deleteRangeTooltip": "Delete range",
"vacationRangesCount": "{count} ranges",
"@vacationRangesCount": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"vacationSummaryActiveCountdown": "Active now · {days} days left",
"@vacationSummaryActiveCountdown": {
"placeholders": {
"days": {
"type": "int"
}
}
},
"vacationSummaryUpcomingCountdown": "Next range in {days} days",
"@vacationSummaryUpcomingCountdown": {
"placeholders": {
"days": {
"type": "int"
}
}
},
"vacationImpactPausedLabel": "Paused: {times}",
"@vacationImpactPausedLabel": {
"placeholders": {
"times": {}
}
},
"vacationImpactContinuesLabel": "Still ringing: {times}",
"@vacationImpactContinuesLabel": {
"placeholders": {
"times": {}
}
},
"vacationUpcomingSectionTitle": "SCHEDULED",
"vacationPastSectionTitle": "Past ranges",
"addVacationRangeCta": "Add range",
"vacationExplainerBanner": "During these ranges, alarms marked \"pause during vacations\" won't ring. Alarms marked \"always ring\" are not affected.",
"vacationNoActiveRangeHint": "No active vacation range right now.",
"vacationsDefaultName": "Vacation",
"newVacationRangeTitle": "New vacation range",
"editVacationRangeTitle": "Edit vacation range",
"vacationDeleteConfirmTitle": "Delete vacation range?",
"vacationDeleteConfirmMessage": "This can't be undone.",
"startField": "Start",
"endField": "End",
"saveRangeAction": "Save range",
@@ -425,5 +590,340 @@
"noAlarmsYetSubtitle": "Create one to design your musical wake-up.",
"ringingInternalAudioActive": "Playing with internal safe audio.",
"ringingPreparingInternalAudio": "Preparing internal safe audio.",
"stopAlarmAction": "Stop alarm"
"stopAlarmAction": "Stop alarm",
"alarmStopFailedMessage": "We couldn't confirm the alarm stopped. Try again.",
"alarmForceStopAction": "Force stop",
"alarmMissedNotificationTitle": "Missed alarm",
"alarmMissedNotificationText": "{name} was silenced automatically after 10 minutes.",
"@alarmMissedNotificationText": {
"placeholders": {
"name": {}
}
},
"pauseAction": "Pause",
"miniPlayerOpenLabel": "Open player for {stationName}",
"@miniPlayerOpenLabel": {
"placeholders": {
"stationName": {}
}
},
"playerIconLabel": "Player",
"playbackStatusConnecting": "Connecting...",
"playbackStatusLive": "Live",
"playbackStatusPaused": "Paused",
"playbackStatusReconnecting": "Reconnecting...",
"playbackStatusConnectionError": "Connection error",
"playbackStatusStopped": "Stopped",
"stationSemanticLabel": "Station {stationName}",
"@stationSemanticLabel": {
"placeholders": {
"stationName": {}
}
},
"favoritesAddTooltip": "Add to favorites",
"favoritesAddedMessage": "{stationName} added to favorites",
"@favoritesAddedMessage": {
"placeholders": {
"stationName": {}
}
},
"stationIconLabel": "Station icon",
"liveNow": "Live",
"equalizerBandLabel": "{band} band",
"@equalizerBandLabel": {
"placeholders": {
"band": {}
}
},
"equalizerBandValue": "{value} decibels",
"@equalizerBandValue": {
"placeholders": {
"value": {}
}
},
"equalizerPresetFlat": "Flat",
"equalizerPresetRock": "Rock",
"equalizerPresetPop": "Pop",
"equalizerPresetBassBoost": "Bass Boost",
"equalizerPresetJazz": "Jazz",
"equalizerPresetVoice": "Voice",
"equalizerPresetCustom": "Custom",
"onboardingTitle": "Welcome to PluriWave",
"onboardingNewsTitle": "What's new",
"onboardingStartAction": "Start",
"onboardingCloseTooltip": "Close",
"radioRecordingError": "Error recording the radio: {error}",
"@radioRecordingError": {
"placeholders": {
"error": {}
}
},
"radioApiConnectionError": "No connection to the radio API",
"radioSearchError": "Search error. Check your connection.",
"radioLoadMoreStationsError": "Could not load more stations.",
"radioNearbyStationsError": "We could not detect nearby stations. Use country filters.",
"radioCannotPlayStation": "Cannot play \"{stationName}\"",
"@radioCannotPlayStation": {
"placeholders": {
"stationName": {}
}
},
"recordingSelectStationFirst": "Select a station before recording.",
"recordingStartError": "Could not start recording: {error}",
"@recordingStartError": {
"placeholders": {
"error": {}
}
},
"unsupportedConfigVersion": "Unsupported configuration version",
"audioErrorGeneric": "Playback error",
"audioErrorNoInternet": "No internet connection",
"audioErrorInvalidUrl": "The radio URL is not valid",
"audioErrorNotFound": "The radio is not available (404 error)",
"audioErrorTimeout": "Connection timed out",
"audioErrorCannotConnect": "Cannot connect to the radio",
"audioErrorUnsupportedFormat": "Unsupported stream format",
"audioErrorDecode": "Error decoding the audio stream",
"audioErrorCleartext": "This radio uses unencrypted HTTP, which is not allowed",
"audioErrorSsl": "Invalid SSL certificate on the radio",
"audioErrorCannotPlay": "This radio cannot be played",
"audioErrorUnexpectedPlayback": "Unexpected playback error",
"androidExactAlarmScheduleError": "Android could not schedule an exact alarm. Check the exact alarm permission.",
"recordingPathEmptyError": "The recording path cannot be empty",
"recordingMaxSizeInvalidError": "The maximum size must be greater than zero",
"recordingAlreadyActiveError": "A recording is already in progress",
"alarmRingingFallbackActive": "Playing with internal safe audio.",
"alarmRingingPreparingFallback": "Preparing internal safe audio.",
"alarmRingingTryingStation": "Trying to play your station at the highest available quality.",
"alarmScheduleDaily": "Every morning",
"alarmScheduleOnce": "Once · {date}",
"@alarmScheduleOnce": {
"placeholders": {
"date": {}
}
},
"alarmScheduleWeekdays": "Days: {days}",
"@alarmScheduleWeekdays": {
"placeholders": {
"days": {}
}
},
"alarmRepeatSectionLabel": "REPEAT",
"alarmVolumeLabel": "Volume",
"androidReliabilityTitle": "Review Android reliability",
"closeAction": "Close",
"customOption": "Custom",
"endLabel": "End",
"equalizerDisable": "Disable equalizer",
"helpTitle": "Help and tutorial",
"helpSubtitle": "9 screens · watch it again anytime",
"tutorialSkipAction": "Skip",
"tutorialNextAction": "Next",
"tutorialPage1Headline": "Save your stations and group them",
"tutorialPage1Body": "Tap the heart to save a station. In \"Your stations\" you can create groups like \"Every morning\" or \"Car\" and drag to reorder them.",
"tutorialPage2Headline": "One base equalizer, plus one per station",
"tutorialPage2Body": "In Settings you set the general equalizer. And from a specific station's playback screen you can give it its own setting, which overrides the general one.",
"tutorialPage3Headline": "Record what you're listening to",
"tutorialPage3Body": "From the player's tool tray, \"Record\" saves the original stream. Find your recordings under Settings › Recordings.",
"tutorialPage4Headline": "Alarms that adapt to you",
"tutorialPage4Body": "Wake up to your favorite station, snooze for 3, 5, or 10 minutes, and add vacation ranges so some alarms skip themselves.",
"tutorialPage5Headline": "Your favorites, in the car too",
"tutorialPage5Body": "Connect your phone with Android Auto to find Favorites, All stations, Your stations, and your Local Music, with big buttons made for driving.",
"tutorialPage6Headline": "It reconnects on its own",
"tutorialPage6Body": "If the signal drops, PluriWave retries automatically and keeps showing your saved favorites even without a connection.",
"tutorialPage7Headline": "Choose how long to snooze",
"tutorialPage7Body": "When an alarm rings, there's no single \"snooze\": you choose 3, 5, or 10 minutes depending on what you need at that moment.",
"tutorialPage8Headline": "Can't find it? Add it yourself",
"tutorialPage8Body": "In \"Your stations\" → Add custom station, paste the stream URL of a station that isn't in the search results. It's saved under \"Your stations\", also available in the car.",
"tutorialPage9Headline": "Done — now you know the essentials",
"tutorialPage9BannerBody": "To watch this tutorial again anytime: Settings → Information → Help and tutorial.",
"indefiniteOption": "Indefinite",
"invalidNumber": "Invalid number",
"nameLabel": "Name",
"notPlaying": "Not playing",
"oneTimeOption": "Once",
"pausePlaybackTooltip": "Pause playback",
"playerQualityChangeAction": "Change",
"playerToolEqLabel": "Own EQ",
"qualityOriginal": "Original quality: {quality}",
"@qualityOriginal": {
"placeholders": {
"quality": {}
}
},
"qualityUnknown": "Quality not reported",
"recordAction": "Record",
"recordDurationTitle": "Recording duration",
"recordRadioSubtitle": "Choose how long you want to record.",
"recordRadioTitle": "Record radio",
"recordingActiveTitle": "Recording radio",
"recordingDirectTitle": "Direct recording",
"recordingsOpenFolderPlainError": "Could not open the recordings folder",
"recordingsOpenLatest": "Open latest recording",
"recordingsOpenLatestError": "Could not open the latest recording",
"startLabel": "Start",
"startPlaybackTooltip": "Start playback",
"stopAction": "Stop",
"stopPlaybackTooltip": "Stop playback",
"weekdayShortMonday": "Mon",
"weekdayShortTuesday": "Tue",
"weekdayShortWednesday": "Wed",
"weekdayShortThursday": "Thu",
"weekdayShortFriday": "Fri",
"weekdayShortSaturday": "Sat",
"weekdayShortSunday": "Sun",
"stationCount": "{count, plural, =1{1 station} other{{count} stations}}",
"alarmIconLabel": "Musical alarm",
"vacationIconLabel": "Vacation mode",
"alarmAdvancedSectionTitle": "Advanced",
"alarmInlineHourLabel": "Hour",
"alarmInlineMinuteLabel": "Minute",
"alarmVolumeRisingStatus": "Turning up the volume",
"streamUrlHint": "https://stream.example.com:8000/radio",
"advancedEqSectionTitle": "Advanced Equalization Options",
"advancedEqEnableToggle": "Enable per-device EQ",
"advancedEqEnableToggleSubtitle": "Automatically apply a different EQ preset when your audio output changes.",
"advancedEqKnownDevicesTitle": "Known audio devices",
"advancedEqKnownDevicesEmpty": "No audio devices recorded yet. Connect a device to get started.",
"advancedEqDevicePresetLabel": "Preset: {presetName}",
"@advancedEqDevicePresetLabel": {
"placeholders": {
"presetName": {}
}
},
"preNoticeCountdown": "Starts in {minutes} min",
"snoozeCountdown": "Rings in {minutes} min",
"@snoozeCountdown": {
"placeholders": {
"minutes": {
"type": "int"
}
}
},
"snoozeAgainAction": "Snooze again",
"alarmRingingNotificationTitle": "PluriWave alarm",
"alarmFireChannelName": "Ringing alarms",
"alarmFireChannelDescription": "Urgent sound and screen when a music alarm must ring",
"alarmPreNoticeChannelName": "Alarm reminders",
"alarmPreNoticeChannelDescription": "Silent notifications before the alarm",
"openFolderChooserTitle": "Open folder",
"openRecordingChooserTitle": "Open recording",
"@preNoticeCountdown": {
"placeholders": {
"minutes": {
"type": "int"
}
}
},
"eqDeviceEditTitle": "Edit device",
"eqDeviceNameLabel": "Device name",
"eqDeviceNameHint": "e.g. Living Room Speaker",
"eqDeviceNameConfirm": "Save",
"eqDeviceConnected": "Connected",
"eqDeviceActiveOutput": "Audio is playing through this device",
"eqDeviceRemove": "Remove device",
"eqDeviceRemoved": "{device} removed",
"@eqDeviceRemoved": {
"placeholders": {
"device": {}
}
},
"localMusicSectionTitle": "Local music (Android Auto)",
"localMusicSectionDescription": "Pick a folder on this device to browse and play its audio files from the car.",
"localMusicFolderNotConfigured": "No folder selected",
"localMusicFolderTitle": "Local music folder",
"localMusicChoosePath": "Choose folder",
"localMusicChangePath": "Change folder",
"localMusicFolderUpdated": "Local music folder updated",
"localMusicFolderSaveError": "Could not save the folder: {error}",
"@localMusicFolderSaveError": {
"placeholders": {
"error": {}
}
},
"localMusicFolderGenericName": "Selected folder",
"welcomeHeadline": "Your world, live",
"welcomeBody": "53,412 stations from 238 countries. Save your favorites, take them to the car, and wake up to them.",
"welcomeBullet1Title": "Per-station equalizer",
"welcomeBullet1Subtitle": "Plus a preset per output device",
"welcomeBullet2Title": "Android Auto",
"welcomeBullet2Subtitle": "Your favorites and local music in the car",
"welcomeBullet3Title": "Music alarms",
"welcomeBullet3Subtitle": "With gradual volume rise and vacation mode",
"welcomeCtaLabel": "Start listening",
"eqCustomActionEnableLabel": "Enable equalizer",
"eqCustomActionDisableLabel": "Disable equalizer",
"eqCustomActionPresetLabel": "Preset: {preset}",
"@eqCustomActionPresetLabel": {
"placeholders": {
"preset": {
"type": "String"
}
}
},
"alarmCardVacationPausedBadge": "Paused for vacation",
"alarmCardSchedulingFailedMessage": "This alarm could not be registered with the system, so it may not ring.",
"alarmCardPreNoticeFailedMessage": "This alarm is scheduled, but its early reminder could not be set.",
"alarmDiagnosticsExactAlarmsTitle": "Exact alarm scheduling",
"alarmDiagnosticsExactAlarmsHint": "Lets the alarm ring at the exact minute you set, even while the phone is asleep.",
"alarmDiagnosticsNotificationsTitle": "Notifications",
"alarmDiagnosticsNotificationsHint": "Needed to show the alarm and the advance-warning notice.",
"alarmDiagnosticsFullScreenTitle": "Full-screen alarm display",
"alarmDiagnosticsFullScreenHint": "Lets the ringing screen appear automatically, even with the phone locked.",
"alarmDiagnosticsBatteryTitle": "Battery optimization",
"alarmDiagnosticsBatteryHint": "Stops the system from closing PluriWave in the background so the alarm can still fire.",
"alarmDiagnosticsNativeCountTitle": "Alarms registered with Android",
"alarmDiagnosticsNativeCountValue": "Currently registered: {count}",
"@alarmDiagnosticsNativeCountValue": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"alarmDiagnosticsNativeCountAttentionHint": "You have an alarm turned on, but none are registered with the system yet. Reopen PluriWave, or fix the items above first.",
"alarmDiagnosticsManufacturerLabel": "Manufacturer",
"alarmDiagnosticsSdkLabel": "Android version (SDK)",
"alarmDiagnosticsNeedsAttentionStatus": "Needs attention",
"alarmDiagnosticsAutostartTitle": "One more manual step on this phone",
"alarmDiagnosticsAutostartBody": "{manufacturer} phones often close apps running in the background to save battery. There is no setting PluriWave can switch on its own — you need to turn on Autostart (sometimes called \"Auto-start\" or \"Background activity\") for PluriWave yourself. Look in Settings, under Apps or Battery, or in the phone's own Security app.",
"@alarmDiagnosticsAutostartBody": {
"placeholders": {
"manufacturer": {
"type": "String"
}
}
},
"alarmDiagnosticsFixAction": "Fix this",
"alarmDiagnosticsIntentUnavailable": "Couldn't open that settings screen on this phone. Try looking for it manually in Settings.",
"alarmDiagnosticsUnavailableHint": "We couldn't check your alarm settings yet.",
"autoEqDisableOption": "Disable",
"funcionPremium": "Premium Feature",
"limiteAlarmasAlcanzado": "You've reached the free 5-alarm limit.",
"desbloquearPremium": "Unlock Premium",
"restaurarCompras": "Restore purchases",
"compraError": "We couldn't complete the purchase. Please try again.",
"restauracionSinCompras": "We didn't find any previous purchase on this account.",
"premiumActivo": "Premium active",
"premiumHojaTitulo": "Unlock PluriWave Premium",
"premiumBeneficioSinAnuncios": "No ads anywhere in the app",
"premiumBeneficioAndroidAuto": "Android Auto",
"premiumBeneficioGrabacion": "Station recording",
"premiumBeneficioVacaciones": "Vacation ranges for alarms",
"premiumBeneficioAlarmasIlimitadas": "Unlimited alarms (the free plan allows up to 5)",
"premiumPagoUnico": "One-time purchase, forever. Not a subscription.",
"premiumAhoraNo": "Not now",
"autoErrorEmisoraPremium": "This station is Premium. Open PluriWave on your phone to unlock it.",
"autoErrorBusquedaSinResultados": "We couldn't find that station. Try another name.",
"autoCarpetaEscuchar": "Listen",
"autoCarpetaFavoritos": "Favorites",
"autoCarpetaTodas": "All stations",
"autoCarpetaMisEmisoras": "My stations",
"autoCarpetaMusicaLocal": "Local music",
"autoMusicaLocalNoDisponible": "Open PluriWave on your phone to read your music",
"autoCargarMas": "More…",
"autoOrdenarPorCalidad": "Sort by quality",
"autoReproducirCarpeta": "Play folder",
"autoReproducirAleatorio": "Shuffle play",
"autoPistaSinNombre": "Untitled track"
}
+508 -49
View File
@@ -1,7 +1,7 @@
{
"@@locale": "es",
"appTitle": "PluriWave",
"navHome": "Inicio",
"navHome": "Escuchar",
"navSearch": "Buscar",
"navFavorites": "Favoritos",
"navAlarms": "Alarmas",
@@ -16,6 +16,33 @@
"hoursLabel": "Horas",
"minutesLabel": "Minutos",
"secondsLabel": "Segundos",
"durationHoursMinutesSeconds": "{hours} h {minutes} min {seconds} s",
"@durationHoursMinutesSeconds": {
"placeholders": {
"hours": {},
"minutes": {},
"seconds": {}
}
},
"durationMinutesSeconds": "{minutes} min {seconds} s",
"@durationMinutesSeconds": {
"placeholders": {
"minutes": {},
"seconds": {}
}
},
"durationMinutesOnly": "{minutes} min",
"@durationMinutesOnly": {
"placeholders": {
"minutes": {}
}
},
"durationSecondsOnly": "{seconds} s",
"@durationSecondsOnly": {
"placeholders": {
"seconds": {}
}
},
"saveQuickAccess": "Guardar como acceso rápido",
"startTimer": "Iniciar timer",
"skipCurrentAlarmExecution": "Omitida esta ejecución de {alarmName}.",
@@ -26,6 +53,11 @@
},
"settingsTitle": "Ajustes",
"settingsSubtitle": "Control fino de sonido, copias de seguridad y emisoras personalizadas.",
"settingsGroupAudioTitle": "AUDIO",
"settingsGroupStationsTitle": "EMISORAS",
"settingsGroupRecordingsTitle": "GRABACIONES Y MÚSICA",
"settingsGroupApplicationTitle": "APLICACIÓN",
"infoSectionTitle": "Información",
"languageSectionTitle": "Idioma",
"languageSectionDescription": "Elegí cómo se muestra el idioma de la app.",
"languageSystemDefault": "Sistema",
@@ -63,6 +95,18 @@
"equalizerPerStationTitle": "Usar EQ propio para esta favorita",
"equalizerPerStationActive": "Activo para {stationName}",
"equalizerPerStationMain": "Usando EQ principal para {stationName}",
"equalizerBaseExplainer": "Este es el ecualizador base: se aplica a todas las emisoras que no tengan un ajuste propio. El de una emisora concreta se define desde su propia reproducción y manda sobre este.",
"equalizerActiveOutputLabel": "Salida activa",
"equalizerActiveOutputDefault": "El altavoz de este dispositivo",
"equalizerStationsWithOwnEqTitle": "Emisoras con ajuste propio",
"equalizerStationsWithOwnEqSubtitle": "Ignoran este ecualizador base",
"equalizerStationsWithOwnEqEmpty": "Ninguna emisora tiene ajuste propio todavía.",
"equalizerSaveAsPresetAction": "Guardar como preset",
"equalizerResetToFlatAction": "Restablecer a plano",
"equalizerSavePresetDialogTitle": "Guardar como preset",
"equalizerSavePresetNameLabel": "Nombre del preset",
"equalizerSavePresetEmptyNameError": "Ingresá un nombre para el preset.",
"equalizerSavePresetConfirm": "Guardar",
"preferredStationTitle": "Emisora preferida",
"preferredStationDescription": "Se preselecciona al crear alarmas y puede iniciarse como reproducción rápida.",
"preferredStationNoStationsTitle": "Todavía no hay emisoras disponibles",
@@ -79,6 +123,7 @@
"deleteAction": "Eliminar",
"addStationTitle": "Añadir emisora",
"stationNameLabel": "Nombre *",
"unnamedStation": "Sin nombre",
"requiredField": "Campo obligatorio",
"streamUrlLabel": "URL del stream *",
"invalidUrl": "URL no válida",
@@ -176,9 +221,34 @@
}
}
},
"recordingsLibraryTitle": "Mis grabaciones",
"recordingsLibraryStorageCaption": "{usedMb} MB de {totalMb} MB usados",
"@recordingsLibraryStorageCaption": {
"placeholders": {
"usedMb": {
"type": "int"
},
"totalMb": {
"type": "int"
}
}
},
"recordingsLibraryStorageFolderCaption": "Music/PluriWave · se borran las más antiguas al llegar al límite",
"recordingsLibraryEmptyTitle": "Todavía no hay grabaciones",
"recordingsLibraryEmptySubtitle": "Las grabaciones que guardes van a aparecer acá.",
"recordingActionRename": "Renombrar",
"recordingActionShare": "Compartir",
"recordingActionDelete": "Eliminar",
"recordingRenameDialogTitle": "Renombrar grabación",
"recordingRenameLabel": "Nombre",
"recordingRenameEmptyError": "Ingresá un nombre",
"recordingDeleteConfirmTitle": "¿Eliminar grabación?",
"recordingDeleteConfirmMessage": "Esta acción no se puede deshacer.",
"recordingsLibrarySettingsTooltip": "Ajustes de grabación",
"stationOrderTitle": "Orden de emisoras",
"stationOrderByName": "Por nombre",
"stationOrderByQuality": "Por calidad",
"stationOrderByPopularity": "Por popularidad",
"stationOrderScopeDescription": "Se aplica a favoritos, búsquedas, emisoras cercanas y listados rápidos.",
"favoriteGroupsTitle": "Listas de favoritos",
"favoriteGroupsDescription": "Creá listas cortas para organizar tus emisoras guardadas.",
@@ -226,38 +296,60 @@
"stationName": {}
}
},
"alarmPostponedCurrentExecution": "Alarma pospuesta para esta ejecuci?n.",
"searchScreenTitle": "Buscar se?al",
"searchScreenSubtitle": "Encontr? radios por nombre, pa?s o idioma con filtros r?pidos y alto contraste.",
"favoritesFilterAllLabel": "Todas",
"favoriteGroupsChipLabel": "{groupName} · {count}",
"@favoriteGroupsChipLabel": {
"placeholders": {
"groupName": {},
"count": {
"type": "int"
}
}
},
"favoriteGroupsManage": "Gestionar listas",
"customStationsAddCta": "Añadir emisora personalizada",
"alarmPostponedCurrentExecution": "Alarma pospuesta para esta ejecución.",
"searchScreenTitle": "Buscar señal",
"searchScreenSubtitle": "Encontrá radios por nombre, país o idioma con filtros rápidos y alto contraste.",
"searchFiltersLabel": "Filtros",
"searchHint": "Radio Horizonte, jazz, noticias...",
"searchCountryFilterLabel": "Pa?s",
"searchCountryFilterLabel": "País",
"searchLanguageFilterLabel": "Idioma",
"searchMinQualityFilterLabel": "Calidad m?nima",
"searchEmptyTitle": "Busc? una emisora",
"searchMinQualityFilterLabel": "Calidad mínima",
"searchLoadingStationsLabel": "BUSCANDO EMISORAS…",
"searchEmptyTitle": "Buscá una emisora",
"searchNoResultsTitle": "Sin resultados",
"searchEmptySubtitle": "Us? la barra superior o los chips para descubrir se?ales de todo el mundo.",
"searchNoResultsSubtitle": "Prob? quitar filtros o escribir otro nombre para encontrar una se?al activa.",
"countrySpain": "Espa?a",
"searchNoResultsForQueryTitle": "Sin resultados para «{query}»",
"@searchNoResultsForQueryTitle": {"placeholders": {"query": {}}},
"searchEmptySubtitle": "Usá la barra superior o los chips para descubrir señales de todo el mundo.",
"searchNoResultsSubtitle": "Probá quitar filtros o escribir otro nombre para encontrar una señal activa.",
"searchResultsCount": "{count, plural, =1{1 resultado} other{{count} resultados}}",
"searchClearFiltersAction": "{count, plural, =1{Quitar el filtro} other{Quitar los {count} filtros}}",
"countriesScreenTitle": "Países",
"countriesSearchHint": "País o código...",
"countriesYourLanguagesTitle": "Tus idiomas",
"countriesAllTitle": "Todos los países",
"radioCountriesError": "No pudimos cargar los países.",
"countrySpain": "España",
"countryUsa": "EE. UU.",
"countryMexico": "M?xico",
"countryMexico": "México",
"countryArgentina": "Argentina",
"countryUk": "Reino Unido",
"countryFrance": "Francia",
"countryGermany": "Alemania",
"countryItaly": "Italia",
"countryBrazil": "Brasil",
"countryJapan": "Jap?n",
"languageNameSpanish": "Espa?ol",
"languageNameEnglish": "Ingl?s",
"languageNameFrench": "Franc?s",
"languageNameGerman": "Alem?n",
"languageNamePortuguese": "Portugu?s",
"countryJapan": "Japón",
"languageNameSpanish": "Español",
"languageNameEnglish": "Inglés",
"languageNameFrench": "Francés",
"languageNameGerman": "Alemán",
"languageNamePortuguese": "Portugués",
"languageNameItalian": "Italiano",
"languageNameJapanese": "Japon?s",
"languageNameArabic": "?rabe",
"languageNameJapanese": "Japonés",
"languageNameArabic": "Árabe",
"languageNameRussian": "Ruso",
"homeScreenSubtitle": "Radio global en vivo con se?ales limpias, favoritos inteligentes y una experiencia visual de concurso.",
"homeScreenSubtitle": "Radio global en vivo con señales limpias, favoritos inteligentes y una experiencia visual de concurso.",
"exploreStations": "Explorar emisoras",
"stationsCount": "{count} radios",
"@stationsCount": {
@@ -268,8 +360,14 @@
}
},
"qualityHd": "Calidad HD",
"yourStationsTitle": "Tus emisoras",
"seeAllAction": "Ver todas",
"openFullPlayerTooltip": "Abrir reproductor completo",
"nowListeningLabel": "Escuchando ahora",
"nothingPlayingTitle": "Todavía no estás escuchando nada",
"nothingPlayingSubtitle": "Elegí una emisora de Tus emisoras o buscá una para empezar.",
"nearYou": "Cerca de vos",
"nearYouInCountry": "Cerca de vos ? {country}",
"nearYouInCountry": "Cerca de vos · {country}",
"@nearYouInCountry": {
"placeholders": {
"country": {}
@@ -277,15 +375,22 @@
},
"detectAction": "Detectar",
"liveRadar": "Radar en directo",
"genresTitle": "G?neros",
"genresTitle": "Géneros",
"exploreByTitle": "Explorar por",
"exploreTrendingTitle": "Tendencias",
"exploreTrendingSubtitle": "Hoy",
"exploreNewTitle": "Novedades",
"exploreNewSubtitle": "Esta semana",
"popularNowTitle": "Populares ahora",
"offlineBannerTitle": "Sin conexión",
"retryAction": "Reintentar",
"noStationsAvailable": "No hay emisoras disponibles",
"noStationsAvailableSubtitle": "Prob? refrescar o elegir otro g?nero para volver a capturar se?al.",
"noStationsAvailableSubtitle": "Probá refrescar o elegir otro género para volver a capturar señal.",
"genrePop": "Pop",
"genreRock": "Rock",
"genreJazz": "Jazz",
"genreClassical": "Cl?sica",
"genreElectronic": "Electr?nica",
"genreClassical": "Clásica",
"genreElectronic": "Electrónica",
"genreNews": "Noticias",
"genreTalk": "Charlas",
"genreHipHop": "Hip-hop",
@@ -294,7 +399,7 @@
"genreReggae": "Reggae",
"genreLatin": "Latina",
"alarmScreenTitle": "Despertar musical",
"alarmScreenSubtitle": "Alarmas con radio, sonido seguro, vacaciones inteligentes y pr?xima ejecuci?n siempre visible.",
"alarmScreenSubtitle": "Alarmas con radio, sonido seguro, vacaciones inteligentes y próxima ejecución siempre visible.",
"createAlarmAction": "Crear alarma",
"alarmsCount": "{count} alarmas",
"@alarmsCount": {
@@ -304,10 +409,10 @@
}
}
},
"activeAlarmsWithoutNextTitle": "Alarmas activas sin pr?xima ejecuci?n",
"activeAlarmsWithoutNextTitle": "Alarmas activas sin próxima ejecución",
"noActiveAlarms": "Sin alarmas activas",
"nextAlarmTitle": "Pr?xima alarma",
"activeAlarmsWithoutNextSubtitle": "Hay {count} alarma(s) activas, pero ahora mismo no tienen una fecha futura v?lida. Revis? fecha, d?as y vacaciones.",
"nextAlarmTitle": "Próxima alarma",
"activeAlarmsWithoutNextSubtitle": "Hay {count} alarma(s) activas, pero ahora mismo no tienen una fecha futura válida. Revisá fecha, días y vacaciones.",
"@activeAlarmsWithoutNextSubtitle": {
"placeholders": {
"count": {
@@ -315,7 +420,7 @@
}
}
},
"createAlarmHint": "Cre? una alarma y PluriWave calcular? la siguiente ejecuci?n autom?ticamente.",
"createAlarmHint": "Creá una alarma y PluriWave calculará la siguiente ejecución automáticamente.",
"alarmVacationPlay": "Suena en vacaciones",
"alarmVacationPause": "Pausa en vacaciones",
"alarmFadeInLabel": "Fade-in {seconds}s",
@@ -326,14 +431,14 @@
}
}
},
"alarmNextExecution": "Siguiente ejecuci?n: {date}",
"alarmNextExecution": "Siguiente ejecución: {date}",
"@alarmNextExecution": {
"placeholders": {
"date": {}
}
},
"alarmNoNextExecution": "No tiene pr?xima ejecuci?n activa.",
"alarmSkippedExecution": "Una ejecuci?n fue omitida: {date}.",
"alarmNoNextExecution": "No tiene próxima ejecución activa.",
"alarmSkippedExecution": "Una ejecución fue omitida: {date}.",
"@alarmSkippedExecution": {
"placeholders": {
"date": {}
@@ -342,27 +447,30 @@
"editAction": "Editar",
"skipNextAction": "Omitir siguiente",
"deleteTooltip": "Eliminar",
"alarmSkippedNoNextSnackbar": "Alarma omitida. No queda pr?xima ejecuci?n.",
"alarmSkippedReturnsSnackbar": "Alarma omitida. Volver? el {date}.",
"alarmHeroSkipAction": "Saltar",
"alarmDeleteConfirmTitle": "¿Eliminar alarma?",
"alarmDeleteConfirmMessage": "Esta acción no se puede deshacer.",
"alarmSkippedNoNextSnackbar": "Alarma omitida. No queda próxima ejecución.",
"alarmSkippedReturnsSnackbar": "Alarma omitida. Volverá el {date}.",
"@alarmSkippedReturnsSnackbar": {
"placeholders": {
"date": {}
}
},
"alarmVacationPausedNoNext": "Est? pausada por vacaciones ({vacationName}) y sin pr?xima ejecuci?n.",
"alarmVacationPausedNoNext": "Está pausada por vacaciones ({vacationName}) y sin próxima ejecución.",
"@alarmVacationPausedNoNext": {
"placeholders": {
"vacationName": {}
}
},
"alarmVacationPausedReturns": "Est? pausada por vacaciones ({vacationName}) y vuelve el {date}.",
"alarmVacationPausedReturns": "Está pausada por vacaciones ({vacationName}) y vuelve el {date}.",
"@alarmVacationPausedReturns": {
"placeholders": {
"vacationName": {},
"date": {}
}
},
"alarmVacationReturns": "Con vacaciones activas, volver? a sonar el {date}.",
"alarmVacationReturns": "Con vacaciones activas, volverá a sonar el {date}.",
"@alarmVacationReturns": {
"placeholders": {
"date": {}
@@ -376,10 +484,10 @@
"dateField": "Fecha",
"onceOption": "Una vez",
"dailyOption": "Diaria",
"weekdaysOption": "D?as",
"weekdaysOption": "Días",
"soundAndVolumeSection": "Sonido y volumen",
"alarmFadeInTitle": "Fade-in de alarma",
"alarmFadeInOff": "0 s (sin transici?n)",
"alarmFadeInOff": "0 s (sin transición)",
"alarmFadeInSummary": "{seconds} s (de 5% al volumen elegido)",
"@alarmFadeInSummary": {
"placeholders": {
@@ -389,21 +497,34 @@
}
},
"internalSafeSoundLabel": "Sonido seguro interno",
"soundWarmSunrise": "Amanecer c?lido",
"soundWarmSunrise": "Amanecer cálido",
"soundSoftBell": "Campana suave",
"soundDigitalPulse": "Pulso digital",
"favoriteStationLabel": "Emisora favorita",
"noStationUseInternalSound": "Sin emisora: usar sonido interno",
"saveFavoritesAlarmHint": "Guard? emisoras en Favoritos para usarlas como alarma musical.",
"alarmFallbackStationLabel": "Emisora de respaldo",
"alarmStationPickerSearchHint": "Buscá una emisora por nombre",
"alarmSnoozeDurationTitle": "Duración de la posposición",
"snoozeAction": "Posponer",
"alarmSnoozeOptionLabel": "{minutes} min",
"@alarmSnoozeOptionLabel": {
"placeholders": {
"minutes": {
"type": "int"
}
}
},
"alarmSnoozeUsualLabel": "habitual",
"saveFavoritesAlarmHint": "Guardá emisoras en Favoritos para usarlas como alarma musical.",
"useCurrentStationAction": "Usar emisora actual",
"playDuringVacations": "Sonar durante vacaciones",
"playDuringVacationsHint": "Si lo apag?s, la pr?xima ejecuci?n saltar? al primer d?a v?lido.",
"playDuringVacationsHint": "Si lo apagás, la próxima ejecución saltará al primer día válido.",
"saveAlarmAction": "Guardar alarma",
"chooseOneWeekdayError": "Eleg? al menos un d?a de la semana.",
"chooseOneWeekdayError": "Elegí al menos un día de la semana.",
"androidReliabilityReview": "Revisar fiabilidad Android",
"statusOk": "OK",
"statusPending": "pendiente",
"androidReliabilityStatus": "Fiabilidad: exactas {exact} ? notificaciones {notifications} ? pantalla {screen}",
"androidReliabilityStatus": "Fiabilidad: exactas {exact} · notificaciones {notifications} · pantalla {screen}",
"@androidReliabilityStatus": {
"placeholders": {
"exact": {},
@@ -413,17 +534,355 @@
},
"vacationRangesTitle": "Rangos de vacaciones",
"addAction": "Agregar",
"vacationRangesHint": "Si una alarma tiene \"Pausa en vacaciones\", se salta autom?ticamente estos rangos.",
"vacationRangesHint": "Si una alarma tiene \"Pausa en vacaciones\", se salta automáticamente estos rangos.",
"noVacationRangesLoaded": "Sin rangos cargados.",
"deleteRangeTooltip": "Eliminar rango",
"vacationRangesCount": "{count} rangos",
"@vacationRangesCount": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"vacationSummaryActiveCountdown": "En curso · quedan {days} días",
"@vacationSummaryActiveCountdown": {
"placeholders": {
"days": {
"type": "int"
}
}
},
"vacationSummaryUpcomingCountdown": "Próximo rango en {days} días",
"@vacationSummaryUpcomingCountdown": {
"placeholders": {
"days": {
"type": "int"
}
}
},
"vacationImpactPausedLabel": "Pausa: {times}",
"@vacationImpactPausedLabel": {
"placeholders": {
"times": {}
}
},
"vacationImpactContinuesLabel": "Sigue sonando: {times}",
"@vacationImpactContinuesLabel": {
"placeholders": {
"times": {}
}
},
"vacationUpcomingSectionTitle": "PROGRAMADOS",
"vacationPastSectionTitle": "Rangos pasados",
"addVacationRangeCta": "Añadir rango",
"vacationExplainerBanner": "Durante estos rangos no suenan las alarmas marcadas como \"pausar en vacaciones\". Las marcadas como \"sonar siempre\" no se ven afectadas.",
"vacationNoActiveRangeHint": "No hay un rango de vacaciones activo ahora mismo.",
"vacationsDefaultName": "Vacaciones",
"newVacationRangeTitle": "Nuevo rango de vacaciones",
"editVacationRangeTitle": "Editar rango de vacaciones",
"vacationDeleteConfirmTitle": "¿Eliminar rango de vacaciones?",
"vacationDeleteConfirmMessage": "Esta acción no se puede deshacer.",
"startField": "Inicio",
"endField": "Fin",
"saveRangeAction": "Guardar rango",
"noAlarmsYetTitle": "Todav?a no hay alarmas.",
"noAlarmsYetSubtitle": "Cre? una para dise?ar tu despertar musical.",
"noAlarmsYetTitle": "Todavía no hay alarmas.",
"noAlarmsYetSubtitle": "Creá una para diseñar tu despertar musical.",
"ringingInternalAudioActive": "Sonando con audio seguro interno.",
"ringingPreparingInternalAudio": "Preparando audio seguro interno.",
"stopAlarmAction": "Detener alarma"
"stopAlarmAction": "Detener alarma",
"alarmStopFailedMessage": "No pudimos confirmar que la alarma se detuvo. Inténtalo de nuevo.",
"alarmForceStopAction": "Forzar detención",
"alarmMissedNotificationTitle": "Alarma perdida",
"alarmMissedNotificationText": "{name} se silenció automáticamente después de 10 minutos.",
"@alarmMissedNotificationText": {"placeholders": {"name": {}}},
"pauseAction": "Pausar",
"miniPlayerOpenLabel": "Abrir reproductor de {stationName}",
"@miniPlayerOpenLabel": {"placeholders": {"stationName": {}}},
"playerIconLabel": "Reproductor",
"playbackStatusConnecting": "Conectando...",
"playbackStatusLive": "En directo",
"playbackStatusPaused": "Pausado",
"playbackStatusReconnecting": "Reconectando...",
"playbackStatusConnectionError": "Error de conexión",
"playbackStatusStopped": "Detenido",
"stationSemanticLabel": "Emisora {stationName}",
"@stationSemanticLabel": {"placeholders": {"stationName": {}}},
"favoritesAddTooltip": "Añadir a favoritos",
"favoritesAddedMessage": "{stationName} añadida a favoritos",
"@favoritesAddedMessage": {"placeholders": {"stationName": {}}},
"stationIconLabel": "Icono de emisora",
"liveNow": "En vivo",
"equalizerBandLabel": "Banda {band}",
"@equalizerBandLabel": {"placeholders": {"band": {}}},
"equalizerBandValue": "{value} decibelios",
"@equalizerBandValue": {"placeholders": {"value": {}}},
"equalizerPresetFlat": "Plano",
"equalizerPresetRock": "Rock",
"equalizerPresetPop": "Pop",
"equalizerPresetBassBoost": "Refuerzo de graves",
"equalizerPresetJazz": "Jazz",
"equalizerPresetVoice": "Voz",
"equalizerPresetCustom": "Personalizado",
"onboardingTitle": "Bienvenido a PluriWave",
"onboardingNewsTitle": "Novedades",
"onboardingStartAction": "Empezar",
"onboardingCloseTooltip": "Cerrar",
"radioRecordingError": "Error al grabar la radio: {error}",
"@radioRecordingError": {"placeholders": {"error": {}}},
"radioApiConnectionError": "Sin conexión a la API de radio",
"radioSearchError": "Error en la búsqueda. Comprueba tu conexión.",
"radioLoadMoreStationsError": "No se pudieron cargar más emisoras.",
"radioNearbyStationsError": "No pudimos detectar emisoras cercanas. Usa filtros por país.",
"radioCannotPlayStation": "No se puede reproducir \"{stationName}\"",
"@radioCannotPlayStation": {"placeholders": {"stationName": {}}},
"recordingSelectStationFirst": "Primero selecciona una emisora para grabar.",
"recordingStartError": "No se pudo iniciar la grabación: {error}",
"@recordingStartError": {"placeholders": {"error": {}}},
"unsupportedConfigVersion": "Versión de configuración no compatible",
"audioErrorGeneric": "Error de reproducción",
"audioErrorNoInternet": "Sin conexión a internet",
"audioErrorInvalidUrl": "La URL de la radio no es válida",
"audioErrorNotFound": "La radio no está disponible (error 404)",
"audioErrorTimeout": "Tiempo de espera agotado al conectar",
"audioErrorCannotConnect": "No se puede conectar a la radio",
"audioErrorUnsupportedFormat": "Formato de stream no compatible",
"audioErrorDecode": "Error al decodificar el stream de audio",
"audioErrorCleartext": "Esta radio usa HTTP sin cifrar, y no está permitido",
"audioErrorSsl": "Certificado SSL inválido en la radio",
"audioErrorCannotPlay": "No se puede reproducir esta radio",
"audioErrorUnexpectedPlayback": "Error inesperado al reproducir",
"androidExactAlarmScheduleError": "Android no pudo programar una alarma exacta. Revisa el permiso de alarmas exactas.",
"recordingPathEmptyError": "La ruta de grabación no puede estar vacía",
"recordingMaxSizeInvalidError": "El tamaño máximo debe ser mayor que cero",
"recordingAlreadyActiveError": "Ya hay una grabación en curso",
"alarmRingingFallbackActive": "Sonando con audio seguro interno.",
"alarmRingingPreparingFallback": "Preparando audio seguro interno.",
"alarmRingingTryingStation": "Intentando reproducir tu emisora con máxima calidad disponible.",
"alarmScheduleDaily": "Cada mañana",
"alarmScheduleOnce": "Una vez · {date}",
"@alarmScheduleOnce": {"placeholders": {"date": {}}},
"alarmScheduleWeekdays": "Días: {days}",
"@alarmScheduleWeekdays": {"placeholders": {"days": {}}},
"alarmRepeatSectionLabel": "REPETIR",
"alarmVolumeLabel": "Volumen",
"androidReliabilityTitle": "Revisar fiabilidad Android",
"closeAction": "Cerrar",
"customOption": "Personalizada",
"endLabel": "Fin",
"equalizerDisable": "Desactivar ecualizador",
"helpTitle": "Ayuda y tutorial",
"helpSubtitle": "9 pantallas · vuelve a verlo cuando quieras",
"tutorialSkipAction": "Saltar",
"tutorialNextAction": "Siguiente",
"tutorialPage1Headline": "Guarda tus emisoras y agrúpalas",
"tutorialPage1Body": "Toca el corazón para guardar una emisora. En «Tus emisoras» puedes crear grupos como «Cada mañana» o «Coche» y reordenarlas arrastrando.",
"tutorialPage2Headline": "Un ecualizador base y otro por emisora",
"tutorialPage2Body": "En Ajustes defines el ecualizador general. Y desde la reproducción de una emisora concreta puedes darle su propio ajuste, que manda sobre el general.",
"tutorialPage3Headline": "Graba lo que estás escuchando",
"tutorialPage3Body": "Desde la bandeja de herramientas del reproductor, «Grabar» guarda el stream original. Encuentra tus grabaciones en Ajustes › Grabaciones.",
"tutorialPage4Headline": "Alarmas que se adaptan a ti",
"tutorialPage4Body": "Despiértate con tu emisora favorita, pospón 3, 5 o 10 minutos, y añade rangos de vacaciones para que algunas alarmas se salten solas.",
"tutorialPage5Headline": "Tus favoritas, también en el coche",
"tutorialPage5Body": "Conecta el móvil con Android Auto y encontrarás Favoritos, Todas las emisoras, Mis emisoras y tu Música Local, con botones grandes pensados para conducir.",
"tutorialPage6Headline": "Se reconecta sola",
"tutorialPage6Body": "Si se corta la señal, PluriWave reintenta automáticamente y sigue mostrando tus favoritas guardadas aunque no tengas conexión.",
"tutorialPage7Headline": "Elige cuánto posponer",
"tutorialPage7Body": "Cuando suene una alarma, no hay un único «posponer»: eliges 3, 5 o 10 minutos según lo que necesites en ese momento.",
"tutorialPage8Headline": "¿No la encuentras? Añádela tú",
"tutorialPage8Body": "En «Tus emisoras» → Añadir emisora personalizada pega la URL del stream de una emisora que no esté en el buscador. Se guarda en «Mis emisoras», también disponible en el coche.",
"tutorialPage9Headline": "Listo, ya conoces lo esencial",
"tutorialPage9BannerBody": "Para volver a ver este tutorial cuando quieras: Ajustes → Información → Ayuda y tutorial.",
"indefiniteOption": "Indefinida",
"invalidNumber": "Número inválido",
"nameLabel": "Nombre",
"notPlaying": "No está reproduciendo",
"oneTimeOption": "Una vez",
"pausePlaybackTooltip": "Pausar reproducción",
"playerQualityChangeAction": "Cambiar",
"playerToolEqLabel": "EQ propio",
"qualityOriginal": "Calidad original: {quality}",
"@qualityOriginal": {"placeholders": {"quality": {}}},
"qualityUnknown": "Calidad no informada",
"recordAction": "Grabar",
"recordDurationTitle": "Duración de grabación",
"recordRadioSubtitle": "Elegí cuánto tiempo querés grabar.",
"recordRadioTitle": "Grabar radio",
"recordingActiveTitle": "Grabando radio",
"recordingDirectTitle": "Grabación directa",
"recordingsOpenFolderPlainError": "No se pudo abrir la carpeta de grabaciones",
"recordingsOpenLatest": "Abrir última grabación",
"recordingsOpenLatestError": "No se pudo abrir la última grabación",
"startLabel": "Inicio",
"startPlaybackTooltip": "Iniciar reproducción",
"stopAction": "Parar",
"stopPlaybackTooltip": "Detener reproducción",
"weekdayShortMonday": "Lun",
"weekdayShortTuesday": "Mar",
"weekdayShortWednesday": "Mié",
"weekdayShortThursday": "Jue",
"weekdayShortFriday": "Vie",
"weekdayShortSaturday": "Sáb",
"weekdayShortSunday": "Dom",
"stationCount": "{count, plural, =1{1 emisora} other{{count} emisoras}}",
"alarmIconLabel": "Alarma musical",
"vacationIconLabel": "Modo vacaciones",
"alarmAdvancedSectionTitle": "Avanzado",
"alarmInlineHourLabel": "Hora",
"alarmInlineMinuteLabel": "Minuto",
"alarmVolumeRisingStatus": "Subiendo volumen",
"streamUrlHint": "https://stream.example.com:8000/radio",
"@stationCount": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"advancedEqSectionTitle": "Opciones avanzadas de ecualización",
"advancedEqEnableToggle": "Activar EQ por dispositivo",
"advancedEqEnableToggleSubtitle": "Aplica automáticamente un preset de EQ distinto al cambiar la salida de audio.",
"advancedEqKnownDevicesTitle": "Dispositivos de audio conocidos",
"advancedEqKnownDevicesEmpty": "No hay dispositivos registrados. Conectá uno para empezar.",
"advancedEqDevicePresetLabel": "Preset: {presetName}",
"@advancedEqDevicePresetLabel": {
"placeholders": {
"presetName": {}
}
},
"preNoticeCountdown": "Empieza en {minutes} min",
"snoozeCountdown": "Suena en {minutes} min",
"@snoozeCountdown": {
"placeholders": {
"minutes": {
"type": "int"
}
}
},
"snoozeAgainAction": "Posponer otra vez",
"alarmRingingNotificationTitle": "Alarma PluriWave",
"alarmFireChannelName": "Alarmas sonando",
"alarmFireChannelDescription": "Sonido y pantalla urgente cuando una alarma musical debe sonar",
"alarmPreNoticeChannelName": "Preavisos de alarmas",
"alarmPreNoticeChannelDescription": "Notificaciones silenciosas antes de la alarma",
"openFolderChooserTitle": "Abrir carpeta",
"openRecordingChooserTitle": "Abrir grabación",
"@preNoticeCountdown": {
"placeholders": {
"minutes": {
"type": "int"
}
}
},
"eqDeviceEditTitle": "Editar dispositivo",
"eqDeviceNameLabel": "Nombre del dispositivo",
"eqDeviceNameHint": "Ej: Altavoz del living",
"eqDeviceNameConfirm": "Guardar",
"eqDeviceConnected": "Conectado",
"eqDeviceActiveOutput": "El audio está saliendo por este dispositivo",
"eqDeviceRemove": "Quitar dispositivo",
"eqDeviceRemoved": "Se quitó {device}",
"@eqDeviceRemoved": {
"placeholders": {
"device": {}
}
},
"localMusicSectionTitle": "Música local (Android Auto)",
"localMusicSectionDescription": "Elegí una carpeta de este dispositivo para explorar y reproducir sus archivos de audio desde el auto.",
"localMusicFolderNotConfigured": "No hay carpeta seleccionada",
"localMusicFolderTitle": "Carpeta de música local",
"localMusicChoosePath": "Elegir carpeta",
"localMusicChangePath": "Cambiar carpeta",
"localMusicFolderUpdated": "Carpeta de música local actualizada",
"localMusicFolderSaveError": "No se pudo guardar la carpeta: {error}",
"@localMusicFolderSaveError": {
"placeholders": {
"error": {}
}
},
"localMusicFolderGenericName": "Carpeta seleccionada",
"welcomeHeadline": "Tu mundo, en directo",
"welcomeBody": "53.412 emisoras de 238 países. Guardá tus favoritas, llevalas al auto y despertate con ellas.",
"welcomeBullet1Title": "Ecualizador por emisora",
"welcomeBullet1Subtitle": "Y un preset por dispositivo de salida",
"welcomeBullet2Title": "Android Auto",
"welcomeBullet2Subtitle": "Tus favoritas y tu música local en el auto",
"welcomeBullet3Title": "Alarmas musicales",
"welcomeBullet3Subtitle": "Con subida progresiva y modo vacaciones",
"welcomeCtaLabel": "Empezar a escuchar",
"eqCustomActionEnableLabel": "Activar ecualizador",
"eqCustomActionDisableLabel": "Desactivar ecualizador",
"eqCustomActionPresetLabel": "Preset: {preset}",
"@eqCustomActionPresetLabel": {
"placeholders": {
"preset": {
"type": "String"
}
}
},
"alarmCardVacationPausedBadge": "Pausada por vacaciones",
"alarmCardSchedulingFailedMessage": "Esta alarma no se pudo registrar en el sistema, así que podría no sonar.",
"alarmCardPreNoticeFailedMessage": "Esta alarma está programada, pero no se pudo activar su aviso previo.",
"alarmDiagnosticsExactAlarmsTitle": "Programación de alarma exacta",
"alarmDiagnosticsExactAlarmsHint": "Permite que la alarma suene en el minuto exacto que elegiste, incluso con el teléfono en reposo.",
"alarmDiagnosticsNotificationsTitle": "Notificaciones",
"alarmDiagnosticsNotificationsHint": "Necesarias para mostrar la alarma y el aviso previo.",
"alarmDiagnosticsFullScreenTitle": "Pantalla completa de la alarma",
"alarmDiagnosticsFullScreenHint": "Permite que la pantalla de la alarma aparezca automáticamente, incluso con el teléfono bloqueado.",
"alarmDiagnosticsBatteryTitle": "Optimización de batería",
"alarmDiagnosticsBatteryHint": "Evita que el sistema cierre PluriWave en segundo plano para que la alarma pueda sonar.",
"alarmDiagnosticsNativeCountTitle": "Alarmas registradas en Android",
"alarmDiagnosticsNativeCountValue": "Registradas ahora mismo: {count}",
"@alarmDiagnosticsNativeCountValue": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"alarmDiagnosticsNativeCountAttentionHint": "Tenés una alarma activada, pero todavía ninguna está registrada en el sistema. Volvé a abrir PluriWave o solucioná primero los puntos de arriba.",
"alarmDiagnosticsManufacturerLabel": "Fabricante",
"alarmDiagnosticsSdkLabel": "Versión de Android (SDK)",
"alarmDiagnosticsNeedsAttentionStatus": "Necesita atención",
"alarmDiagnosticsAutostartTitle": "Un paso manual más en este teléfono",
"alarmDiagnosticsAutostartBody": "Los teléfonos {manufacturer} suelen cerrar las apps que están en segundo plano para ahorrar batería. No hay ningún ajuste que PluriWave pueda activar por su cuenta: tenés que activar vos mismo el Inicio automático (a veces llamado \"Autostart\" o \"Actividad en segundo plano\"). Buscalo en Ajustes, dentro de Apps o Batería, o en la app de Seguridad del teléfono.",
"@alarmDiagnosticsAutostartBody": {
"placeholders": {
"manufacturer": {
"type": "String"
}
}
},
"alarmDiagnosticsFixAction": "Solucionar",
"alarmDiagnosticsIntentUnavailable": "No se pudo abrir esa pantalla de ajustes en este teléfono. Buscala manualmente en Ajustes.",
"alarmDiagnosticsUnavailableHint": "Todavía no pudimos revisar tus ajustes de alarma.",
"autoEqDisableOption": "Desactivar",
"funcionPremium": "Función Premium",
"limiteAlarmasAlcanzado": "Has alcanzado el límite de 5 alarmas gratuitas.",
"desbloquearPremium": "Desbloquear Premium",
"restaurarCompras": "Restaurar compras",
"compraError": "No se ha podido completar la compra. Inténtalo de nuevo.",
"restauracionSinCompras": "No hemos encontrado ninguna compra anterior en esta cuenta.",
"premiumActivo": "Premium activo",
"premiumHojaTitulo": "Desbloquea PluriWave Premium",
"premiumBeneficioSinAnuncios": "Sin publicidad en toda la app",
"premiumBeneficioAndroidAuto": "Android Auto",
"premiumBeneficioGrabacion": "Grabación de emisoras",
"premiumBeneficioVacaciones": "Rangos de vacaciones para las alarmas",
"premiumBeneficioAlarmasIlimitadas": "Alarmas ilimitadas (el plan gratuito permite hasta 5)",
"premiumPagoUnico": "Pago único, para siempre. No es una suscripción.",
"premiumAhoraNo": "Ahora no",
"autoErrorEmisoraPremium": "Esta emisora es Premium. Abre PluriWave en el móvil para desbloquearla.",
"autoErrorBusquedaSinResultados": "No hemos encontrado esa emisora. Prueba con otro nombre.",
"autoCarpetaEscuchar": "Escuchar",
"autoCarpetaFavoritos": "Favoritos",
"autoCarpetaTodas": "Todas las emisoras",
"autoCarpetaMisEmisoras": "Mis emisoras",
"autoCarpetaMusicaLocal": "Música Local",
"autoMusicaLocalNoDisponible": "Abre PluriWave en el móvil para leer tu música",
"autoCargarMas": "Más…",
"autoOrdenarPorCalidad": "Ordenar por calidad",
"autoReproducirCarpeta": "Reproducir carpeta",
"autoReproducirAleatorio": "Reproducir aleatorio",
"autoPistaSinNombre": "Pista sin nombre"
}
+727 -227
View File
File diff suppressed because it is too large Load Diff
+749 -249
View File
File diff suppressed because it is too large Load Diff
+724 -224
View File
File diff suppressed because it is too large Load Diff
+720 -220
View File
File diff suppressed because it is too large Load Diff
+749 -249
View File
File diff suppressed because it is too large Load Diff
+377
View File
@@ -0,0 +1,377 @@
import 'gen/app_localizations.dart';
extension PluriAppLocalizationsExt on AppLocalizations {
String _lang() {
final locale = localeName.toLowerCase();
if (locale.startsWith('es')) return 'es';
if (locale.startsWith('en')) return 'en';
if (locale.startsWith('fr')) return 'fr';
if (locale.startsWith('de')) return 'de';
if (locale.startsWith('it')) return 'it';
if (locale.startsWith('pt')) return 'pt';
if (locale.startsWith('ru')) return 'ru';
if (locale.startsWith('ja')) return 'ja';
if (locale.startsWith('zh')) return 'zh';
if (locale.startsWith('ar')) return 'ar';
if (locale.startsWith('hi')) return 'hi';
if (locale.startsWith('bn')) return 'bn';
if (locale.startsWith('id')) return 'id';
return 'en';
}
String _pick(Map<String, String> values) =>
values[_lang()] ?? values['en'] ?? values.values.first;
String weekdayLong(int day) => switch (day) {
DateTime.monday => _pick({
'es': 'lunes',
'en': 'Monday',
'fr': 'lundi',
'de': 'Montag',
'it': 'lunedì',
'pt': 'segunda-feira',
'ru': 'понедельник',
'ja': '月曜日',
'zh': '星期一',
'ar': 'الإثنين',
'hi': 'सोमवार',
'bn': 'সোমবার',
'id': 'Senin',
}),
DateTime.tuesday => _pick({
'es': 'martes',
'en': 'Tuesday',
'fr': 'mardi',
'de': 'Dienstag',
'it': 'martedì',
'pt': 'terça-feira',
'ru': 'вторник',
'ja': '火曜日',
'zh': '星期二',
'ar': 'الثلاثاء',
'hi': 'मंगलवार',
'bn': 'মঙ্গলবার',
'id': 'Selasa',
}),
DateTime.wednesday => _pick({
'es': 'miércoles',
'en': 'Wednesday',
'fr': 'mercredi',
'de': 'Mittwoch',
'it': 'mercoledì',
'pt': 'quarta-feira',
'ru': 'среда',
'ja': '水曜日',
'zh': '星期三',
'ar': 'الأربعاء',
'hi': 'बुधवार',
'bn': 'বুধবার',
'id': 'Rabu',
}),
DateTime.thursday => _pick({
'es': 'jueves',
'en': 'Thursday',
'fr': 'jeudi',
'de': 'Donnerstag',
'it': 'giovedì',
'pt': 'quinta-feira',
'ru': 'четверг',
'ja': '木曜日',
'zh': '星期四',
'ar': 'الخميس',
'hi': 'गुरुवार',
'bn': 'বৃহস্পতিবার',
'id': 'Kamis',
}),
DateTime.friday => _pick({
'es': 'viernes',
'en': 'Friday',
'fr': 'vendredi',
'de': 'Freitag',
'it': 'venerdì',
'pt': 'sexta-feira',
'ru': 'пятница',
'ja': '金曜日',
'zh': '星期五',
'ar': 'الجمعة',
'hi': 'शुक्रवार',
'bn': 'শুক্রবার',
'id': 'Jumat',
}),
DateTime.saturday => _pick({
'es': 'sábado',
'en': 'Saturday',
'fr': 'samedi',
'de': 'Samstag',
'it': 'sabato',
'pt': 'sábado',
'ru': 'суббота',
'ja': '土曜日',
'zh': '星期六',
'ar': 'السبت',
'hi': 'शनिवार',
'bn': 'শনিবার',
'id': 'Sabtu',
}),
DateTime.sunday => _pick({
'es': 'domingo',
'en': 'Sunday',
'fr': 'dimanche',
'de': 'Sonntag',
'it': 'domenica',
'pt': 'domingo',
'ru': 'воскресенье',
'ja': '日曜日',
'zh': '星期日',
'ar': 'الأحد',
'hi': 'रविवार',
'bn': 'রবিবার',
'id': 'Minggu',
}),
_ => _pick({
'es': 'día',
'en': 'day',
'fr': 'jour',
'de': 'Tag',
'it': 'giorno',
'pt': 'dia',
'ru': 'день',
'ja': '日',
'zh': '日',
'ar': 'يوم',
'hi': 'दिन',
'bn': 'দিন',
'id': 'hari',
}),
};
String monthName(int month) => switch (month) {
1 => _pick({
'es': 'enero',
'en': 'January',
'fr': 'janvier',
'de': 'Januar',
'it': 'gennaio',
'pt': 'janeiro',
'ru': 'января',
'ja': '1月',
'zh': '1月',
'ar': 'يناير',
'hi': 'जनवरी',
'bn': 'জানুয়ারি',
'id': 'Januari',
}),
2 => _pick({
'es': 'febrero',
'en': 'February',
'fr': 'février',
'de': 'Februar',
'it': 'febbraio',
'pt': 'fevereiro',
'ru': 'февраля',
'ja': '2月',
'zh': '2月',
'ar': 'فبراير',
'hi': 'फ़रवरी',
'bn': 'ফেব্রুয়ারি',
'id': 'Februari',
}),
3 => _pick({
'es': 'marzo',
'en': 'March',
'fr': 'mars',
'de': 'März',
'it': 'marzo',
'pt': 'março',
'ru': 'марта',
'ja': '3月',
'zh': '3月',
'ar': 'مارس',
'hi': 'मार्च',
'bn': 'মার্চ',
'id': 'Maret',
}),
4 => _pick({
'es': 'abril',
'en': 'April',
'fr': 'avril',
'de': 'April',
'it': 'aprile',
'pt': 'abril',
'ru': 'апреля',
'ja': '4月',
'zh': '4月',
'ar': 'أبريل',
'hi': 'अप्रैल',
'bn': 'এপ্রিল',
'id': 'April',
}),
5 => _pick({
'es': 'mayo',
'en': 'May',
'fr': 'mai',
'de': 'Mai',
'it': 'maggio',
'pt': 'maio',
'ru': 'мая',
'ja': '5月',
'zh': '5月',
'ar': 'مايو',
'hi': 'मई',
'bn': 'মে',
'id': 'Mei',
}),
6 => _pick({
'es': 'junio',
'en': 'June',
'fr': 'juin',
'de': 'Juni',
'it': 'giugno',
'pt': 'junho',
'ru': 'июня',
'ja': '6月',
'zh': '6月',
'ar': 'يونيو',
'hi': 'जून',
'bn': 'জুন',
'id': 'Juni',
}),
7 => _pick({
'es': 'julio',
'en': 'July',
'fr': 'juillet',
'de': 'Juli',
'it': 'luglio',
'pt': 'julho',
'ru': 'июля',
'ja': '7月',
'zh': '7月',
'ar': 'يوليو',
'hi': 'जुलाई',
'bn': 'জুলাই',
'id': 'Juli',
}),
8 => _pick({
'es': 'agosto',
'en': 'August',
'fr': 'août',
'de': 'August',
'it': 'agosto',
'pt': 'agosto',
'ru': 'августа',
'ja': '8月',
'zh': '8月',
'ar': 'أغسطس',
'hi': 'अगस्त',
'bn': 'আগস্ট',
'id': 'Agustus',
}),
9 => _pick({
'es': 'septiembre',
'en': 'September',
'fr': 'septembre',
'de': 'September',
'it': 'settembre',
'pt': 'setembro',
'ru': 'сентября',
'ja': '9月',
'zh': '9月',
'ar': 'سبتمبر',
'hi': 'सितंबर',
'bn': 'সেপ্টেম্বর',
'id': 'September',
}),
10 => _pick({
'es': 'octubre',
'en': 'October',
'fr': 'octobre',
'de': 'Oktober',
'it': 'ottobre',
'pt': 'outubro',
'ru': 'октября',
'ja': '10月',
'zh': '10月',
'ar': 'أكتوبر',
'hi': 'अक्टूबर',
'bn': 'অক্টোবর',
'id': 'Oktober',
}),
11 => _pick({
'es': 'noviembre',
'en': 'November',
'fr': 'novembre',
'de': 'November',
'it': 'novembre',
'pt': 'novembro',
'ru': 'ноября',
'ja': '11月',
'zh': '11月',
'ar': 'نوفمبر',
'hi': 'नवंबर',
'bn': 'নভেম্বর',
'id': 'November',
}),
12 => _pick({
'es': 'diciembre',
'en': 'December',
'fr': 'décembre',
'de': 'Dezember',
'it': 'dicembre',
'pt': 'dezembro',
'ru': 'декабря',
'ja': '12月',
'zh': '12月',
'ar': 'ديسمبر',
'hi': 'दिसंबर',
'bn': 'ডিসেম্বর',
'id': 'Desember',
}),
_ => _pick({
'es': 'mes',
'en': 'month',
'fr': 'mois',
'de': 'Monat',
'it': 'mese',
'pt': 'mês',
'ru': 'месяц',
'ja': '月',
'zh': '月',
'ar': 'شهر',
'hi': 'महीना',
'bn': 'মাস',
'id': 'bulan',
}),
};
String dateTimeSentence(DateTime date) {
final local = date.toLocal();
final hm =
'${local.hour.toString().padLeft(2, '0')}:${local.minute.toString().padLeft(2, '0')}';
return _pick({
'es':
'${weekdayLong(local.weekday)} ${local.day} de ${monthName(local.month)} a las $hm',
'en':
'${weekdayLong(local.weekday)}, ${monthName(local.month)} ${local.day} at $hm',
'fr':
'${weekdayLong(local.weekday)} ${local.day} ${monthName(local.month)} à $hm',
'de':
'${weekdayLong(local.weekday)}, ${local.day}. ${monthName(local.month)} um $hm',
'it':
'${weekdayLong(local.weekday)} ${local.day} ${monthName(local.month)} alle $hm',
'pt':
'${weekdayLong(local.weekday)}, ${local.day} de ${monthName(local.month)} às $hm',
'ru':
'${weekdayLong(local.weekday)}, ${local.day} ${monthName(local.month)} в $hm',
'ja':
'${monthName(local.month)}${local.day}日(${weekdayLong(local.weekday)})$hm',
'zh':
'${monthName(local.month)}${local.day}日 ${weekdayLong(local.weekday)} $hm',
'ar':
'${weekdayLong(local.weekday)} ${local.day} ${monthName(local.month)} في $hm',
'hi':
'${weekdayLong(local.weekday)}, ${local.day} ${monthName(local.month)} $hm बजे',
'bn':
'${weekdayLong(local.weekday)}, ${local.day} ${monthName(local.month)} $hm',
'id':
'${weekdayLong(local.weekday)}, ${local.day} ${monthName(local.month)} pukul $hm',
});
}
}
+726 -226
View File
File diff suppressed because it is too large Load Diff
+749 -249
View File
File diff suppressed because it is too large Load Diff
+749 -249
View File
File diff suppressed because it is too large Load Diff
+29
View File
@@ -0,0 +1,29 @@
import 'gen/app_localizations.dart';
const _legacyAlarmName = 'Alarma musical';
const _legacyVacationName = 'Vacaciones';
const _legacyUnnamedStation = 'Sin nombre';
String localizedAlarmName(AppLocalizations l10n, String rawName) {
final name = rawName.trim();
if (name.isEmpty || name == _legacyAlarmName) {
return l10n.defaultAlarmName;
}
return name;
}
String localizedVacationName(AppLocalizations l10n, String rawName) {
final name = rawName.trim();
if (name.isEmpty || name == _legacyVacationName) {
return l10n.vacationsDefaultName;
}
return name;
}
String localizedStationName(AppLocalizations l10n, String rawName) {
final name = rawName.trim();
if (name.isEmpty || name == _legacyUnnamedStation) {
return l10n.unnamedStation;
}
return name;
}
+42
View File
@@ -0,0 +1,42 @@
import 'package:intl/intl.dart';
/// Locale-aware short date (S5-R4).
///
/// Replaces the old hardcoded `DD/MM/YYYY` pattern, which was wrong for
/// locales like en-US (M/D/Y) or ja (Y/M/D). [localeTag] accepts both
/// BCP-47 ('en-US') and ICU ('en_US') forms — intl canonicalizes them.
///
/// Date symbols for the active locale are loaded by
/// GlobalMaterialLocalizations, so any widget below MaterialApp can call
/// this safely.
String fechaCortaLocalizada(String localeTag, DateTime fecha) =>
DateFormat.yMd(localeTag).format(fecha);
/// Day + abbreviated month, locale-aware (e.g. "28 jul" for `es`, "Jul 28"
/// for `en`) — item 21 / audit 9b.4 (t4:459), the big digit half of the
/// vacation screen's date-pair.
String diaMesLocalizado(String localeTag, DateTime fecha) =>
DateFormat.MMMd(localeTag).format(fecha);
/// Full weekday name, locale-aware (e.g. "lunes"/"Monday") — item 21 /
/// audit 9b.4 (t4:459), the small caption under the day-month.
String nombreDiaSemanaLocalizado(String localeTag, DateTime fecha) =>
DateFormat.EEEE(localeTag).format(fecha);
/// Full weekday + month + day, locale-aware (e.g. "lunes, 3 de agosto" for
/// `es`, "Monday, August 3" for `en`) — audit 9.4 (t4:419), the ringing
/// screen's date line between the schedule pill and the hero time.
String fechaLargaConDiaSemana(String localeTag, DateTime fecha) =>
DateFormat.MMMMEEEEd(localeTag).format(fecha);
/// Short "d–d MON" range pill, locale-aware month abbreviation, uppercased
/// (e.g. "4–18 AGO" for `es`, "4–18 AUG" for `en`) — audit 7.3 (t4:332),
/// the vacation-row date-range pill on the Alarmas root. Always labels the
/// range with the END date's month: vacation ranges are short (days to a
/// couple of weeks), so a cross-month span is the rare case, and the
/// prototype itself only ever shows a single abbreviation.
String rangoFechasCorto(String localeTag, DateTime inicio, DateTime fin) {
final dia = DateFormat.d(localeTag);
final mes = DateFormat.MMM(localeTag).format(fin).toUpperCase();
return '${dia.format(inicio)}–${dia.format(fin)} $mes';
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+376 -34
View File
@@ -1,67 +1,388 @@
import 'dart:async';
import 'dart:developer' as developer;
import 'dart:ui' as ui;
import 'package:audio_service/audio_service.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:google_mobile_ads/google_mobile_ads.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'app.dart';
import 'estado/estado_entitlement.dart';
import 'servicios/arranque_audio.dart';
import 'servicios/contexto_reproduccion.dart';
import 'servicios/emisoras_destacadas.dart';
import 'servicios/musica_local_auto.dart';
import 'servicios/navegacion_auto.dart';
import 'servicios/servicio_audio.dart';
import 'servicios/servicio_audio_session.dart';
import 'servicios/servicio_compras.dart';
import 'servicios/servicio_consentimiento.dart';
import 'servicios/servicio_ecualizador.dart';
import 'servicios/servicio_presets_personalizados.dart';
import 'tema/pluriwave_tokens.dart';
const _anchoMinimoLandscape = 600.0;
/// Branded monochrome status-bar icon, replacing the default full-color
/// launcher silhouette fallback.
const androidNotificationIconResource = 'drawable/ic_stat_pluriwave';
/// S5-R8: media notification accent uses the brand color, not the M3
/// default purple. Top-level const so tests can assert it.
const configuracionAudioService = AudioServiceConfig(
androidNotificationChannelId: 'es.freetimelab.pluriwave.audio',
androidNotificationChannelName: 'PluriWave Radio',
// Paired with `androidStopForegroundOnPause: false` below, and required to
// be: the plugin asserts `androidNotificationOngoing` implies
// `androidStopForegroundOnPause`. Nothing is lost by turning it off —
// while the service is in the foreground the OS forces the notification to
// be ongoing anyway, which is now the whole time playback is alive.
androidNotificationOngoing: false,
// The service stays in the FOREGROUND while paused.
//
// With `true`, a pause called `stopForeground(...)`, and a service that is
// not in the foreground is a service Android may kill at will. In the car
// that is exactly what happened: an interruption paused playback, the
// service dropped out of the foreground, Android reclaimed it, and
// PluriWave disappeared from the Android Auto pane — another media app
// took the slot. Ducking (see `ServicioAudioSession.configurar`) removes
// most pauses, but a real pause must not be a death sentence either.
//
// The plugin's own doc for this flag says it outright: «while in this
// lower priority state, the operating system will also be able to kill
// your service at any time to reclaim resources».
//
// Cost of `false`: the notification is not swipe-dismissible while paused,
// only after Stop. That is how every serious media app behaves, and Stop
// still tears everything down.
androidStopForegroundOnPause: false,
notificationColor: PluriWaveTokens.brand,
androidNotificationIcon: androidNotificationIconResource,
);
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
await _aplicarPoliticaOrientacion();
final handler = await AudioService.init(
builder: () => PluriWaveAudioHandler(),
config: const AudioServiceConfig(
androidNotificationChannelId: 'es.freetimelab.pluriwave.audio',
androidNotificationChannelName: 'PluriWave Radio',
androidNotificationOngoing: true,
androidStopForegroundOnPause: true,
notificationColor: Color(0xFF6750A4),
),
// Android Auto browse source: registered FIRST, before any await at all.
// It depends on nothing, and everything below it is a potential place to
// get stuck — so nothing may sit between engine start and this line.
//
// Reported: with Android Auto connected, the car screen sometimes came up
// black and the app then opened WHITE on the phone until it was
// force-killed. `AudioServiceActivity.provideFlutterEngine` returns the
// engine from `AudioServicePlugin.getFlutterEngine`, which CREATES the
// engine and runs `main()` headlessly the first time — with no Activity —
// when the car binds the MediaBrowserService before the app is opened.
// `_aplicarPoliticaOrientacion` used to be the first `await` here, and
// `SystemChrome.setPreferredOrientations` travels the `flutter/platform`
// channel, whose handler (`PlatformPlugin`) is installed by the Activity.
// Headless there is nobody to answer it, so `main()` died or hung on line
// one: the browse source below was never registered (`getChildren` had no
// source -> black car screen) and `runApp` was never reached. Opening the
// app then REUSED that same cached, already-dead engine -> white screen,
// and only a force-kill (which disposes the engine) recovered it.
final fuenteAuto = FuenteEmisorasAutoLocal();
registrarFuenteNavegacion(fuenteAuto);
// Local music registers HERE, above every await, alongside the station
// source — not after `SharedPreferences.getInstance()` where it used to
// sit.
//
// Regression this fixes, self-inflicted by the reordering above: the root
// menu decides whether to offer "Música Local" with
// `fuenteLocal != null && await fuenteLocal.estadoCarpeta() != noConfigurada`.
// Moving ONLY the station source above the awaits meant the car could get
// a root response in the window before this line ran, find a null source,
// and be told there is no local music — and Android Auto caches the browse
// root, so it stayed missing for the whole session. Before the reorder
// both registrations sat together after the await, so the window did not
// exist.
//
// `FuenteMusicaLocalAutoImpl` needs no prefs to be CONSTRUCTED: it
// resolves them lazily per call (`_resolverPrefs`, falling back to
// `getInstance()`), the same convention `ServicioAlarmas` uses. So there
// was never a reason for it to wait on that await.
registrarFuenteMusicaLocal(FuenteMusicaLocalAutoImpl());
// Cosmetic, and deliberately NOT awaited: a display preference must never
// gate `runApp`. `OrientacionResponsiveApp.didChangeDependencies` applies
// it again as soon as a real view exists, which is the only moment it can
// actually take effect anyway.
unawaited(aplicarPoliticaOrientacion());
// iap-freemium-unlock: neither SDK init call blocks `runApp` — a purchase
// stream subscription and an ad-SDK warm-up are both safe to finish late
// (mirrors `aplicarPoliticaOrientacion`'s "cosmetic, never gates startup"
// rule immediately above).
//
// FIX 4 (code review): the Mobile Ads SDK is only initialized AFTER the
// GDPR/UMP consent flow resolves that ads may actually be requested
// (`ConsentInformation.canRequestAds()`) — serving personalized ads to
// EEA/UK users with no CMP violates Google's EU User Consent Policy.
// Premium users never even reach the consent form (`resolverConsentimientoAnuncios`
// short-circuits for them — they get zero ads regardless). This whole
// chain is deliberately `unawaited`: consent/ads are exactly as
// "cosmetic, never gates startup" as `aplicarPoliticaOrientacion` above,
// and any failure inside it degrades to "no ads", never a crash or a
// blocked UI.
unawaited(
esPremiumPersistido()
.then(
(premium) => resolverConsentimientoAnuncios(
esPremium: premium,
consentimiento: ServicioConsentimientoUmp(),
),
)
.then((puedeSolicitarAnuncios) async {
if (puedeSolicitarAnuncios) {
await MobileAds.instance.initialize();
}
}),
);
registrarHandler(handler);
final compras = ServicioComprasPlayBilling();
runApp(const _OrientacionResponsiveApp(child: PluriWaveApp()));
}
// S3-R4: single SharedPreferences instance resolved once at startup and
// injected into every state/service below.
final prefs = await SharedPreferences.getInstance();
Future<void> _aplicarPoliticaOrientacion([ui.Display? display]) async {
final vista =
WidgetsBinding.instance.platformDispatcher.views.isNotEmpty
? WidgetsBinding.instance.platformDispatcher.views.first
: null;
final displayActivo = display ?? vista?.display;
if (displayActivo == null) return;
// User-saved EQ presets for the car's Ecualizador folder, same
// injectable-prefs DI convention and same pre-init placement as the two
// registrations above (neither depends on the AudioHandler). Passed as a
// read function, not the service, so the folder re-reads on every browse:
// a preset saved on the phone appears in the car without an app restart.
final presetsPersonalizados = ServicioPresetsPersonalizados(prefs: prefs);
registrarFuentePresetsPersonalizados(presetsPersonalizados.listar);
final anchoLogico =
displayActivo.size.width / displayActivo.devicePixelRatio;
if (anchoLogico < _anchoMinimoLandscape) {
await SystemChrome.setPreferredOrientations([
DeviceOrientation.portraitUp,
]);
return;
// eq-estado-unico items A/B: the handler's own link to the equalizer's
// persisted on/off flag. `ServicioEcualizador` needs nothing but the
// `prefs` instance resolved just above — no widget tree, no Provider — so
// it is available on EVERY engine, including the headless one Android Auto
// starts. Before this, the persisted value only reached the handler
// through `EstadoEcualizador.cargarPersistido()`, which that engine never
// runs: the handler played with the equalizer forced on while disk and the
// phone UI both said off, and a toggle made in the car was lost on
// restart. Passed as two narrow function ports, mirroring the
// read-function convention used for the preset folder right above.
final ecualizador = ServicioEcualizador(prefs: prefs);
// Silent-error channel (fix/notificacion-media): `AudioService.asyncError`
// had ZERO subscribers app-wide, and a `PublishSubject` with no listeners
// drops what it is given — so every exception `audio_service` catches
// internally was discarded without a trace, which is exactly why the
// "media notification disappeared" report came with no evidence attached.
// Subscribed BEFORE `AudioService.init` below (the getter only touches a
// static subject, so it needs no initialisation) so nothing reported
// during the MediaBrowser handshake is missed, and placed here rather than
// in `conectarHandler` so ONE subscription covers both the on-time and the
// degraded/timeout startup paths.
final subErroresAudio = observarErroresAudio(
AudioService.asyncError,
registrar: registrarErrorAudioService,
);
// Design "Timeout without re-init": AudioService.init is started exactly
// ONCE here and `handlerFuturo` is the only future ever awaited for it —
// the plugin caches state internally, so a double-configure call is
// unsafe and must never happen, even on the degraded/timeout path below.
final handlerFuturo = AudioService.init(
builder: () => PluriWaveAudioHandler(),
config: configuracionAudioService,
);
// S3-R1: audio focus — phone calls / transient losses pause or duck the
// radio; headphones unplugged pauses it. Shared by both the on-time and
// degraded/late-completion paths below.
void conectarHandler(PluriWaveAudioHandler handler) {
registrarHandler(
handler,
leerEqActivoPersistido: ecualizador.leerActivo,
guardarEqActivoPersistido: ecualizador.guardarActivo,
// The PRESET's half of the same seam. Without it the handler enabled
// the equalizer with its hardcoded FLAT preset on any engine where the
// phone UI never ran — i.e. every headless Android Auto bind. There is
// no write port: `EstadoEcualizador` still owns saving presets (a car
// preset choice goes through it), so the handler only ever reads.
leerPresetPersistido: ecualizador.leerPresetPrincipal,
// Skip context («in which list am I»). Bound here, on the audio
// bootstrap path of EVERY engine, precisely because the headless
// Android Auto engine builds no widget tree and therefore no
// `EstadoRadio`: a context only the phone UI could write would be a
// context the car could never have.
leerContextoSalto: contextoSaltoPersistido,
guardarContextoSalto: guardarContextoSalto,
// Last played station (`ultima_emisora_v1`). Bound here for the SAME
// reason as the skip context: `EstadoRadio` — which used to be its only
// writer — belongs to the widget tree, and the Android Auto engine
// builds none, so a session that happened only in the car never updated
// the key and the head unit was offered whatever the PHONE last played.
// The write port is now the key's single writer; the read port feeds the
// cold-start metadata seed and the bare-`play()` resume.
leerUltimaEmisora: ultimaEmisoraPersistida,
guardarUltimaEmisora: guardarUltimaEmisoraPersistida,
);
// The handler is the only thing this app ever tears down
// (`onTaskRemoved`), so the asyncError subscription's `cancel` travels
// with it and can never leak — same "register from main.dart" convention
// as `registrarHandler` itself.
registrarLimpiezaArranque(subErroresAudio.cancel);
final sesionAudio = ServicioAudioSession(objetivo: handler);
unawaited(sesionAudio.configurar());
}
await SystemChrome.setPreferredOrientations(DeviceOrientation.values);
Widget construirApp() => OrientacionResponsiveApp(
child: PluriWaveApp(prefs: prefs, fuenteAuto: fuenteAuto, compras: compras),
);
final resultado = await esperarArranqueAudio(handlerFuturo);
switch (resultado) {
case ArranqueAudioListo<PluriWaveAudioHandler>(:final handler):
// Handshake finished in time — exactly today's startup path.
conectarHandler(handler);
runApp(construirApp());
case ArranqueAudioPendiente<PluriWaveAudioHandler>(
handlerFuturo: final futuroPendiente,
):
// Handshake still hung after the timeout: run the app anyway with a
// minimal loading bootstrap that keeps awaiting the SAME
// `futuroPendiente` — identical to the outer `handlerFuturo`, per
// esperarArranqueAudio's contract (never a second AudioService.init
// call) — and wires the handler + swaps to the real app once it
// eventually resolves.
runApp(
ArranqueAudioApp<PluriWaveAudioHandler>(
handlerFuturo: futuroPendiente,
alListo: conectarHandler,
construirApp: (_) => construirApp(),
),
);
}
}
class _OrientacionResponsiveApp extends StatefulWidget {
const _OrientacionResponsiveApp({required this.child});
/// Which orientations a display [anchoLogico] dp wide may use: phones stay
/// portrait, tablets get everything. Pure, so the policy is testable without
/// a platform channel.
@visibleForTesting
List<DeviceOrientation> orientacionesPara(double anchoLogico) =>
anchoLogico < _anchoMinimoLandscape
? const [DeviceOrientation.portraitUp]
: DeviceOrientation.values;
/// Applies [orientacionesPara] to the active display.
///
/// NEVER throws and never blocks a caller that matters. This runs on the
/// headless engine Android Auto starts (see `main`), where the
/// `flutter/platform` channel has no handler because there is no Activity to
/// install `PlatformPlugin` — so the call can fail with a
/// `MissingPluginException` or simply never be answered. Before this guard
/// that outcome killed `main()` outright, taking the Android Auto browse
/// registration and `runApp` with it.
///
/// [aplicar] is injectable so the swallow-everything contract is testable
/// without a real platform channel.
@visibleForTesting
Future<void> aplicarPoliticaOrientacion({
ui.Display? display,
Future<void> Function(List<DeviceOrientation>)? aplicar,
}) async {
try {
final vista =
WidgetsBinding.instance.platformDispatcher.views.isNotEmpty
? WidgetsBinding.instance.platformDispatcher.views.first
: null;
final displayActivo = display ?? vista?.display;
if (displayActivo == null) return;
final anchoLogico =
displayActivo.size.width / displayActivo.devicePixelRatio;
await (aplicar ?? SystemChrome.setPreferredOrientations)(
orientacionesPara(anchoLogico),
);
} catch (e) {
// Deliberately broad: a cosmetic preference is never worth a failed
// startup, and headless is exactly where this fails.
developer.log(
'[PluriWave] no se pudo aplicar la política de orientación: $e',
name: 'Arranque',
level: 900,
);
}
}
/// Whether the Android Auto browse tree must be invalidated right now
/// (fix/android-auto-musica-local, item 4 — CORRECTED trigger).
///
/// The trigger used to be `View.maybeOf(context) != null` inside
/// `didChangeDependencies`, latched once, on the premise that «a View means
/// there is an Activity». That premise is FALSE: `runApp` unconditionally
/// wraps the tree in a `View` built from
/// `platformDispatcher.implicitView` and throws a `StateError` when there is
/// none (`flutter/lib/src/widgets/binding.dart`, `wrapWithDefaultView`). So
/// on the headless `audio_service` engine — which demonstrably reaches
/// `runApp`, see [aplicarPoliticaOrientacion] — the View is ALREADY there at
/// the first `didChangeDependencies`. The one-shot latch was spent at the
/// exact moment it could accomplish nothing (`_childrenSubjects` still
/// empty, so `notificarHijosCambiaron` is a silent no-op) and could never
/// fire again, because `didChangeDependencies` does not re-run when an
/// Activity later attaches to that same cached engine.
///
/// Two conditions replace it, both required:
///
/// * [estado] is [AppLifecycleState.resumed] — the only state that genuinely
/// means «an Activity is attached and in the foreground». It reaches Dart
/// exclusively through `SystemChannels.lifecycle` (or
/// `PlatformDispatcher.initialLifecycleState`, which buffers the same
/// messages), and on Android only `LifecycleChannel.appIsResumed()` sends
/// it, driven by the Activity's own `onResume`.
/// `AudioServicePlugin.getFlutterEngine` builds its engine from the
/// APPLICATION context and runs the Dart entrypoint immediately, with no
/// Activity and no `FlutterActivityAndFragmentDelegate`, so nothing sends
/// it on the headless engine.
/// * [hayCocheSuscrito] — a head unit has actually subscribed to at least
/// one browse id (`hayCocheSuscritoAlArbol`). This is what makes the latch
/// worth spending, and it is also the belt to `resumed`'s braces: even if
/// a lifecycle event did somehow arrive during a headless cold start,
/// nothing has subscribed yet, so the latch survives for the moment an
/// Activity really does attach.
///
/// [yaInvalidado] keeps it one-shot: an app foregrounded twenty times must
/// not send twenty `notifyChildrenChanged` storms to the car.
///
/// Pure, so the whole policy is testable without an engine.
@visibleForTesting
bool debeInvalidarArbolAutoAlReanudar({
required AppLifecycleState estado,
required bool hayCocheSuscrito,
required bool yaInvalidado,
}) =>
!yaInvalidado && hayCocheSuscrito && estado == AppLifecycleState.resumed;
/// Root wrapper that keeps the orientation policy applied and owns the
/// Android Auto browse-tree recovery hook.
///
/// Public only so a test can mount it and drive real lifecycle events
/// through [debeInvalidarArbolAutoAlReanudar]'s call site — the previous
/// trigger shipped broken precisely because nothing could reach it.
@visibleForTesting
class OrientacionResponsiveApp extends StatefulWidget {
const OrientacionResponsiveApp({super.key, required this.child});
final Widget child;
@override
State<_OrientacionResponsiveApp> createState() =>
State<OrientacionResponsiveApp> createState() =>
_OrientacionResponsiveAppState();
}
class _OrientacionResponsiveAppState extends State<_OrientacionResponsiveApp>
class _OrientacionResponsiveAppState extends State<OrientacionResponsiveApp>
with WidgetsBindingObserver {
ui.Display? _display;
/// fix/android-auto-musica-local, item 4: la invalidación del árbol del
/// coche se dispara UNA sola vez. Ver
/// [debeInvalidarArbolAutoAlReanudar].
bool _arbolAutoInvalidado = false;
@override
void initState() {
super.initState();
@@ -72,12 +393,33 @@ class _OrientacionResponsiveAppState extends State<_OrientacionResponsiveApp>
void didChangeDependencies() {
super.didChangeDependencies();
_display = View.maybeOf(context)?.display;
unawaited(_aplicarPoliticaOrientacion(_display));
unawaited(aplicarPoliticaOrientacion(display: _display));
}
/// `resumed` es lo único que significa de verdad «ya hay Activity
/// adjunta», y con ella el handler nativo de `pluriwave/file_actions` que
/// `MainActivity.configureFlutterEngine` instala. Si el coche había
/// navegado la raíz ANTES (arranque headless), la cacheó sin poder
/// resolver la música local; Android Auto no vuelve a preguntar por su
/// cuenta, así que se lo decimos aquí. Ver
/// [debeInvalidarArbolAutoAlReanudar] para las tres condiciones.
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
super.didChangeAppLifecycleState(state);
if (!debeInvalidarArbolAutoAlReanudar(
estado: state,
hayCocheSuscrito: hayCocheSuscritoAlArbol(),
yaInvalidado: _arbolAutoInvalidado,
)) {
return;
}
_arbolAutoInvalidado = true;
invalidarArbolAuto();
}
@override
void didChangeMetrics() {
unawaited(_aplicarPoliticaOrientacion(_display));
unawaited(aplicarPoliticaOrientacion(display: _display));
}
@override
+88 -12
View File
@@ -62,7 +62,9 @@ class AlarmaMusical {
DateTime? fechaUnica,
bool limpiarFechaUnica = false,
Emisora? emisora,
bool limpiarEmisora = false,
Emisora? emisoraFallback,
bool limpiarEmisoraFallback = false,
bool? sonarEnVacaciones,
int? snoozeMinutos,
double? volumen,
@@ -87,8 +89,11 @@ class AlarmaMusical {
tipoProgramacion: tipoProgramacion ?? this.tipoProgramacion,
diasSemana: diasSemana ?? this.diasSemana,
fechaUnica: limpiarFechaUnica ? null : fechaUnica ?? this.fechaUnica,
emisora: emisora ?? this.emisora,
emisoraFallback: emisoraFallback ?? this.emisoraFallback,
emisora: limpiarEmisora ? emisora : emisora ?? this.emisora,
emisoraFallback:
limpiarEmisoraFallback
? emisoraFallback
: emisoraFallback ?? this.emisoraFallback,
sonarEnVacaciones: sonarEnVacaciones ?? this.sonarEnVacaciones,
snoozeMinutos: snoozeMinutos ?? this.snoozeMinutos,
volumen: volumen ?? this.volumen,
@@ -98,7 +103,8 @@ class AlarmaMusical {
limpiarProximaEjecucion
? proximaEjecucion
: proximaEjecucion ?? this.proximaEjecucion,
snoozeHasta: limpiarSnooze ? snoozeHasta : snoozeHasta ?? this.snoozeHasta,
snoozeHasta:
limpiarSnooze ? snoozeHasta : snoozeHasta ?? this.snoozeHasta,
snoozeOrigen:
limpiarSnooze ? snoozeOrigen : snoozeOrigen ?? this.snoozeOrigen,
ultimaEjecucionGestionada:
@@ -128,14 +134,29 @@ class AlarmaMusical {
'volumen': volumen,
'fadeInSegundos': fadeInSegundos,
'sonidoInterno': sonidoInterno.name,
'proximaEjecucion': proximaEjecucion?.toIso8601String(),
'snoozeHasta': snoozeHasta?.toIso8601String(),
'snoozeOrigen': snoozeOrigen?.toIso8601String(),
'ultimaEjecucionGestionada': ultimaEjecucionGestionada?.toIso8601String(),
'creadaEn': creadaEn?.toIso8601String(),
'actualizadaEn': actualizadaEn?.toIso8601String(),
// INSTANT fields serialize as UTC (offset-carrying "Z" ISO): a local
// toIso8601String() has no offset, so re-parsing it after the device
// changes timezone reinterprets the same wall fields as a DIFFERENT
// instant (a snooze set in Madrid would shift hours after landing in
// New York). fechaUnica stays local-ISO on purpose: it is a wall-clock
// DATE (only y/m/d are ever read), which must follow the user.
'proximaEjecucion': proximaEjecucion?.toUtc().toIso8601String(),
'snoozeHasta': snoozeHasta?.toUtc().toIso8601String(),
'snoozeOrigen': snoozeOrigen?.toUtc().toIso8601String(),
'ultimaEjecucionGestionada':
ultimaEjecucionGestionada?.toUtc().toIso8601String(),
'creadaEn': creadaEn?.toUtc().toIso8601String(),
'actualizadaEn': actualizadaEn?.toUtc().toIso8601String(),
};
// persistence-resilience (D2): `id` stays a REQUIRED, un-defaulted cast
// on purpose -- a missing/wrong-type id must throw, not fall back to a
// fabricated value. Callers that read persisted collections (e.g.
// ServicioAlarmas._parsear via persistencia_tolerante.dart) wrap each
// fromJson call in a per-entry try: a thrown entry is skipped and
// logged, never replacing this required field with a sentinel/fabricated
// id ("skip-never-fabricate"). This boundary also tolerates any future
// required-field break the same way, not just id.
factory AlarmaMusical.fromJson(Map<String, dynamic> json) {
return AlarmaMusical(
id: json['id'] as String,
@@ -159,8 +180,8 @@ class AlarmaMusical {
sonarEnVacaciones: json['sonarEnVacaciones'] as bool? ?? true,
snoozeMinutos: json['snoozeMinutos'] as int? ?? 5,
volumen: (json['volumen'] as num?)?.toDouble() ?? 0.85,
fadeInSegundos: ((json['fadeInSegundos'] as int? ?? 0).clamp(0, 60))
as int,
fadeInSegundos:
(json['fadeInSegundos'] as int? ?? 0).clamp(0, 60).toInt(),
sonidoInterno: _enumFromName(
SonidoInternoAlarma.values,
json['sonidoInterno'] as String?,
@@ -182,8 +203,12 @@ class AlarmaMusical {
return Emisora.fromMap(Map<String, dynamic>.from(raw));
}
// Normalizes to LOCAL on read: new payloads carry "Z" (UTC instants,
// toLocal converts), legacy offset-less payloads parse as local already
// (toLocal is then the identity) — both shapes land as the same local
// DateTime the scheduling math expects, so no data migration is needed.
static DateTime? _dateFromJson(Object? raw) =>
raw is String ? DateTime.tryParse(raw) : null;
raw is String ? DateTime.tryParse(raw)?.toLocal() : null;
static T _enumFromName<T extends Enum>(
List<T> values,
@@ -252,6 +277,20 @@ class RangoVacaciones {
}
}
/// Per-alarm vacation pause impact (design ADR-6, WU9). Produced by
/// `EstadoAlarmas.impactoDeRango`, never persisted, never built from a
/// second date-math implementation — see that method's own doc comment for
/// the exact predicate it mirrors.
class ImpactoVacaciones {
const ImpactoVacaciones({required this.pausadas, required this.noAfectadas});
/// `activa && !sonarEnVacaciones`.
final List<AlarmaMusical> pausadas;
/// `activa && sonarEnVacaciones`.
final List<AlarmaMusical> noAfectadas;
}
class ExcepcionAlarma {
const ExcepcionAlarma({
required this.alarmaId,
@@ -263,6 +302,43 @@ class ExcepcionAlarma {
final DateTime ejecucion;
final String tipo;
/// User-requested skip of the next occurrence (the only [tipo] this model
/// originally supported). `ServicioProgramacionAlarmas._esValida` only
/// treats THIS tipo as an actual schedule skip -- every tipo below records
/// a scheduling-reliability failure and must never affect which occurrence
/// fires next.
static const tipoSaltoSiguiente = 'skipNext';
/// The main alarm registration with the OS failed (`android.programar`
/// threw). Recorded per-alarm so the alarms list can mark the exact card
/// affected instead of only a transient, alarm-agnostic app-wide message.
static const tipoFalloProgramacion = 'schedulingFailed';
/// The main alarm registered successfully but its 30-minute pre-notice
/// reminder did not (native `SecurityException` scheduling the pre-notice
/// alone) -- distinguished from [tipoFalloProgramacion] because the alarm
/// itself will still ring; only the early warning is missing.
static const tipoFalloPreaviso = 'preNoticeFailed';
/// The OS refused to start the foreground ringing service when the alarm
/// fired (e.g. a background-restricted app), so the alarm never actually
/// rang even though it was armed.
static const tipoFalloServicioSonido = 'foregroundServiceFailed';
/// A per-alarm reschedule after boot/unlock failed while sibling alarms
/// succeeded, leaving this one specific alarm unscheduled.
static const tipoFalloReprogramacionArranque = 'rescheduleAfterBootFailed';
/// Every tipo above that represents a reliability FAILURE rather than a
/// deliberate user action -- used by the UI to decide whether to mark a
/// card, and by [ServicioAlarmas] to know which prior record to replace.
static const tiposFallo = {
tipoFalloProgramacion,
tipoFalloPreaviso,
tipoFalloServicioSonido,
tipoFalloReprogramacionArranque,
};
Map<String, dynamic> toJson() => {
'alarmaId': alarmaId,
'ejecucion': ejecucion.toIso8601String(),
+26
View File
@@ -0,0 +1,26 @@
/// A single recorded audio file on disk, as listed by
/// `ServicioGrabacionRadio.listarGrabaciones()` (WU15, recordings-library
/// spec, "Browsable Recordings List"). Pure filesystem metadata only — no
/// embedded-audio decoding here; duration is resolved separately and
/// lazily by the screen's own playback abstraction, since decoding audio
/// is not something this service conceptually does today.
class ArchivoGrabacion {
const ArchivoGrabacion({
required this.ruta,
required this.nombre,
required this.fecha,
required this.tamanoBytes,
});
/// Full filesystem path — the identity used for playback, rename and
/// delete.
final String ruta;
/// Display name: the filename without its extension.
final String nombre;
/// Last-modified timestamp, used as the recording's date.
final DateTime fecha;
final int tamanoBytes;
}
+47
View File
@@ -0,0 +1,47 @@
/// Audio device types detected via platform channel.
enum TipoDispositivo {
/// Built-in speaker — id: "builtin_speaker"
altavozInterno,
/// Wired headset or headphones — id: "wired_headset"
auricularesCable,
/// Bluetooth A2DP device — id: `"bt_a2dp:<MAC>"`
bluetoothA2dp,
/// USB audio device — id: `"usb_headset:<address>"`
usbAudio,
/// Unrecognized device type.
desconocido,
}
/// Represents an audio output device detected on the current platform.
///
/// Equality is based on [id] only so that two instances referring to the
/// same physical device compare as equal regardless of display name.
class DispositivoAudio {
final String id;
final TipoDispositivo tipo;
final String nombre;
const DispositivoAudio({
required this.id,
required this.tipo,
required this.nombre,
});
@override
bool operator ==(Object other) {
if (identical(this, other)) return true;
if (other is! DispositivoAudio) return false;
return id == other.id;
}
@override
int get hashCode => id.hashCode;
@override
String toString() =>
'DispositivoAudio(id: $id, tipo: $tipo, nombre: $nombre)';
}
+8 -2
View File
@@ -136,7 +136,11 @@ class Emisora {
/// Lista de géneros/tags como lista limpia.
List<String> get generos {
if (tags == null || tags!.isEmpty) return [];
return tags!.split(',').map((t) => t.trim()).where((t) => t.isNotEmpty).toList();
return tags!
.split(',')
.map((t) => t.trim())
.where((t) => t.isNotEmpty)
.toList();
}
static String? _nonEmpty(String? s) =>
@@ -148,7 +152,9 @@ class Emisora {
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is Emisora && runtimeType == other.runtimeType && uuid == other.uuid;
other is Emisora &&
runtimeType == other.runtimeType &&
uuid == other.uuid;
@override
int get hashCode => uuid.hashCode;
+28
View File
@@ -0,0 +1,28 @@
/// Country entry from the Radio Browser `/json/countries` endpoint.
///
/// `stationcount` arrives as a JSON **string**, not a number — an `as int`
/// cast throws at runtime. This model always goes through `int.tryParse`
/// and defaults safely when a field is missing or malformed (Engram
/// `reference/radio-browser-countries-endpoint`, id 2500).
class PaisRadio {
const PaisRadio({
required this.nombre,
required this.codigoIso,
required this.numeroEmisoras,
});
/// `name` — country name as the API returns it (not translated).
final String nombre;
/// `iso_3166_1` — ISO 3166-1 alpha-2, normalized to uppercase.
final String codigoIso;
/// `stationcount`, parsed from its JSON string form.
final int numeroEmisoras;
factory PaisRadio.fromApi(Map<String, dynamic> json) => PaisRadio(
nombre: json['name'] as String? ?? '',
codigoIso: (json['iso_3166_1'] as String? ?? '').toUpperCase(),
numeroEmisoras: int.tryParse('${json['stationcount'] ?? ''}') ?? 0,
);
}
+101
View File
@@ -0,0 +1,101 @@
/// Local file-system node returned by the native `listAudioChildren`
/// channel call (Design "Interfaces / Contracts"): either a subfolder or an
/// audio file, one SAF tree level deep. Pure DTO — no behavior, so no unit
/// tests are warranted for it on its own (exercised indirectly through its
/// consumers, e.g. `ConstructorArbolAuto.itemsLocales`).
class NodoLocal {
const NodoLocal({
required this.documentId,
required this.nombre,
required this.esDirectorio,
});
/// Opaque SAF document id, unique within the picked tree. May itself
/// contain `:`/`/` (Design "Prefix stripped by length"), so callers must
/// never split/parse it — only wrap it verbatim in a media id.
final String documentId;
/// Raw on-device filename (or folder name), NOT yet title-stripped.
final String nombre;
/// Whether this node is a browsable subfolder (`true`) or a playable
/// audio file (`false`).
final bool esDirectorio;
}
/// Playable local track (Phase 2 extends the Phase 1 minimal shape with
/// resolved embedded metadata — Design "Data Flow"). Pure DTO — no
/// behavior, so no unit tests are warranted for it on its own beyond
/// construction (`pista_local_test.dart`).
class PistaLocal {
const PistaLocal({
required this.documentId,
required this.titulo,
required this.contentUri,
this.artista,
this.embeddedArtUri,
this.bitrate,
this.sampleRate,
});
/// Opaque SAF document id for this track.
final String documentId;
/// Display title, already computed by the caller (filename minus
/// extension, or a derived fallback — see `navegacion_auto.dart`).
final String titulo;
/// Playable `content://` URI resolved via
/// `FuenteMusicaLocalAuto.uriContenidoDePista`.
final String contentUri;
/// Resolved embedded artist metadata, when available (Design "Interfaces
/// / Contracts" — `readAudioMetadataBatch`). `null` when unresolved or
/// unavailable.
final String? artista;
/// Resolved embedded-art `content://` URI served via the app's
/// `FileProvider` cache (Design ADR-1). `null` when there is no embedded
/// picture or it could not be resolved.
final String? embeddedArtUri;
/// Resolved bitrate in bits per second, when available.
final int? bitrate;
/// Resolved sample rate in Hz, only available on API 31+ (Design ADR-5).
/// `null` on older API levels or when unresolved.
final int? sampleRate;
}
/// Resolved embedded metadata for a single local track (Design "Interfaces
/// / Contracts"): one entry per requested `documentId`, all fields
/// individually nullable (per-field parse failures degrade gracefully
/// instead of dropping the whole entry). Pure DTO — no behavior, so no
/// unit tests are warranted for it on its own beyond construction
/// (`pista_local_test.dart`).
class MetadatosPista {
const MetadatosPista({
this.titulo,
this.artista,
this.artUri,
this.bitrate,
this.sampleRate,
});
/// Resolved embedded title, or `null` when absent/unparseable.
final String? titulo;
/// Resolved embedded artist, or `null` when absent/unparseable.
final String? artista;
/// Resolved embedded-art `content://` URI (Design ADR-1), or `null` when
/// there is no embedded picture or it could not be resolved/served.
final String? artUri;
/// Resolved bitrate in bits per second, or `null` when unknown.
final int? bitrate;
/// Resolved sample rate in Hz (API 31+ only, Design ADR-5), or `null`
/// when unknown/unsupported on this API level.
final int? sampleRate;
}
+36 -10
View File
@@ -5,21 +5,47 @@ class PresetEcualizador {
final List<double> bandas; // 5 valores entre -12.0 y +12.0 dB
const PresetEcualizador({required this.nombre, required this.bandas})
: assert(bandas.length == 5);
: assert(bandas.length == 5);
static final flat = PresetEcualizador(nombre: 'Flat', bandas: [0.0, 0.0, 0.0, 0.0, 0.0]);
static final rock = PresetEcualizador(nombre: 'Rock', bandas: [2.0, 1.0, -1.0, 2.0, 3.0]);
static final pop = PresetEcualizador(nombre: 'Pop', bandas: [1.0, 1.5, 0.5, 1.0, 1.5]);
static final bassBoost = PresetEcualizador(nombre: 'Bass Boost', bandas: [5.0, 3.0, -1.0, 0.5, 0.0]);
static final jazz = PresetEcualizador(nombre: 'Jazz', bandas: [3.0, -1.0, -1.5, 2.0, 4.0]);
static final voz = PresetEcualizador(nombre: 'Voz', bandas: [-2.0, -1.0, 2.0, 3.0, 1.0]);
static final flat = PresetEcualizador(
nombre: 'Flat',
bandas: [0.0, 0.0, 0.0, 0.0, 0.0],
);
static final rock = PresetEcualizador(
nombre: 'Rock',
bandas: [2.0, 1.0, -1.0, 2.0, 3.0],
);
static final pop = PresetEcualizador(
nombre: 'Pop',
bandas: [1.0, 1.5, 0.5, 1.0, 1.5],
);
static final bassBoost = PresetEcualizador(
nombre: 'Bass Boost',
bandas: [5.0, 3.0, -1.0, 0.5, 0.0],
);
static final jazz = PresetEcualizador(
nombre: 'Jazz',
bandas: [3.0, -1.0, -1.5, 2.0, 4.0],
);
static final voz = PresetEcualizador(
nombre: 'Voz',
bandas: [-2.0, -1.0, 2.0, 3.0, 1.0],
);
static final presets = [flat, rock, pop, bassBoost, jazz, voz];
factory PresetEcualizador.desdeJson(Map<String, dynamic> json) {
final raw = (json['bandas'] as List?)?.map((e) => (e as num).toDouble()).toList() ?? <double>[];
final bandas = List<double>.generate(5, (i) => i < raw.length ? raw[i] : 0.0);
return PresetEcualizador(nombre: json['nombre'] as String? ?? 'Personalizado', bandas: bandas);
final raw =
(json['bandas'] as List?)?.map((e) => (e as num).toDouble()).toList() ??
<double>[];
final bandas = List<double>.generate(
5,
(i) => i < raw.length ? raw[i] : 0.0,
);
return PresetEcualizador(
nombre: json['nombre'] as String? ?? 'Personalizado',
bandas: bandas,
);
}
Map<String, dynamic> toJson() => {'nombre': nombre, 'bandas': bandas};
@@ -0,0 +1,179 @@
import 'dart:io';
import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart';
import 'package:path_provider/path_provider.dart';
import 'package:provider/provider.dart';
import 'package:share_plus/share_plus.dart' show Share, XFile;
import '../../estado/estado_alarmas.dart';
import '../../estado/estado_radio.dart';
import '../../l10n/gen/app_localizations.dart';
import '../../widgets/pluri_glass_surface.dart';
import '../../widgets/pluri_layout.dart';
import '../../widgets/pluri_push_scaffold.dart';
/// Applies a successfully-parsed backup to BOTH independent notifiers that
/// own pieces of it (fix/import-alarmas-y-paywall).
///
/// `EstadoRadio.importarConfig` writes the raw alarm/vacation/exception JSON
/// block straight to SharedPreferences, but `EstadoAlarmas` is a separate
/// long-lived `ChangeNotifier` that loaded its alarms into memory at
/// construction and never re-reads storage on its own — `EstadoRadio` stays
/// deliberately free of a dependency on it. Without the two calls below the
/// imported block is invisible to the running app: the UI keeps showing the
/// pre-import alarms, a later edit would persist that stale in-memory list
/// OVER the imported one, and the imported alarms would never be
/// (re)scheduled with the Android native layer even after a restart.
///
/// Extracted as a top-level function (rather than inlined in `_importar`)
/// so this exact production sequence — not a reimplementation of it — is
/// directly unit-testable without depending on the `file_picker` platform
/// channel or the confirmation dialog.
Future<void> aplicarImportacionConfig(
EstadoRadio estado,
EstadoAlarmas alarmas,
Map<String, dynamic> json,
) async {
await estado.importarConfig(json);
// Re-reads from storage — clears ServicioAlarmas' in-memory cache so the
// just-imported alarms/vacations/exceptions (same JSON block, same
// notifier) replace the stale ones.
await alarmas.cargarPersistidasSinRecalcular();
// Recomputes next-run times against the (now fresh) imported data and
// re-syncs every alarm with the Android native scheduler.
await alarmas.refrescarProgramacion();
}
/// APLICACIÓN group · "Copia de seguridad" (design ADR-3). Body moved
/// verbatim from the former `_SeccionBackup` in `pantalla_ajustes.dart` —
/// only the panel header's icon and title were removed (the pushed screen's
/// title now carries them); every method below is unchanged.
class PantallaAjustesBackup extends StatelessWidget {
const PantallaAjustesBackup({super.key});
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
return PluriPushScaffold(
title: l10n.backupSectionTitle,
body: ListView(
padding: PluriLayout.pageContentPadding,
children: const [_CuerpoBackup()],
),
);
}
}
class _CuerpoBackup extends StatelessWidget {
const _CuerpoBackup();
Future<void> _exportar(BuildContext context) async {
final l10n = AppLocalizations.of(context);
try {
final estado = context.read<EstadoRadio>();
// JSON serialization is owned by ServicioExportImport (S4-R4).
final json = await estado.exportarConfigJson();
final dir = await getTemporaryDirectory();
final file = File('${dir.path}/pluriwave-backup.json');
await file.writeAsString(json);
await Share.shareXFiles(
[XFile(file.path)],
subject: l10n.backupShareSubject,
text: l10n.backupShareText(DateTime.now().toLocal()),
);
} catch (e) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(l10n.backupExportError(e.toString()))),
);
}
}
}
Future<void> _importar(BuildContext context) async {
final l10n = AppLocalizations.of(context);
try {
final result = await FilePicker.platform.pickFiles(
type: FileType.custom,
allowedExtensions: ['json'],
);
if (result == null || result.files.single.path == null) return;
final file = File(result.files.single.path!);
final contenido = await file.readAsString();
if (!context.mounted) return;
// Parsing is owned by ServicioExportImport (S4-R4): null = malformed.
final json = context.read<EstadoRadio>().parsearConfigJson(contenido);
if (json == null) {
throw const FormatException('invalid backup file');
}
if (context.mounted) {
final confirmar = await showDialog<bool>(
context: context,
builder:
(ctx) => AlertDialog(
title: Text(AppLocalizations.of(ctx).backupImportTitle),
content: Text(
AppLocalizations.of(ctx).backupImportConfirmMessage,
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: Text(AppLocalizations.of(ctx).cancelAction),
),
FilledButton(
onPressed: () => Navigator.pop(ctx, true),
child: Text(AppLocalizations.of(ctx).backupImportTitle),
),
],
),
);
if (confirmar != true) return;
if (context.mounted) {
final estado = context.read<EstadoRadio>();
final alarmas = context.read<EstadoAlarmas>();
final messenger = ScaffoldMessenger.of(context);
await aplicarImportacionConfig(estado, alarmas, json);
messenger.showSnackBar(
SnackBar(content: Text(l10n.backupImportSuccess)),
);
}
}
} catch (e) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(l10n.backupImportError(e.toString()))),
);
}
}
}
@override
Widget build(BuildContext context) {
return PluriGlassSurface(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ListTile(
contentPadding: EdgeInsets.zero,
leading: const Icon(Icons.upload_outlined),
title: Text(AppLocalizations.of(context).backupExportTitle),
subtitle: Text(AppLocalizations.of(context).backupExportSubtitle),
onTap: () => _exportar(context),
),
ListTile(
contentPadding: EdgeInsets.zero,
leading: const Icon(Icons.download_outlined),
title: Text(AppLocalizations.of(context).backupImportTitle),
subtitle: Text(AppLocalizations.of(context).backupImportSubtitle),
onTap: () => _importar(context),
),
],
),
);
}
}
@@ -0,0 +1,556 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../estado/estado_ecualizador.dart';
import '../../estado/estado_radio.dart';
import '../../l10n/gen/app_localizations.dart';
import '../../modelos/preset_ecualizador.dart';
import '../../tema/pluriwave_theme.dart';
import '../../tema/pluriwave_tokens.dart';
import '../../widgets/ecualizador_widget.dart';
import '../../widgets/pluri_glass_surface.dart';
import '../../widgets/pluri_layout.dart';
import '../../widgets/pluri_push_scaffold.dart';
/// AUDIO group · "Ecualizador" (design ADR-3). Body moved verbatim from the
/// former `_SeccionEcualizador` in `pantalla_ajustes.dart` — only the panel
/// header row (icon + title + status chip) was removed, since
/// [PluriPushScaffold] now carries the title and the very next row already
/// shows the same active/disabled state.
///
/// WU13 (design ADR-5) restyled [EcualizadorWidget] itself and added the
/// base-vs-per-station explainer, the "Salida activa" summary row, the
/// "Emisoras con ajuste propio" drill-down, and the "Guardar como preset"
/// custom-preset flow — see `_CuerpoEcualizador` below.
///
/// Audit 11.10/11.8 (t4 lines 570-602, item 19): the prototype draws 4
/// SEPARATE cards (explainer banner / band sliders / "Salida activa +
/// Guardar como preset + Restablecer a plano" / "Emisoras con ajuste
/// propio"), not one shared glass surface wrapping everything — and adds
/// a "Restablecer a plano" action the build never had. `_CuerpoEcualizador`
/// no longer wraps its children in one outer `PluriGlassSurface`; each
/// section now either already draws its own background
/// ([EcualizadorWidget], unchanged) or gained one here.
class PantallaAjustesEcualizador extends StatelessWidget {
const PantallaAjustesEcualizador({super.key});
@override
Widget build(BuildContext context) {
// Audit 11.1 (t4 line 566): the master enable switch lives in the
// HEADER, not as the body's first row -- read here (a second,
// cheap watch alongside _CuerpoEcualizadorState's own Consumer2)
// purely to feed PluriPushScaffold.actions.
final eq = context.watch<EstadoEcualizador>();
return PluriPushScaffold(
title: AppLocalizations.of(context).equalizerTitle,
actions: [
Padding(
padding: const EdgeInsets.only(right: 8),
child: Switch(
key: const ValueKey('eq-master-switch'),
value: eq.activo,
onChanged: eq.cambiarActivo,
// t4 line 569: brand-teal track, white thumb -- the default
// Material thumb colour already renders white when "on".
activeTrackColor: PluriWaveTokens.brand,
),
),
],
body: ListView(
padding: PluriLayout.pageContentPadding,
children: const [_CuerpoEcualizador()],
),
);
}
}
class _CuerpoEcualizador extends StatefulWidget {
const _CuerpoEcualizador();
@override
State<_CuerpoEcualizador> createState() => _CuerpoEcualizadorState();
}
class _CuerpoEcualizadorState extends State<_CuerpoEcualizador> {
@override
void initState() {
super.initState();
final eq = context.read<EstadoEcualizador>();
// Fire-and-forget, mirroring the established
// `pantalla_ajustes_salida_audio.dart` pattern: both calls are
// genuinely async (a SharedPreferences read / a native re-query), so
// their completion never lands inside THIS build — no
// "setState() during build" risk.
unawaited(eq.cargarPresetsPersonalizados());
unawaited(eq.refrescarDispositivoActual());
}
@override
Widget build(BuildContext context) {
// EQ state comes from EstadoEcualizador (S4-R1/S4-R5); EstadoRadio is
// only consulted for the current station + favorite flag and for
// resolving station names in the "ajuste propio" drill-down.
return Consumer2<EstadoRadio, EstadoEcualizador>(
builder: (ctx, estado, eq, _) {
final disponible = eq.disponible;
final l10n = AppLocalizations.of(ctx);
final emisoraActual = estado.emisoraActual;
final mostrarModoPorEmisora =
emisoraActual != null && estado.emisoraActualEsFavorita;
final usandoEqPropio = eq.emisoraActualTienePresetPropio;
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Audit 11.1 (t4 line 566): the enable switch itself now lives
// in PluriPushScaffold's header (see PantallaAjustesEcualizador
// above) -- this stays behind only as the explanatory caption
// the old SwitchListTile's subtitle carried, so that
// information is not lost.
Text(
disponible
? l10n.equalizerRealtimeSubtitle
: l10n.equalizerPendingSubtitle,
style: Theme.of(ctx).textTheme.bodySmall,
),
const SizedBox(height: 12),
if (mostrarModoPorEmisora) ...[
const SizedBox(height: 8),
SwitchListTile.adaptive(
contentPadding: EdgeInsets.zero,
title: Text(l10n.equalizerPerStationTitle),
subtitle: Text(
usandoEqPropio
? l10n.equalizerPerStationActive(emisoraActual.nombre)
: l10n.equalizerPerStationMain(emisoraActual.nombre),
),
value: usandoEqPropio,
onChanged:
(usarPropio) =>
eq.cambiarModoEmisoraActual(usarPropio: usarPropio),
),
],
const SizedBox(height: 12),
// CARD 1 (t4 line 571) — already its own tinted Container.
_BannerExplicacionBase(l10n: l10n),
const SizedBox(height: 16),
// Bare row, no card (t4 lines 573-578) — unchanged.
PresetsEcualizadorWidget(
presetActual: eq.presetActual,
personalizados: eq.presetsPersonalizados,
onSeleccionar: (p) => eq.cambiarPreset(p),
),
const SizedBox(height: 14),
// CARD 2 (t4 lines 580-590) — EcualizadorWidget already draws
// its own PluriGlassSurface (design ADR-5), unchanged here.
EcualizadorWidget(
preset: eq.presetActual,
habilitado: eq.activo,
onCambio: (p) => eq.cambiarPreset(p),
),
const SizedBox(height: 14),
// CARD 3 (t4 lines 592-598): Salida activa / Guardar como
// preset / Restablecer a plano, grouped.
_TarjetaSalidaYAcciones(
eq: eq,
l10n: l10n,
onGuardarPreset: () => _abrirDialogoGuardarPreset(context, eq),
),
const SizedBox(height: 14),
// CARD 4 (t4 lines 600-602) — its own PluriGlassSurface now.
_FilaEmisorasConAjustePropio(eq: eq, estado: estado, l10n: l10n),
],
);
},
);
}
}
Future<void> _abrirDialogoGuardarPreset(
BuildContext context,
EstadoEcualizador eq,
) {
return showDialog<void>(
context: context,
builder: (_) => _DialogoGuardarPreset(eq: eq),
);
}
/// Base-vs-per-station explainer (spec `eq-custom-presets` "Base-vs-Per-
/// Station Explainer Preserved"): always visible, distinguishing the base
/// (global/device) EQ this screen edits from a station's own override.
class _BannerExplicacionBase extends StatelessWidget {
const _BannerExplicacionBase({required this.l10n});
final AppLocalizations l10n;
/// Audit 11.2 (t4 line 571): 16 -- doesn't match any of
/// [PluriWaveTokens]'s three named radii (14/18/30), so this stays a
/// local one-off constant (same precedent as `_stopButtonRadius` in
/// `pantalla_alarma_sonando.dart`).
static const _bannerRadius = 16.0;
@override
Widget build(BuildContext context) {
final tokens = context.pluriTokens;
return Container(
key: const Key('eq-base-explainer-banner'),
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: tokens.liveGreen.withValues(alpha: 0.09),
borderRadius: BorderRadius.circular(_bannerRadius),
border: Border.all(color: tokens.liveGreen.withValues(alpha: 0.26)),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(Icons.info_outline_rounded, size: 20, color: tokens.liveGreen),
const SizedBox(width: 11),
Expanded(
child: Text(
l10n.equalizerBaseExplainer,
style: Theme.of(context).textTheme.bodySmall,
),
),
],
),
);
}
}
/// "Salida activa" row (spec `eq-custom-presets` "Active Output Surfaced on
/// the Main Screen"): surfaced here instead of only inside the Advanced
/// (multi-device) screen, and kept live via the SAME `notifyListeners()`
/// calls `_onDispositivoCambiado` already fires — no new plumbing needed
/// beyond reading `dispositivoActualId` here.
class _FilaSalidaActiva extends StatelessWidget {
const _FilaSalidaActiva({required this.eq, required this.l10n});
final EstadoEcualizador eq;
final AppLocalizations l10n;
@override
Widget build(BuildContext context) {
final deviceId = eq.dispositivoActualId;
final nombre =
deviceId == null
? l10n.equalizerActiveOutputDefault
: eq.nombreVisible(deviceId, eq.nombrePlataforma(deviceId));
return Padding(
key: const Key('eq-active-output-row'),
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
child: Row(
children: [
const Icon(Icons.speaker_group_rounded, size: 20),
const SizedBox(width: 12),
Expanded(
child: Text(
l10n.equalizerActiveOutputLabel,
style: Theme.of(context).textTheme.bodyMedium,
),
),
Flexible(
child: Text(
nombre,
maxLines: 1,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.end,
style: Theme.of(context).textTheme.bodySmall,
),
),
],
),
);
}
}
/// Audit 11.8 (t4 lines 592-598): groups "Salida activa", "Guardar como
/// preset" and the entirely-missing "Restablecer a plano" into ONE card —
/// the build previously drew the first as a bare row and the second as a
/// floating, right-aligned button at the bottom of the whole screen.
/// "Restablecer a plano" is not a new capability: `PresetEcualizador.flat`
/// is the SAME preset the "Plano" chip in [PresetsEcualizadorWidget]
/// already applies via `EstadoEcualizador.cambiarPreset` — this is just a
/// second, prototype-mandated entry point to it.
class _TarjetaSalidaYAcciones extends StatelessWidget {
const _TarjetaSalidaYAcciones({
required this.eq,
required this.l10n,
required this.onGuardarPreset,
});
final EstadoEcualizador eq;
final AppLocalizations l10n;
final VoidCallback onGuardarPreset;
@override
Widget build(BuildContext context) {
return PluriGlassSurface(
key: const Key('eq-card-output-actions'),
borderRadius: BorderRadius.circular(context.pluriTokens.radiusMd),
padding: EdgeInsets.zero,
child: Column(
children: [
_FilaSalidaActiva(eq: eq, l10n: l10n),
const Divider(height: 1),
_FilaAccionTarjeta(
key: const Key('eq-save-preset-action'),
icon: Icons.bookmark_add_outlined,
label: l10n.equalizerSaveAsPresetAction,
trailing: const Icon(Icons.chevron_right_rounded, size: 19),
onTap: onGuardarPreset,
),
const Divider(height: 1),
_FilaAccionTarjeta(
key: const Key('eq-reset-flat-action'),
icon: Icons.restart_alt_rounded,
label: l10n.equalizerResetToFlatAction,
onTap: () => eq.cambiarPreset(PresetEcualizador.flat),
),
],
),
);
}
}
/// One tappable icon+label row inside [_TarjetaSalidaYAcciones] — shared
/// shape for "Guardar como preset" and "Restablecer a plano" (t4 lines
/// 595, 597: identical row layout, only "Guardar..." has a trailing
/// chevron since it opens a dialog rather than acting immediately).
class _FilaAccionTarjeta extends StatelessWidget {
const _FilaAccionTarjeta({
super.key,
required this.icon,
required this.label,
required this.onTap,
this.trailing,
});
final IconData icon;
final String label;
final VoidCallback onTap;
final Widget? trailing;
@override
Widget build(BuildContext context) {
return Material(
type: MaterialType.transparency,
child: InkWell(
onTap: onTap,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 13),
child: Row(
children: [
Icon(icon, size: 21),
const SizedBox(width: 12),
Expanded(
child: Text(
label,
style: Theme.of(context).textTheme.bodyMedium,
),
),
if (trailing != null) trailing!,
],
),
),
),
);
}
}
/// "Emisoras con ajuste propio" drill-down row (spec `eq-custom-presets`
/// "Stations-With-Own-EQ Drill-Down"): sourced from the existing
/// `presetsPorEmisora` map, no new state.
///
/// Audit 11.10 (t4 lines 600-602): CARD 4 — now wraps itself in its own
/// `PluriGlassSurface` instead of being one more row inside a shared
/// glass block.
class _FilaEmisorasConAjustePropio extends StatelessWidget {
const _FilaEmisorasConAjustePropio({
required this.eq,
required this.estado,
required this.l10n,
});
final EstadoEcualizador eq;
final EstadoRadio estado;
final AppLocalizations l10n;
@override
Widget build(BuildContext context) {
final uuids = eq.presetsPorEmisora.keys.toList();
return PluriGlassSurface(
key: const Key('eq-card-stations-own-eq'),
borderRadius: BorderRadius.circular(context.pluriTokens.radiusMd),
padding: EdgeInsets.zero,
child: Material(
type: MaterialType.transparency,
child: InkWell(
key: const Key('eq-stations-own-eq-row'),
borderRadius: BorderRadius.circular(context.pluriTokens.radiusMd),
onTap:
() => PluriPushScaffold.push(
context,
(_) => _PantallaEmisorasConAjustePropio(
uuids: uuids,
nombrePorUuid: (uuid) => _resolverNombreEmisora(estado, uuid),
),
),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 13),
child: Row(
children: [
const Icon(Icons.tune_rounded, size: 20),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
l10n.equalizerStationsWithOwnEqTitle,
style: Theme.of(context).textTheme.bodyMedium,
),
Text(
l10n.equalizerStationsWithOwnEqSubtitle,
style: Theme.of(context).textTheme.bodySmall,
),
],
),
),
Text(
key: const Key('eq-stations-own-eq-count'),
'${uuids.length}',
style: Theme.of(context).textTheme.bodySmall,
),
const SizedBox(width: 4),
const Icon(Icons.chevron_right_rounded, size: 20),
],
),
),
),
),
);
}
}
/// Resolves a station uuid to its display name via [EstadoRadio.listaFavoritos]
/// (the only local, synchronous list of known stations) — falls back to the
/// raw uuid for a station that has its own EQ but is not (or no longer) a
/// favorite, e.g. one set from the player's per-station EQ sheet (WU14).
String _resolverNombreEmisora(EstadoRadio estado, String uuid) {
for (final emisora in estado.listaFavoritos) {
if (emisora.uuid == uuid) return emisora.nombre;
}
return uuid;
}
/// Destination screen for the drill-down row above.
class _PantallaEmisorasConAjustePropio extends StatelessWidget {
const _PantallaEmisorasConAjustePropio({
required this.uuids,
required this.nombrePorUuid,
});
final List<String> uuids;
final String Function(String uuid) nombrePorUuid;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
return PluriPushScaffold(
title: l10n.equalizerStationsWithOwnEqTitle,
body:
uuids.isEmpty
? Center(child: Text(l10n.equalizerStationsWithOwnEqEmpty))
: ListView.separated(
padding: PluriLayout.pageContentPadding,
itemCount: uuids.length,
separatorBuilder: (_, __) => const SizedBox(height: 4),
itemBuilder: (ctx, i) {
final uuid = uuids[i];
return Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Row(
children: [
const Icon(Icons.radio_rounded, size: 20),
const SizedBox(width: 12),
Expanded(child: Text(nombrePorUuid(uuid))),
],
),
);
},
),
);
}
}
/// "Guardar como preset" dialog (spec `eq-custom-presets` "Custom Preset
/// Save" / "Custom Preset Naming Validates Non-Empty Input").
class _DialogoGuardarPreset extends StatefulWidget {
const _DialogoGuardarPreset({required this.eq});
final EstadoEcualizador eq;
@override
State<_DialogoGuardarPreset> createState() => _DialogoGuardarPresetState();
}
class _DialogoGuardarPresetState extends State<_DialogoGuardarPreset> {
late final TextEditingController _nombreCtrl;
String? _error;
@override
void initState() {
super.initState();
_nombreCtrl = TextEditingController();
}
@override
void dispose() {
_nombreCtrl.dispose();
super.dispose();
}
Future<void> _confirmar() async {
final guardado = await widget.eq.guardarPresetPersonalizado(
_nombreCtrl.text,
);
if (!mounted) return;
if (!guardado) {
setState(
() =>
_error =
AppLocalizations.of(context).equalizerSavePresetEmptyNameError,
);
return;
}
Navigator.of(context).pop();
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
return AlertDialog(
title: Text(l10n.equalizerSavePresetDialogTitle),
content: TextField(
key: const Key('eq-save-preset-name-field'),
controller: _nombreCtrl,
autofocus: true,
decoration: InputDecoration(
labelText: l10n.equalizerSavePresetNameLabel,
errorText: _error,
),
),
actions: [
FilledButton(
key: const Key('eq-save-preset-confirm-button'),
onPressed: _confirmar,
child: Text(l10n.equalizerSavePresetConfirm),
),
],
);
}
}
@@ -0,0 +1,133 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../estado/estado_radio.dart';
import '../../l10n/display_names.dart';
import '../../l10n/gen/app_localizations.dart';
import '../../modelos/emisora.dart';
import '../../widgets/pluri_glass_surface.dart';
import '../../widgets/pluri_layout.dart';
import '../../widgets/pluri_push_scaffold.dart';
/// EMISORAS group · "Emisora preferida" (design ADR-3). Body moved verbatim
/// from the former `_SeccionEmisoraPreferida` in `pantalla_ajustes.dart` —
/// only the panel header row (icon + title) was removed, since
/// [PluriPushScaffold] now carries the title.
class PantallaAjustesEmisoraPreferida extends StatelessWidget {
const PantallaAjustesEmisoraPreferida({super.key});
@override
Widget build(BuildContext context) => PluriPushScaffold(
title: AppLocalizations.of(context).preferredStationTitle,
body: ListView(
padding: PluriLayout.pageContentPadding,
children: const [_CuerpoEmisoraPreferida()],
),
);
}
class _CuerpoEmisoraPreferida extends StatelessWidget {
const _CuerpoEmisoraPreferida();
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
// S4-R5: scoped selects over identity-memoized getters.
final favoritas = context.select<EstadoRadio, List<Emisora>>(
(e) => e.listaFavoritos,
);
final disponibles = context.select<EstadoRadio, List<Emisora>>(
(e) => e.emisorasDisponiblesPreferencia,
);
final preferida = context.select<EstadoRadio, Emisora?>(
(e) => e.emisoraPreferida,
);
final opciones = _opciones(favoritas, disponibles, preferida);
return PluriGlassSurface(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
l10n.preferredStationDescription,
style: Theme.of(context).textTheme.bodySmall,
),
const SizedBox(height: 12),
if (opciones.isEmpty)
ListTile(
contentPadding: EdgeInsets.zero,
leading: const Icon(Icons.info_outline_rounded),
title: Text(l10n.preferredStationNoStationsTitle),
subtitle: Text(l10n.preferredStationNoStationsSubtitle),
)
else
DropdownButtonFormField<String>(
initialValue: preferida?.uuid,
decoration: InputDecoration(
labelText:
favoritas.isEmpty
? l10n.preferredStationAutomaticFallback
: l10n.preferredStationDefaultFavorite,
),
items: [
for (final emisora in opciones)
DropdownMenuItem<String>(
value: emisora.uuid,
child: Text(
localizedStationName(l10n, emisora.nombre),
overflow: TextOverflow.ellipsis,
),
),
],
onChanged: (uuid) async {
final seleccion = opciones.firstWhere((e) => e.uuid == uuid);
await context.read<EstadoRadio>().cambiarEmisoraPreferida(
seleccion,
);
},
),
if (preferida != null) ...[
const SizedBox(height: 8),
Text(
favoritas.any((e) => e.uuid == preferida.uuid)
? l10n.preferredStationCurrent(
localizedStationName(l10n, preferida.nombre),
)
: l10n.preferredStationAutoUsing(
localizedStationName(l10n, preferida.nombre),
),
),
const SizedBox(height: 8),
Align(
alignment: Alignment.centerLeft,
child: FilledButton.tonalIcon(
icon: const Icon(Icons.play_arrow_rounded),
label: Text(l10n.preferredStationPlay),
onPressed:
() =>
context
.read<EstadoRadio>()
.reproducirEmisoraPreferida(),
),
),
],
],
),
);
}
List<Emisora> _opciones(
List<Emisora> favoritas,
List<Emisora> disponibles,
Emisora? preferida,
) {
final base = favoritas.isNotEmpty ? favoritas : disponibles;
final mapa = <String, Emisora>{
for (final emisora in base) emisora.uuid: emisora,
};
if (preferida != null) {
mapa[preferida.uuid] = preferida;
}
return mapa.values.toList();
}
}
@@ -0,0 +1,240 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:uuid/uuid.dart';
import '../../estado/estado_radio.dart';
import '../../l10n/display_names.dart';
import '../../l10n/gen/app_localizations.dart';
import '../../modelos/emisora.dart';
import '../../servicios/servicio_anuncios.dart';
import '../../widgets/pluri_glass_surface.dart';
import '../../widgets/pluri_layout.dart';
import '../../widgets/pluri_push_scaffold.dart';
/// EMISORAS group · "Emisoras personalizadas" (design ADR-3). Body moved
/// verbatim from the former `_SeccionEmisoras` + `FormularioEmisoraPersonalizada` in
/// `pantalla_ajustes.dart` — the panel header's icon, title and spacer were
/// removed (the pushed screen's title now carries them); the "Add" action,
/// being a real capability rather than decorative chrome, stays in the body,
/// right-aligned.
class PantallaAjustesEmisorasPersonalizadas extends StatelessWidget {
const PantallaAjustesEmisorasPersonalizadas({super.key});
@override
Widget build(BuildContext context) => PluriPushScaffold(
title: AppLocalizations.of(context).customStationsTitle,
body: ListView(
padding: PluriLayout.pageContentPadding,
children: const [_CuerpoEmisorasPersonalizadas()],
),
);
}
class _CuerpoEmisorasPersonalizadas extends StatelessWidget {
const _CuerpoEmisorasPersonalizadas();
@override
Widget build(BuildContext context) {
// S4-R5: scoped select — rebuilds only when the custom list changes.
final custom = context.select<EstadoRadio, List<Emisora>>(
(e) => e.emisorasCustom,
);
return PluriGlassSurface(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Align(
alignment: Alignment.centerRight,
child: TextButton.icon(
icon: const Icon(Icons.add_rounded),
label: Text(AppLocalizations.of(context).customStationsAdd),
onPressed: () => _mostrarFormularioAnadir(context),
),
),
if (custom.isEmpty)
Padding(
padding: const EdgeInsets.only(top: 8),
child: Text(
AppLocalizations.of(context).customStationsEmpty,
style: TextStyle(
color: Theme.of(
context,
).colorScheme.onSurface.withValues(alpha: 0.6),
),
),
)
else
for (final emisora in custom)
ListTile(
contentPadding: EdgeInsets.zero,
leading: const Icon(Icons.radio_rounded),
title: Text(
localizedStationName(
AppLocalizations.of(context),
emisora.nombre,
),
),
subtitle: Text(
emisora.url,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
icon: const Icon(Icons.play_arrow_rounded),
tooltip: AppLocalizations.of(context).playAction,
onPressed:
() => context.read<EstadoRadio>().reproducir(emisora),
),
IconButton(
icon: const Icon(Icons.delete_outline_rounded),
tooltip: AppLocalizations.of(context).deleteAction,
onPressed:
() => context
.read<EstadoRadio>()
.eliminarEmitoraCustom(emisora.uuid),
),
],
),
),
],
),
);
}
Future<void> _mostrarFormularioAnadir(BuildContext context) async {
// ad-display spec "Interstitial Before Manual Station Add" (design.md
// ADR-6): fires on the CTA tap, before the form even opens — a no-op
// for premium (ServicioAnuncios' own entitlement gate).
await context.read<ServicioAnuncios>().intentarInterstitial();
if (!context.mounted) return;
await showModalBottomSheet(
context: context,
isScrollControlled: true,
useSafeArea: true,
showDragHandle: true,
builder: (ctx) => const FormularioEmisoraPersonalizada(),
);
}
}
class FormularioEmisoraPersonalizada extends StatefulWidget {
const FormularioEmisoraPersonalizada({super.key});
@override
State<FormularioEmisoraPersonalizada> createState() =>
FormularioEmisoraPersonalizadaState();
}
class FormularioEmisoraPersonalizadaState
extends State<FormularioEmisoraPersonalizada> {
final _formKey = GlobalKey<FormState>();
final _nombreCtrl = TextEditingController();
final _urlCtrl = TextEditingController();
final _paisCtrl = TextEditingController();
bool _guardando = false;
@override
void dispose() {
_nombreCtrl.dispose();
_urlCtrl.dispose();
_paisCtrl.dispose();
super.dispose();
}
Future<void> _guardar() async {
if (!_formKey.currentState!.validate()) return;
setState(() => _guardando = true);
final emisora = Emisora(
uuid: const Uuid().v4(),
nombre: _nombreCtrl.text.trim(),
url: _urlCtrl.text.trim(),
pais: _paisCtrl.text.trim().isEmpty ? null : _paisCtrl.text.trim(),
);
await context.read<EstadoRadio>().agregarEmitoraCustom(emisora);
if (mounted) Navigator.pop(context);
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final bottom = MediaQuery.of(context).viewInsets.bottom;
return Padding(
padding: EdgeInsets.fromLTRB(
PluriLayout.horizontal,
PluriLayout.horizontal,
PluriLayout.horizontal,
PluriLayout.horizontal + bottom,
),
child: Form(
key: _formKey,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
l10n.addStationTitle,
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 16),
TextFormField(
controller: _nombreCtrl,
decoration: InputDecoration(
labelText: AppLocalizations.of(context).stationNameLabel,
border: const OutlineInputBorder(),
),
validator:
(v) =>
v == null || v.trim().isEmpty
? AppLocalizations.of(context).requiredField
: null,
),
const SizedBox(height: 12),
TextFormField(
controller: _urlCtrl,
decoration: InputDecoration(
labelText: AppLocalizations.of(context).streamUrlLabel,
hintText: AppLocalizations.of(context).streamUrlHint,
border: const OutlineInputBorder(),
),
keyboardType: TextInputType.url,
validator: (v) {
if (v == null || v.trim().isEmpty) {
return l10n.requiredField;
}
final uri = Uri.tryParse(v.trim());
if (uri == null || !uri.hasScheme) return l10n.invalidUrl;
return null;
},
),
const SizedBox(height: 12),
TextFormField(
controller: _paisCtrl,
decoration: InputDecoration(
labelText: l10n.countryOptionalLabel,
border: const OutlineInputBorder(),
),
),
const SizedBox(height: 20),
FilledButton(
onPressed: _guardando ? null : _guardar,
child:
_guardando
? const SizedBox(
height: 20,
width: 20,
child: CircularProgressIndicator(strokeWidth: 2),
)
: Text(AppLocalizations.of(context).saveStation),
),
],
),
),
);
}
}
@@ -0,0 +1,245 @@
import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../estado/estado_grabacion.dart';
import '../../l10n/gen/app_localizations.dart';
import '../../widgets/pluri_glass_surface.dart';
import '../../widgets/pluri_layout.dart';
import '../../widgets/pluri_push_scaffold.dart';
/// GRABACIONES Y MÚSICA group · "Grabaciones" (design ADR-3). Body moved
/// verbatim from the former `_SeccionGrabaciones` in `pantalla_ajustes.dart`
/// — only the panel header's icon and title were removed (the pushed
/// screen's title now carries them); every method below is unchanged.
class PantallaAjustesGrabaciones extends StatelessWidget {
const PantallaAjustesGrabaciones({super.key});
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
return PluriPushScaffold(
title: l10n.recordingsSectionTitle,
body: ListView(
padding: PluriLayout.pageContentPadding,
children: const [_CuerpoGrabaciones()],
),
);
}
}
class _CuerpoGrabaciones extends StatelessWidget {
const _CuerpoGrabaciones();
Future<void> _seleccionarRuta(BuildContext context) async {
final estado = context.read<EstadoGrabacion>();
final messenger = ScaffoldMessenger.of(context);
final l10n = AppLocalizations.of(context);
final ruta = await FilePicker.platform.getDirectoryPath(
dialogTitle: l10n.recordingsFolderDialogTitle,
);
if (ruta == null) return;
try {
await estado.cambiarDirectorio(ruta);
if (!context.mounted) return;
messenger.showSnackBar(
SnackBar(content: Text(l10n.recordingsPathUpdated)),
);
} catch (e) {
if (!context.mounted) return;
messenger.showSnackBar(
SnackBar(content: Text(l10n.recordingsPathSaveError(e.toString()))),
);
}
}
Future<void> _restaurarRuta(BuildContext context) async {
final estado = context.read<EstadoGrabacion>();
final messenger = ScaffoldMessenger.of(context);
final l10n = AppLocalizations.of(context);
await estado.restaurarDirectorio();
if (!context.mounted) return;
messenger.showSnackBar(
SnackBar(content: Text(l10n.recordingsDefaultFolderRestored)),
);
}
Future<void> _abrirCarpeta(BuildContext context) async {
final estado = context.read<EstadoGrabacion>();
final messenger = ScaffoldMessenger.of(context);
final l10n = AppLocalizations.of(context);
try {
final abierto = await estado.abrirDirectorio();
if (!context.mounted) return;
if (!abierto) {
messenger.showSnackBar(
SnackBar(content: Text(l10n.recordingsOpenFolderError(l10n.dash))),
);
}
} catch (e) {
if (!context.mounted) return;
messenger.showSnackBar(
SnackBar(content: Text(l10n.recordingsOpenFolderError(e.toString()))),
);
}
}
Future<void> _editarTamanoMaximo(BuildContext context) async {
final estado = context.read<EstadoGrabacion>();
final actualMb = _bytesAMegabytes(estado.maxBytes);
final nuevoMb = await showModalBottomSheet<int>(
context: context,
isScrollControlled: true,
showDragHandle: true,
builder: (ctx) => _HojaTamanoMaximo(actualMb: actualMb),
);
if (nuevoMb == null || !context.mounted) return;
final l10n = AppLocalizations.of(context);
await estado.cambiarMaxBytes(nuevoMb * 1024 * 1024);
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(l10n.recordingsMaxSizeSaved(nuevoMb))),
);
}
int _bytesAMegabytes(int bytes) =>
(bytes / (1024 * 1024)).round().clamp(1, 1048576);
@override
Widget build(BuildContext context) {
// Recording state lives in EstadoGrabacion (S4-R2): this section only
// rebuilds on recording changes, never on playback notifications.
final estado = context.watch<EstadoGrabacion>();
final l10n = AppLocalizations.of(context);
return PluriGlassSurface(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
FutureBuilder<String>(
future: estado.directorioEfectivo(),
builder:
(ctx, snap) => ListTile(
contentPadding: EdgeInsets.zero,
leading: const Icon(Icons.folder_outlined),
title: Text(l10n.recordingsFolderTitle),
subtitle: Text(
snap.data ?? l10n.recordingsPathCalculating,
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
),
),
Wrap(
spacing: 8,
runSpacing: 8,
children: [
OutlinedButton.icon(
icon: const Icon(Icons.folder_open_rounded),
label: Text(l10n.recordingsChangePath),
onPressed: () => _seleccionarRuta(context),
),
FilledButton.tonalIcon(
icon: const Icon(Icons.folder_copy_rounded),
label: Text(l10n.recordingsOpenFolder),
onPressed: () => _abrirCarpeta(context),
),
IconButton.filledTonal(
tooltip: l10n.recordingsUseDefaultPath,
icon: const Icon(Icons.restore_rounded),
onPressed: () => _restaurarRuta(context),
),
],
),
const SizedBox(height: 8),
ListTile(
contentPadding: EdgeInsets.zero,
leading: const Icon(Icons.sd_storage_rounded),
title: Text(l10n.recordingsMaxSizeTitle),
subtitle: Text(
l10n.recordingsMaxSizeSubtitle(_bytesAMegabytes(estado.maxBytes)),
),
onTap: () => _editarTamanoMaximo(context),
),
const SizedBox(height: 8),
Text(
l10n.recordingsOriginalStreamHint,
style: Theme.of(context).textTheme.bodySmall,
),
],
),
);
}
}
/// The "maximum recording size" bottom sheet's own content, as a
/// `StatefulWidget` so its `TextEditingController` is owned by the SHEET's
/// `State`, not by the caller's `async` function — same bugfix shape as
/// `_HojaEditarGrupo` in `pantalla_ajustes_grupos_favoritos.dart` (a
/// controller disposed immediately after `showModalBottomSheet` resolves
/// races the sheet's own close animation, which still holds a bound
/// `TextField` for a couple more frames). Flutter only calls
/// `State.dispose()` once this widget is actually removed from the tree,
/// i.e. after the close animation finishes.
class _HojaTamanoMaximo extends StatefulWidget {
const _HojaTamanoMaximo({required this.actualMb});
final int actualMb;
@override
State<_HojaTamanoMaximo> createState() => _HojaTamanoMaximoState();
}
class _HojaTamanoMaximoState extends State<_HojaTamanoMaximo> {
late final TextEditingController _controller = TextEditingController(
text: widget.actualMb.toString(),
);
@override
void dispose() {
_controller.dispose();
super.dispose();
}
void _guardar() {
final value = int.tryParse(_controller.text.trim());
if (value == null || value <= 0) return;
Navigator.of(context).pop(value);
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final bottom = MediaQuery.viewInsetsOf(context).bottom;
return Padding(
padding: EdgeInsets.fromLTRB(20, 0, 20, bottom + 24),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
l10n.recordingsMaxSizeDialogTitle,
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 16),
TextField(
controller: _controller,
autofocus: true,
keyboardType: TextInputType.number,
decoration: InputDecoration(
labelText: l10n.recordingsMaxSizeMbLabel,
border: const OutlineInputBorder(),
),
),
const SizedBox(height: 16),
FilledButton.icon(
onPressed: _guardar,
icon: const Icon(Icons.save_rounded),
label: Text(l10n.saveQuickAccessButton),
),
],
),
);
}
}
@@ -0,0 +1,208 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../estado/estado_radio.dart';
import '../../l10n/gen/app_localizations.dart';
import '../../modelos/grupo_favoritos.dart';
import '../../widgets/pluri_glass_surface.dart';
import '../../widgets/pluri_layout.dart';
import '../../widgets/pluri_push_scaffold.dart';
/// EMISORAS group · "Grupos de favoritos" (design ADR-3). Body moved
/// verbatim from the former `_SeccionGruposFavoritos` in
/// `pantalla_ajustes.dart` — the panel header's icon and title were removed
/// (the pushed screen's title now carries them); the "Add list" action,
/// being a real capability rather than decorative chrome, stays in the body,
/// right-aligned.
class PantallaAjustesGruposFavoritos extends StatelessWidget {
const PantallaAjustesGruposFavoritos({super.key});
@override
Widget build(BuildContext context) => PluriPushScaffold(
title: AppLocalizations.of(context).favoriteGroupsTitle,
body: ListView(
padding: PluriLayout.pageContentPadding,
children: const [_CuerpoGruposFavoritos()],
),
);
}
class _CuerpoGruposFavoritos extends StatelessWidget {
const _CuerpoGruposFavoritos();
Future<void> _editarGrupo(
BuildContext context, [
GrupoFavoritos? grupo,
]) async {
final nombre = await showModalBottomSheet<String>(
context: context,
isScrollControlled: true,
showDragHandle: true,
builder: (ctx) => _HojaEditarGrupo(grupo: grupo),
);
if (nombre == null || !context.mounted) return;
final l10n = AppLocalizations.of(context);
final estado = context.read<EstadoRadio>();
if (grupo == null) {
await estado.crearGrupoFavoritos(nombre);
} else {
await estado.renombrarGrupoFavoritos(grupo.id, nombre);
}
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
grupo == null
? l10n.favoriteGroupsCreated
: l10n.favoriteGroupsUpdated,
),
),
);
}
Future<void> _eliminarGrupo(
BuildContext context,
GrupoFavoritos grupo,
) async {
final l10n = AppLocalizations.of(context);
await context.read<EstadoRadio>().eliminarGrupoFavoritos(grupo.id);
if (!context.mounted) return;
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(l10n.favoriteGroupsDeleted)));
}
String _nombreVisible(AppLocalizations l10n, GrupoFavoritos grupo) =>
grupo.esSinAsignar ? l10n.favoriteGroupsUnassigned : grupo.nombre;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
// S4-R5: scoped select — rebuilds only when the groups list changes.
final grupos = context.select<EstadoRadio, List<GrupoFavoritos>>(
(e) => e.gruposFavoritos,
);
return PluriGlassSurface(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(l10n.favoriteGroupsDescription),
const SizedBox(height: 4),
Align(
alignment: Alignment.centerRight,
child: TextButton.icon(
icon: const Icon(Icons.add_rounded),
label: Text(l10n.favoriteGroupsAdd),
onPressed: () => _editarGrupo(context),
),
),
const SizedBox(height: 4),
for (final grupo in grupos)
ListTile(
contentPadding: EdgeInsets.zero,
leading: Icon(
grupo.esSinAsignar ? Icons.lock_rounded : Icons.folder_rounded,
),
title: Text(_nombreVisible(l10n, grupo)),
subtitle:
grupo.esSinAsignar
? Text(l10n.favoriteGroupsProtectedHint)
: null,
trailing:
grupo.esSinAsignar
? null
: Wrap(
spacing: 4,
children: [
IconButton(
tooltip: l10n.favoriteGroupsEdit,
icon: const Icon(Icons.edit_rounded),
onPressed: () => _editarGrupo(context, grupo),
),
IconButton(
tooltip: l10n.favoriteGroupsDelete,
icon: const Icon(Icons.delete_outline_rounded),
onPressed: () => _eliminarGrupo(context, grupo),
),
],
),
),
],
),
);
}
}
/// The add/rename bottom sheet's own content, as a `StatefulWidget` so its
/// `TextEditingController` is owned by the SHEET's `State`, not by the
/// caller's `async` function (bugfix: a `TextEditingController` disposed
/// immediately after `showModalBottomSheet` resolves races the sheet's own
/// close animation, which still holds a `TextField` bound to that controller
/// for a couple more frames — "A TextEditingController was used after being
/// disposed"). Flutter only calls `State.dispose()` once this widget is
/// actually removed from the tree, i.e. after the close animation finishes,
/// so there is no dispose-timing decision left for the caller to get wrong.
class _HojaEditarGrupo extends StatefulWidget {
const _HojaEditarGrupo({required this.grupo});
final GrupoFavoritos? grupo;
@override
State<_HojaEditarGrupo> createState() => _HojaEditarGrupoState();
}
class _HojaEditarGrupoState extends State<_HojaEditarGrupo> {
late final TextEditingController _controller = TextEditingController(
text: widget.grupo?.nombre ?? '',
);
@override
void dispose() {
_controller.dispose();
super.dispose();
}
void _guardar() {
final value = _controller.text.trim();
if (value.isEmpty || value.length > 28) return;
Navigator.pop(context, value);
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final bottom = MediaQuery.viewInsetsOf(context).bottom;
return Padding(
padding: EdgeInsets.fromLTRB(20, 0, 20, bottom + 24),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
widget.grupo == null
? l10n.favoriteGroupsAdd
: l10n.favoriteGroupsEdit,
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 16),
TextField(
controller: _controller,
autofocus: true,
maxLength: 28,
decoration: InputDecoration(
labelText: l10n.favoriteGroupsNameLabel,
helperText: l10n.favoriteGroupsNameTooLong,
border: const OutlineInputBorder(),
),
),
const SizedBox(height: 12),
FilledButton.icon(
icon: const Icon(Icons.save_rounded),
label: Text(l10n.saveQuickAccessButton),
onPressed: _guardar,
),
],
),
);
}
}
@@ -0,0 +1,151 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../estado/estado_idioma.dart';
import '../../l10n/gen/app_localizations.dart';
import '../../widgets/pluri_glass_surface.dart';
import '../../widgets/pluri_layout.dart';
import '../../widgets/pluri_push_scaffold.dart';
/// APLICACIÓN group · "Idioma" (design ADR-3). Body moved verbatim from the
/// former `_SeccionIdioma` (+ `_IdiomaDisponible`) in `pantalla_ajustes.dart`
/// — only the panel header's icon and title were removed (the pushed
/// screen's title now carries them); every method and the language list
/// below is unchanged.
class PantallaAjustesIdioma extends StatelessWidget {
const PantallaAjustesIdioma({super.key});
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
return PluriPushScaffold(
title: l10n.languageSectionTitle,
body: ListView(
padding: PluriLayout.pageContentPadding,
children: const [_CuerpoIdioma()],
),
);
}
}
/// S8 (Tier 1 visual fidelity): the "system" pseudo-code, the native-name
/// list and the locale<->code mapping used to be private to this file's
/// `_CuerpoIdioma`. Hoisted to module level (unchanged values/logic) so
/// `pantalla_ajustes.dart`'s Idioma row can show the CURRENT language's
/// native name as its trailing value (t4's own example, line 526:
/// "Español") without duplicating this list.
const codigoIdiomaSistema = 'system';
const idiomasDisponibles = [
IdiomaDisponible(Locale('en'), 'English'),
IdiomaDisponible(Locale('es'), 'Español'),
IdiomaDisponible(Locale('zh'), '中文'),
IdiomaDisponible(Locale('hi'), 'हिन्दी'),
IdiomaDisponible(Locale('ar'), 'العربية'),
IdiomaDisponible(Locale('pt'), 'Português'),
IdiomaDisponible(Locale('fr'), 'Français'),
IdiomaDisponible(Locale('ru'), 'Русский'),
IdiomaDisponible(Locale('de'), 'Deutsch'),
IdiomaDisponible(Locale('ja'), '日本語'),
IdiomaDisponible(Locale('id'), 'Bahasa Indonesia'),
IdiomaDisponible(Locale('bn'), 'বাংলা'),
IdiomaDisponible(Locale('it'), 'Italiano'),
];
String codigoLocaleIdioma(Locale locale) {
final countryCode = locale.countryCode;
if (countryCode == null || countryCode.isEmpty) {
return locale.languageCode;
}
return '${locale.languageCode}_$countryCode';
}
/// The current language's own native name (e.g. "Español"), or the
/// localized "system default" label when [locale] is null.
String nombreIdiomaActual(Locale? locale, AppLocalizations l10n) {
if (locale == null) return l10n.languageSystemDefault;
final codigo = codigoLocaleIdioma(locale);
final idioma = idiomasDisponibles.firstWhere(
(item) => codigoLocaleIdioma(item.locale) == codigo,
orElse: () => idiomasDisponibles.first,
);
return idioma.nombreNativo;
}
class _CuerpoIdioma extends StatelessWidget {
const _CuerpoIdioma();
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final estadoIdioma = context.watch<EstadoIdioma>();
final locale = estadoIdioma.localeSeleccionado;
final valorActual =
locale == null ? codigoIdiomaSistema : codigoLocaleIdioma(locale);
return PluriGlassSurface(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(height: 8),
Text(
l10n.languageSectionDescription,
style: Theme.of(context).textTheme.bodySmall,
),
const SizedBox(height: 12),
DropdownButtonFormField<String>(
initialValue: valorActual,
decoration: InputDecoration(
labelText: l10n.languageSectionTitle,
border: const OutlineInputBorder(),
),
items: [
DropdownMenuItem(
value: codigoIdiomaSistema,
child: Text(l10n.languageSystemDefault),
),
for (final idioma in idiomasDisponibles)
DropdownMenuItem(
value: codigoLocaleIdioma(idioma.locale),
child: Text(idioma.nombreNativo),
),
],
onChanged: (codigo) async {
if (codigo == null) return;
if (codigo == codigoIdiomaSistema) {
await context.read<EstadoIdioma>().seleccionarSistema();
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(l10n.languageUpdatedSystem)),
);
return;
}
final idioma = idiomasDisponibles.firstWhere(
(item) => codigoLocaleIdioma(item.locale) == codigo,
orElse: () => idiomasDisponibles.first,
);
await context.read<EstadoIdioma>().seleccionarLocale(
idioma.locale,
);
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(l10n.languageUpdated(idioma.nombreNativo)),
),
);
},
),
],
),
);
}
}
class IdiomaDisponible {
const IdiomaDisponible(this.locale, this.nombreNativo);
final Locale locale;
final String nombreNativo;
}
@@ -0,0 +1,128 @@
import 'package:flutter/material.dart';
import 'package:package_info_plus/package_info_plus.dart';
import 'package:provider/provider.dart';
import '../../estado/estado_radio.dart';
import '../../l10n/gen/app_localizations.dart';
import '../../widgets/pluri_glass_surface.dart';
import '../../widgets/pluri_icon.dart';
import '../../widgets/pluri_layout.dart';
import '../../widgets/pluri_push_scaffold.dart';
import '../pantalla_tutorial_ayuda.dart';
/// APLICACIÓN group · "Info" (design ADR-3). Body moved verbatim from the
/// former `_SeccionInfo` in `pantalla_ajustes.dart`. Unlike the other four
/// sections in this batch, `_SeccionInfo` never had its own header
/// icon+title row — its first tile (app name + version) already served that
/// role — so there is no header row to strip here; the body below is
/// unchanged in full. `infoSectionTitle` is the one new ARB key this screen
/// needed, since no existing in-body header string covers a bare "Info"
/// label (see WU3b's apply-progress note).
class PantallaAjustesInfo extends StatelessWidget {
const PantallaAjustesInfo({super.key});
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
return PluriPushScaffold(
title: l10n.infoSectionTitle,
body: ListView(
padding: PluriLayout.pageContentPadding,
children: const [_CuerpoInfo()],
),
);
}
}
class _CuerpoInfo extends StatelessWidget {
const _CuerpoInfo();
@override
Widget build(BuildContext context) {
return Consumer<EstadoRadio>(
builder:
(ctx, estado, _) => PluriGlassSurface(
child: Column(
children: [
FutureBuilder<PackageInfo>(
future: PackageInfo.fromPlatform(),
builder: (ctx, snap) {
final version =
snap.hasData
? 'v${snap.data!.version}+${snap.data!.buildNumber}'
: AppLocalizations.of(ctx).appVersionLoading;
return ListTile(
contentPadding: EdgeInsets.zero,
leading: const PluriIcon(
glyph: PluriIconGlyph.settings,
variant: PluriIconVariant.filled,
),
title: Text(AppLocalizations.of(ctx).appTitle),
subtitle: Text(
AppLocalizations.of(ctx).appVersionSubtitle(version),
),
);
},
),
FutureBuilder<int>(
future: estado.favoritos.obtenerTodos().then((l) => l.length),
builder:
(ctx, snap) => ListTile(
contentPadding: EdgeInsets.zero,
leading: const Icon(Icons.favorite_outline_rounded),
title: Text(
AppLocalizations.of(ctx).savedFavoritesTitle,
),
trailing: Text(
snap.data?.toString() ??
AppLocalizations.of(ctx).dash,
style: Theme.of(ctx).textTheme.bodyLarge,
),
),
),
ListTile(
contentPadding: EdgeInsets.zero,
leading: const Icon(Icons.help_outline_rounded),
title: Text(AppLocalizations.of(ctx).helpTitle),
subtitle: Text(AppLocalizations.of(ctx).helpSubtitle),
trailing: const Icon(Icons.chevron_right_rounded),
onTap:
() => Navigator.of(ctx).push(
MaterialPageRoute<void>(
builder:
(_) => const PantallaTutorialAyuda(
primerArranque: false,
),
),
),
),
ListTile(
contentPadding: EdgeInsets.zero,
leading: const Icon(Icons.verified_outlined),
title: Text(AppLocalizations.of(ctx).stationFilterTitle),
subtitle: Text(
AppLocalizations.of(ctx).stationFilterSubtitle,
),
trailing: Icon(
Icons.check_circle_rounded,
color: Theme.of(ctx).colorScheme.secondary,
),
),
ListTile(
contentPadding: EdgeInsets.zero,
leading: const Icon(Icons.music_note_outlined),
title: Text(AppLocalizations.of(ctx).backgroundAudioTitle),
subtitle: Text(
AppLocalizations.of(ctx).backgroundAudioSubtitle,
),
trailing: Icon(
Icons.check_circle_rounded,
color: Theme.of(ctx).colorScheme.secondary,
),
),
],
),
),
);
}
}
@@ -0,0 +1,139 @@
import 'package:flutter/material.dart';
import '../../l10n/gen/app_localizations.dart';
import '../../servicios/musica_local_auto.dart';
import '../../servicios/servicio_audio.dart' show invalidarArbolAuto;
import '../../widgets/pluri_glass_surface.dart';
import '../../widgets/pluri_layout.dart';
import '../../widgets/pluri_push_scaffold.dart';
/// GRABACIONES Y MÚSICA group · "Música local" (design ADR-3). Body moved
/// verbatim from the former `_SeccionMusicaLocal` in `pantalla_ajustes.dart`
/// — only the panel header's icon and title were removed (the pushed
/// screen's title now carries them). Deliberately does NOT use
/// `FilePicker.platform` (see tasks.md "Grounding corrections") —
/// [FuenteMusicaLocalAutoImpl.elegirCarpeta] calls the native
/// `pickMusicFolder` channel method directly, since it needs a
/// persistable-grant SAF tree URI, not a plain filesystem path.
class PantallaAjustesMusicaLocal extends StatelessWidget {
const PantallaAjustesMusicaLocal({super.key});
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
return PluriPushScaffold(
title: l10n.localMusicSectionTitle,
body: ListView(
padding: PluriLayout.pageContentPadding,
children: const [_CuerpoMusicaLocal()],
),
);
}
}
class _CuerpoMusicaLocal extends StatefulWidget {
const _CuerpoMusicaLocal();
@override
State<_CuerpoMusicaLocal> createState() => _CuerpoMusicaLocalState();
}
class _CuerpoMusicaLocalState extends State<_CuerpoMusicaLocal> {
final _fuente = FuenteMusicaLocalAutoImpl();
late Future<String?> _carpetaActual;
@override
void initState() {
super.initState();
_carpetaActual = _fuente.carpetaActual();
}
Future<void> _elegirCarpeta(BuildContext context) async {
final messenger = ScaffoldMessenger.of(context);
final l10n = AppLocalizations.of(context);
try {
final uri = await _fuente.elegirCarpeta();
if (uri == null) return; // Cancelled — no snackbar, matches the SAF
// picker's own "nothing changed" affordance.
// fix/android-auto-musica-local, item 4: acaba de aparecer música
// local donde antes no había. Android Auto cachea la raíz y no
// vuelve a preguntar por su cuenta, así que sin esto el coche seguía
// sin ofrecer «Música Local» hasta el siguiente re-bind — que puede
// no llegar en toda la sesión. Fuera del `context.mounted` de abajo:
// el árbol del coche no depende de que esta pantalla siga viva.
invalidarArbolAuto();
if (!context.mounted) return;
setState(() {
_carpetaActual = Future.value(uri);
});
messenger.showSnackBar(
SnackBar(content: Text(l10n.localMusicFolderUpdated)),
);
} catch (e) {
if (!context.mounted) return;
messenger.showSnackBar(
SnackBar(content: Text(l10n.localMusicFolderSaveError(e.toString()))),
);
}
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
return PluriGlassSurface(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(height: 8),
Text(
l10n.localMusicSectionDescription,
style: Theme.of(context).textTheme.bodySmall,
),
FutureBuilder<String?>(
future: _carpetaActual,
builder: (ctx, snap) {
final carpeta = snap.data;
return ListTile(
contentPadding: EdgeInsets.zero,
leading: const Icon(Icons.folder_outlined),
title: Text(l10n.localMusicFolderTitle),
subtitle: Text(
(carpeta == null || carpeta.isEmpty)
? l10n.localMusicFolderNotConfigured
: nombreCarpetaDesdeUri(
carpeta,
nombreGenerico: l10n.localMusicFolderGenericName,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
);
},
),
const SizedBox(height: 8),
FutureBuilder<String?>(
future: _carpetaActual,
builder: (ctx, snap) {
final configurada = (snap.data ?? '').isNotEmpty;
return Align(
alignment: Alignment.centerLeft,
child: OutlinedButton.icon(
icon: const Icon(Icons.folder_open_rounded),
label: Text(
configurada
? l10n.localMusicChangePath
: l10n.localMusicChoosePath,
),
onPressed: () => _elegirCarpeta(context),
),
);
},
),
],
),
);
}
}
@@ -0,0 +1,68 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../estado/estado_radio.dart';
import '../../l10n/gen/app_localizations.dart';
import '../../widgets/pluri_glass_surface.dart';
import '../../widgets/pluri_layout.dart';
import '../../widgets/pluri_push_scaffold.dart';
/// EMISORAS group · "Orden de listas" (design ADR-3). Body moved verbatim
/// from the former `_SeccionOrdenListas` in `pantalla_ajustes.dart` — only
/// the panel header row (icon + title) was removed, since
/// [PluriPushScaffold] now carries the title.
class PantallaAjustesOrdenListas extends StatelessWidget {
const PantallaAjustesOrdenListas({super.key});
@override
Widget build(BuildContext context) => PluriPushScaffold(
title: AppLocalizations.of(context).stationOrderTitle,
body: ListView(
padding: PluriLayout.pageContentPadding,
children: const [_CuerpoOrdenListas()],
),
);
}
class _CuerpoOrdenListas extends StatelessWidget {
const _CuerpoOrdenListas();
@override
Widget build(BuildContext context) {
// S4-R5: scoped select — rebuilds only when the ordering changes.
final orden = context.select<EstadoRadio, OrdenEmisoras>(
(e) => e.ordenListas,
);
final l10n = AppLocalizations.of(context);
return PluriGlassSurface(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SegmentedButton<OrdenEmisoras>(
segments: [
ButtonSegment(
value: OrdenEmisoras.nombre,
icon: const Icon(Icons.sort_by_alpha_rounded),
label: Text(l10n.stationOrderByName),
),
ButtonSegment(
value: OrdenEmisoras.calidad,
icon: const Icon(Icons.hd_rounded),
label: Text(l10n.stationOrderByQuality),
),
],
selected: {orden},
onSelectionChanged: (value) {
context.read<EstadoRadio>().cambiarOrdenListas(value.first);
},
),
const SizedBox(height: 8),
Text(
l10n.stationOrderScopeDescription,
style: Theme.of(context).textTheme.bodySmall,
),
],
),
);
}
}
@@ -0,0 +1,369 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../estado/estado_ecualizador.dart';
import '../../l10n/gen/app_localizations.dart';
import '../../modelos/preset_ecualizador.dart';
import '../../widgets/ecualizador_widget.dart';
import '../../widgets/pluri_glass_surface.dart';
import '../../widgets/pluri_layout.dart';
import '../../widgets/pluri_push_scaffold.dart';
/// AUDIO group · "Salida de audio" (design ADR-3). Body moved verbatim from
/// the former `_SeccionEcualizadorAvanzado` + `_FilaDispositivo` +
/// `_DialogoEdicionDispositivo` in `pantalla_ajustes.dart` — only the panel
/// header row (icon + title) was removed, since [PluriPushScaffold] now
/// carries the title. The visible title text is unchanged
/// ("Advanced Equalization Options" / `advancedEqSectionTitle`) — the file
/// name reflects the design's AUDIO row label ("Salida de audio"), not new
/// UI copy.
class PantallaAjustesSalidaAudio extends StatelessWidget {
const PantallaAjustesSalidaAudio({super.key});
@override
Widget build(BuildContext context) => PluriPushScaffold(
title: AppLocalizations.of(context).advancedEqSectionTitle,
body: ListView(
padding: PluriLayout.pageContentPadding,
children: const [_CuerpoSalidaAudio()],
),
);
}
/// Always shows the feature toggle so the user can discover it. When the
/// toggle is OFF, the device list is completely absent (not just invisible),
/// matching the spec scenario "Settings section is absent when toggle is
/// off".
class _CuerpoSalidaAudio extends StatefulWidget {
const _CuerpoSalidaAudio();
@override
State<_CuerpoSalidaAudio> createState() => _CuerpoSalidaAudioState();
}
class _CuerpoSalidaAudioState extends State<_CuerpoSalidaAudio> {
@override
void initState() {
super.initState();
// Fix "stale green dot": refresh the active-device indicator with a
// fresh native query the moment this section becomes visible, instead of
// trusting the last event that happened to arrive (no-op when the
// multi-device toggle is off).
unawaited(context.read<EstadoEcualizador>().refrescarDispositivoActual());
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final eq = context.watch<EstadoEcualizador>();
final multiDeviceEnabled = eq.eqMultiDeviceEnabled;
final presetsDispositivo = eq.presetsDispositivo;
return PluriGlassSurface(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// The toggle uses GestureDetector + custom row instead of
// SwitchListTile to avoid Material ink assertion inside
// PluriGlassSurface's DecoratedBox. The visual result is identical
// to SwitchListTile.
Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Row(
children: [
Expanded(
child: GestureDetector(
behavior: HitTestBehavior.translucent,
onTap: () => _alternarMultiDevice(eq, !multiDeviceEnabled),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
l10n.advancedEqEnableToggle,
style: Theme.of(context).textTheme.bodyLarge,
),
const SizedBox(height: 2),
Text(
l10n.advancedEqEnableToggleSubtitle,
style: Theme.of(context).textTheme.bodySmall,
),
],
),
),
),
Switch.adaptive(
value: multiDeviceEnabled,
onChanged:
(habilitado) => _alternarMultiDevice(eq, habilitado),
),
],
),
),
if (multiDeviceEnabled) ...[
const SizedBox(height: 8),
Text(
l10n.advancedEqKnownDevicesTitle,
style: Theme.of(context).textTheme.labelLarge,
),
const SizedBox(height: 4),
if (presetsDispositivo.isEmpty)
Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Text(
l10n.advancedEqKnownDevicesEmpty,
style: Theme.of(context).textTheme.bodySmall,
),
)
else
for (final entry in presetsDispositivo.entries)
_FilaDispositivo(deviceId: entry.key, preset: entry.value),
],
],
),
);
}
/// Toggles the multi-device EQ feature and, when turning it ON, requests
/// `BLUETOOTH_CONNECT` at this point-of-intent (bt-device-identity ADR-1)
/// so BT devices report their real MAC instead of the OS placeholder.
/// Fire-and-forget: neither call blocks the toggle UI on its result.
void _alternarMultiDevice(EstadoEcualizador eq, bool habilitado) {
unawaited(eq.cambiarMultiDeviceEnabled(habilitado));
if (habilitado) {
unawaited(eq.solicitarPermisoBluetooth());
}
}
}
/// A single device row in the known-devices list.
///
/// Shows a connection indicator (green dot) when [deviceId] matches the
/// currently active device. Tapping the edit icon opens
/// [_DialogoEdicionDispositivo].
class _FilaDispositivo extends StatelessWidget {
const _FilaDispositivo({required this.deviceId, required this.preset});
final String deviceId;
final PresetEcualizador preset;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final eq = context.watch<EstadoEcualizador>();
final isActive = eq.dispositivoActualId == deviceId;
final displayName = _nombreLegible(
deviceId,
eq.nombreVisible(deviceId, eq.nombrePlataforma(deviceId)),
);
return Padding(
padding: const EdgeInsets.symmetric(vertical: 6),
child: Row(
children: [
// The dot marks where audio is coming out RIGHT NOW, which is not the
// same as "paired" or "connected" — it needs a label, both for screen
// readers and for anyone wondering what a bare green dot means.
if (isActive)
Tooltip(
message: l10n.eqDeviceActiveOutput,
child: Icon(
Icons.circle,
size: 10,
color: Colors.green,
semanticLabel: l10n.eqDeviceActiveOutput,
),
)
else
const SizedBox(width: 10),
const SizedBox(width: 8),
const Icon(Icons.headphones_rounded, size: 20),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
displayName,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.bodyMedium,
),
Text(
l10n.advancedEqDevicePresetLabel(preset.nombre),
style: Theme.of(context).textTheme.bodySmall,
),
],
),
),
IconButton(
icon: const Icon(Icons.edit_rounded, size: 20),
tooltip: l10n.eqDeviceEditTitle,
onPressed: () => _abrirModal(context),
),
],
),
);
}
/// Turns a device id the user never named into something readable.
///
/// [nombreVisible] falls back to the raw id when neither a custom name nor a
/// platform name is known — which is the normal case for a Bluetooth device
/// that is not currently connected, since platform names are cached in memory
/// only. Showing `bt_a2dp:AA:BB:CC:DD:EE:FF` tells the user nothing, so keep
/// the transport plus the tail of the address, which is what distinguishes
/// two otherwise identical rows.
static String _nombreLegible(String deviceId, String nombreVisible) {
if (nombreVisible != deviceId) return nombreVisible;
final separador = deviceId.indexOf(':');
if (separador == -1) return deviceId;
final transporte = deviceId.substring(0, separador);
final resto = deviceId.substring(separador + 1);
final etiqueta = switch (transporte) {
'bt_a2dp' => 'Bluetooth',
'usb_headset' => 'USB',
_ => transporte,
};
final cola = resto.split(':').where((p) => p.isNotEmpty).toList();
if (cola.isEmpty) return etiqueta;
final sufijo =
cola.length >= 2 ? cola.sublist(cola.length - 2).join(':') : cola.last;
return '$etiqueta · $sufijo';
}
Future<void> _abrirModal(BuildContext context) async {
await showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
showDragHandle: true,
builder:
(ctx) =>
_DialogoEdicionDispositivo(deviceId: deviceId, preset: preset),
);
}
}
/// Bottom sheet for editing a device's custom name and EQ preset.
class _DialogoEdicionDispositivo extends StatefulWidget {
const _DialogoEdicionDispositivo({
required this.deviceId,
required this.preset,
});
final String deviceId;
final PresetEcualizador preset;
@override
State<_DialogoEdicionDispositivo> createState() =>
_DialogoEdicionDispositivoState();
}
class _DialogoEdicionDispositivoState
extends State<_DialogoEdicionDispositivo> {
late final TextEditingController _nombreCtrl;
late PresetEcualizador _presetActual;
@override
void initState() {
super.initState();
final eq = context.read<EstadoEcualizador>();
final displayName = eq.nombreVisible(
widget.deviceId,
eq.nombrePlataforma(widget.deviceId),
);
_nombreCtrl = TextEditingController(text: displayName);
_presetActual = widget.preset;
}
@override
void dispose() {
_nombreCtrl.dispose();
super.dispose();
}
Future<void> _guardar() async {
final eq = context.read<EstadoEcualizador>();
await eq.renombrarDispositivo(widget.deviceId, _nombreCtrl.text);
if (_presetActual != widget.preset) {
await eq.guardarPresetDispositivo(widget.deviceId, _presetActual);
}
if (mounted) Navigator.of(context).pop();
}
Future<void> _eliminar() async {
final eq = context.read<EstadoEcualizador>();
final l10n = AppLocalizations.of(context);
final messenger = ScaffoldMessenger.of(context);
final nombre =
_nombreCtrl.text.trim().isEmpty
? widget.deviceId
: _nombreCtrl.text.trim();
await eq.eliminarDispositivo(widget.deviceId);
if (!mounted) return;
Navigator.of(context).pop();
messenger.showSnackBar(
SnackBar(content: Text(l10n.eqDeviceRemoved(nombre))),
);
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final bottom = MediaQuery.viewInsetsOf(context).bottom;
return SingleChildScrollView(
child: Padding(
padding: EdgeInsets.fromLTRB(20, 0, 20, bottom + 24),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
l10n.eqDeviceEditTitle,
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 16),
TextField(
controller: _nombreCtrl,
autofocus: true,
decoration: InputDecoration(
labelText: l10n.eqDeviceNameLabel,
hintText: l10n.eqDeviceNameHint,
border: const OutlineInputBorder(),
),
),
const SizedBox(height: 16),
EcualizadorWidget(
preset: _presetActual,
onCambio: (p) => setState(() => _presetActual = p),
),
const SizedBox(height: 16),
Row(
children: [
Expanded(
child: FilledButton.icon(
onPressed: _guardar,
icon: const Icon(Icons.save_rounded),
label: Text(l10n.eqDeviceNameConfirm),
),
),
const SizedBox(width: 12),
// Lets the user clear stale or duplicate rows. The device comes
// back on its next connection, so this is recoverable.
OutlinedButton.icon(
onPressed: _eliminar,
icon: const Icon(Icons.delete_outline_rounded),
label: Text(l10n.eqDeviceRemove),
),
],
),
],
),
),
);
}
}
@@ -0,0 +1,225 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../estado/estado_radio.dart';
import '../../l10n/gen/app_localizations.dart';
import '../../widgets/pluri_glass_surface.dart';
import '../../widgets/pluri_layout.dart';
import '../../widgets/pluri_push_scaffold.dart';
/// AUDIO group · "Temporizador de sueño" (design ADR-3). Body moved verbatim
/// from the former `_SeccionTimerSueno` in `pantalla_ajustes.dart` — the
/// panel header's icon and title were removed (the pushed screen's title
/// now carries them), and the "Add" action moved into the screen's app bar
/// via [PluriPushScaffold.actions] since it is a real capability, not
/// decorative header chrome.
class PantallaAjustesTimerSueno extends StatelessWidget {
const PantallaAjustesTimerSueno({super.key});
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
return PluriPushScaffold(
title: l10n.timerSectionTitle,
actions: [
IconButton(
icon: const Icon(Icons.add_rounded),
tooltip: l10n.timerSectionAdd,
onPressed: () => _anadirPreset(context),
),
],
body: ListView(
padding: PluriLayout.pageContentPadding,
children: const [_CuerpoTimerSueno()],
),
);
}
}
Future<void> _anadirPreset(BuildContext context) async {
final l10n = AppLocalizations.of(context);
final duracion = await showModalBottomSheet<Duration>(
context: context,
isScrollControlled: true,
showDragHandle: true,
builder: (_) => const _FormularioDuracionTimer(),
);
if (duracion == null || !context.mounted) return;
await context.read<EstadoRadio>().agregarTimerSuenoPreset(duracion);
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
'${l10n.saveQuickAccessButton}: ${_formatearDuracionTimer(l10n, duracion)}',
),
),
);
}
class _CuerpoTimerSueno extends StatelessWidget {
const _CuerpoTimerSueno();
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
// S4-R5: scoped select — rebuilds only when the presets list changes.
final presets = context.select<EstadoRadio, List<int>>(
(e) => e.timerSuenoPresetsSegundos,
);
return PluriGlassSurface(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
l10n.timerSectionDescription,
style: Theme.of(context).textTheme.bodySmall,
),
const SizedBox(height: 12),
Wrap(
spacing: 8,
runSpacing: 8,
children: [
for (final segundos in presets)
InputChip(
label: Text(
_formatearDuracionTimer(l10n, Duration(seconds: segundos)),
),
onDeleted:
presets.length <= 1
? null
: () => context
.read<EstadoRadio>()
.eliminarTimerSuenoPreset(segundos),
),
],
),
const SizedBox(height: 8),
Align(
alignment: Alignment.centerLeft,
child: TextButton.icon(
icon: const Icon(Icons.restore_rounded),
label: Text(l10n.timerSectionRestoreRecommended),
onPressed:
() =>
context.read<EstadoRadio>().restaurarTimerSuenoPresets(),
),
),
],
),
);
}
}
class _FormularioDuracionTimer extends StatefulWidget {
const _FormularioDuracionTimer();
@override
State<_FormularioDuracionTimer> createState() =>
_FormularioDuracionTimerState();
}
class _FormularioDuracionTimerState extends State<_FormularioDuracionTimer> {
final _horasCtrl = TextEditingController();
final _minutosCtrl = TextEditingController(text: '15');
final _segundosCtrl = TextEditingController();
@override
void dispose() {
_horasCtrl.dispose();
_minutosCtrl.dispose();
_segundosCtrl.dispose();
super.dispose();
}
int _leer(TextEditingController ctrl) => int.tryParse(ctrl.text.trim()) ?? 0;
void _guardar() {
final l10n = AppLocalizations.of(context);
final duracion = Duration(
hours: _leer(_horasCtrl),
minutes: _leer(_minutosCtrl),
seconds: _leer(_segundosCtrl),
);
if (duracion <= Duration.zero) {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(l10n.durationGreaterThanZero)));
return;
}
Navigator.pop(context, duracion);
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final bottom = MediaQuery.viewInsetsOf(context).bottom;
return SafeArea(
child: Padding(
padding: EdgeInsets.fromLTRB(18, 0, 18, 18 + bottom),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
l10n.newQuickAccessTitle,
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 12),
Row(
children: [
Expanded(child: _campo(_horasCtrl, l10n.hoursLabel)),
const SizedBox(width: 8),
Expanded(child: _campo(_minutosCtrl, l10n.minutesLabel)),
const SizedBox(width: 8),
Expanded(child: _campo(_segundosCtrl, l10n.secondsLabel)),
],
),
const SizedBox(height: 16),
FilledButton.icon(
icon: const Icon(Icons.save_rounded),
label: Text(l10n.saveQuickAccessButton),
onPressed: _guardar,
),
],
),
),
);
}
Widget _campo(TextEditingController controller, String label) {
return TextField(
controller: controller,
keyboardType: TextInputType.number,
decoration: InputDecoration(
labelText: label,
border: const OutlineInputBorder(),
),
);
}
}
String _formatearDuracionTimer(AppLocalizations l10n, Duration duracion) {
final horas = duracion.inHours;
final minutos = duracion.inMinutes.remainder(60);
final segundos = duracion.inSeconds.remainder(60);
if (horas > 0) {
return l10n.durationHoursMinutesSeconds(
horas,
minutos.toString().padLeft(2, '0'),
segundos.toString().padLeft(2, '0'),
);
}
if (minutos > 0) {
return segundos == 0
? l10n.durationMinutesOnly(minutos)
: l10n.durationMinutesSeconds(
minutos,
segundos.toString().padLeft(2, '0'),
);
}
return l10n.durationSecondsOnly(segundos);
}
@@ -0,0 +1,160 @@
import 'package:flutter/material.dart';
import '../../../tema/pluriwave_theme.dart';
import '../../../widgets/pluri_glass_surface.dart';
import '../../../widgets/pluri_layout.dart';
/// Design ADR-3: the two nav-row primitives every Settings detail screen is
/// reached through. [GrupoAjustes] is a single [PluriGlassSurface] card
/// carrying a [PluriWaveTypography.eyebrowLabel] group heading and a list of
/// [FilaAjuste] rows, each pushing its detail screen via
/// `PluriPushScaffold.push`. Neither primitive owns any business logic or
/// provider read — they are pure navigation chrome, which is what keeps the
/// Settings root down to "grouped nav rows only".
class GrupoAjustes extends StatelessWidget {
const GrupoAjustes({super.key, required this.titulo, required this.filas});
/// Group heading, styled with [PluriWaveTypography.eyebrowLabel]. Authored
/// already in its display form — this style never applies `toUpperCase()`.
final String titulo;
/// S8 (Tier 1 visual fidelity): `Widget`, not `List<FilaAjuste>` — a few
/// rows source their current-value text asynchronously (e.g. recordings
/// count, app version) and wrap their own `FilaAjuste` in a
/// `FutureBuilder`. `GrupoAjustes` only iterates and inserts dividers; it
/// never reaches into `FilaAjuste`-specific state.
final List<Widget> filas;
@override
Widget build(BuildContext context) {
final type = context.pluriType;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Audit 10.2 (t4 line 511): the group eyebrow sits OUTSIDE the
// card entirely, at title-tier (20px) padding -- it used to live
// INSIDE the PluriGlassSurface, sharing the card's own 16px
// padding.
Padding(
padding: const EdgeInsets.fromLTRB(
PluriLayout.titleHorizontal,
0,
PluriLayout.titleHorizontal,
6,
),
child: Text(titulo, style: type.eyebrowLabel),
),
PluriGlassSurface(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
for (var i = 0; i < filas.length; i++) ...[
// S10 (Tier 4 visual fidelity): the prototype insets its
// row divider by 47px (t4 line 516), not full-bleed.
if (i > 0) const Divider(height: 1, indent: 47),
filas[i],
],
],
),
),
],
);
}
}
/// A single Settings navigation row: icon, title, an optional trailing
/// current-value string, and a trailing chevron. Tapping it is the row's
/// only behaviour — it carries no switches, sliders or text fields, which
/// is what "zero inline controls" means at the root.
class FilaAjuste extends StatelessWidget {
const FilaAjuste({
super.key,
required this.icon,
required this.titulo,
required this.onTap,
this.valor,
this.iconColor,
});
final IconData icon;
final String titulo;
final VoidCallback onTap;
/// S8 (Tier 1 visual fidelity): the prototype puts a trailing current
/// value on nearly every row (t4 lines 512-539, 625 — e.g. "3 guardados",
/// "Alfabético", "Español"), 13px `rgba(242,247,250,.55)`. Null means
/// "no current value to show" — the row renders exactly as before.
final String? valor;
/// Audit 10.5 (t4 lines 514/516/522 — equalizer `#21D4D9`, hd
/// `#7EE4C2`, folder `#F4B860`): only the first row or two of a group
/// carries an accent colour in the prototype; every other row's icon
/// stays the ambient default. Null (the vast majority of rows) means
/// "no accent" — the icon renders exactly as before.
final Color? iconColor;
/// Issue 5 (feedback-pruebas): caps how much width the trailing current
/// value can claim. `ListTile` gives `trailing` as much width as it wants
/// before handing the title whatever is left — an unbounded value (e.g. a
/// real, arbitrarily long station name for "Emisora preferida") could
/// squeeze the title down to almost nothing, forcing it to wrap across
/// several lines that then get cut short by the row's fixed height.
static const _anchoMaximoValor = 108.0;
@override
Widget build(BuildContext context) {
final type = context.pluriType;
final valorActual = valor;
return ListTile(
contentPadding: EdgeInsets.zero,
// 10.6 (Tier 4 visual fidelity): the prototype's row icon is 21px (t4
// line 514), not Material's 24px default.
leading: Icon(icon, size: 21, color: iconColor),
// 10.8 (Tier 4 visual fidelity): the prototype's row title is
// 14px/w700 (t4 line 514); cardTitle is 14.5/w700 — a one-off
// override, not a new PluriWaveTypography style (mirrors the
// precedent set for the ringing screen's station name, audit 9.7).
// Issue 5: constrained to one line, ellipsizing instead of wrapping —
// labels must wrap as little as possible and never render visibly
// truncated (a multi-line wrap inside this fixed-height row cuts the
// last line short, which reads as broken, not as intentional).
title: Text(
titulo,
style: type.cardTitle.copyWith(fontSize: 14),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (valorActual != null) ...[
ConstrainedBox(
constraints: const BoxConstraints(maxWidth: _anchoMaximoValor),
child: Text(
valorActual,
// bodyStrong is already 13/w600, matching the prototype's row
// value spec exactly — only the colour needs overriding.
style: type.bodyStrong.copyWith(
color: const Color(0xFFF2F7FA).withValues(alpha: 0.55),
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.end,
),
),
const SizedBox(width: 6),
],
// 10.9 (Tier 4 visual fidelity): the prototype's chevron is 19px
// at 40% opacity (t4 lines 515-539), not Material's 24px
// full-opacity default.
Icon(
Icons.chevron_right_rounded,
size: 19,
color: const Color(0xFFF2F7FA).withValues(alpha: 0.4),
),
],
),
onTap: onTap,
);
}
}
File diff suppressed because it is too large Load Diff
+726 -171
View File
@@ -1,152 +1,163 @@
import 'dart:async';
import 'dart:async';
import 'dart:ui';
import 'package:flutter/material.dart';
import 'package:just_audio/just_audio.dart';
import 'package:flutter/services.dart';
import 'package:flutter_animate/flutter_animate.dart';
import 'package:provider/provider.dart';
import '../estado/estado_alarmas.dart';
import '../l10n/display_names.dart';
import '../l10n/formato_fechas.dart';
import '../l10n/gen/app_localizations.dart';
import '../estado/estado_radio.dart';
import '../modelos/alarma_musical.dart';
import '../servicios/servicio_audio.dart';
import '../widgets/pluri_glass_surface.dart';
import '../tema/pluri_animate.dart';
import '../tema/pluriwave_theme.dart';
import '../tema/pluriwave_tokens.dart';
import '../widgets/pluri_wave_scaffold.dart';
class PantallaAlarmaSonando extends StatefulWidget {
const PantallaAlarmaSonando({
super.key,
required this.alarma,
this.audioPrearrancado = false,
});
const PantallaAlarmaSonando({super.key, required this.alarma});
final AlarmaMusical alarma;
final bool audioPrearrancado;
@override
State<PantallaAlarmaSonando> createState() => _PantallaAlarmaSonandoState();
}
class _PantallaAlarmaSonandoState extends State<PantallaAlarmaSonando> {
static const _volumenInicialFadeIn = 0.05;
static const _fadeStep = Duration(milliseconds: 250);
final AudioPlayer _fallbackPlayer = AudioPlayer();
StreamSubscription<EstadoReproduccion>? _estadoSub;
Timer? _fallbackTimer;
Timer? _fadeInTimer;
bool _fallbackActivo = false;
bool _radioIntentada = false;
bool _audioFlutterConfirmado = false;
/// Single-exit guard: Stop, snooze and the system back gesture all funnel
/// into the same teardown; whichever lands first wins and the rest no-op,
/// so a back-press racing a button tap can never run the exit flow twice
/// (a second _dismissScreen would pop the route UNDER the alarm screen).
bool _salidaEnCurso = false;
/// Retryable force-stop affordance (Finding A, spec `alarm-stop-safety` /
/// "Retryable Force-Stop Affordance"): true while a VERIFIED stop failure
/// (or an unknown-state exception) is outstanding. Unlike a timed SnackBar,
/// this drives a persistent in-screen banner that stays until a confirmed
/// stop clears it — the ring is still audible while this is true, so the
/// screen intentionally does NOT dismiss.
bool _falloDetencionVisible = false;
late final EstadoAlarmas _alarmas;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) => _iniciarAlarma());
_alarmas = context.read<EstadoAlarmas>();
_alarmas.addListener(_alReconciliarFinExterno);
}
Future<void> _iniciarAlarma() async {
final radio = context.read<EstadoRadio>();
await _fallbackPlayer.setVolume(_volumenInicialFadeIn);
await _fallbackPlayer.setLoopMode(LoopMode.one);
final emisora = widget.alarma.emisora;
if (emisora == null) {
await _iniciarFallback();
return;
}
_radioIntentada = true;
await radio.audio.setVolumen(_volumenInicialFadeIn);
if (!widget.audioPrearrancado) {
unawaited(radio.reproducir(emisora));
}
_iniciarFadeIn();
_estadoSub = radio.estadoStream.listen((estado) {
if (estado == EstadoReproduccion.reproduciendo && mounted) {
_fallbackTimer?.cancel();
_confirmarAudioFlutterListo();
}
if (estado == EstadoReproduccion.error && mounted) {
_iniciarFallback();
}
});
_fallbackTimer = Timer(const Duration(seconds: 12), () {
if (mounted) _iniciarFallback();
});
if (widget.audioPrearrancado && radio.audio.estaSonando) {
_fallbackTimer?.cancel();
}
/// External end-of-ring reconciliation (RES-1): if this alarm's occurrence
/// gets recorded as MISSED while this screen is up, auto-dismiss instead of
/// leaving a stale ringing screen with no audio behind it.
void _alReconciliarFinExterno() {
if (_salidaEnCurso || !mounted) return;
if (_alarmas.ultimaAlarmaPerdidaId != widget.alarma.id) return;
_salidaEnCurso = true;
_dismissScreen();
}
Future<void> _iniciarFallback() async {
if (_fallbackActivo) return;
_fallbackActivo = true;
await _fallbackPlayer.setAsset(_assetFallback(widget.alarma.sonidoInterno));
await _fallbackPlayer.play();
_iniciarFadeIn();
await _confirmarAudioFlutterListo();
if (mounted) setState(() {});
}
void _iniciarFadeIn() {
_fadeInTimer?.cancel();
final volumenObjetivo = widget.alarma.volumen.clamp(0.0, 1.0);
final inicio = _volumenInicialFadeIn.clamp(0.0, volumenObjetivo);
final segundosFade = widget.alarma.fadeInSegundos.clamp(0, 60);
if (segundosFade <= 0 || volumenObjetivo <= inicio) {
unawaited(_aplicarVolumenGlobal(volumenObjetivo));
return;
}
final duracionTotalMs = segundosFade * 1000;
final pasos = (duracionTotalMs / _fadeStep.inMilliseconds).ceil();
var pasoActual = 0;
_fadeInTimer = Timer.periodic(_fadeStep, (timer) {
if (!mounted) {
timer.cancel();
/// Pure UI: the ring's audio is owned entirely by the native
/// PluriWaveAlarmService. This screen only reports the outcome to
/// EstadoAlarmas; it never touches an audio player or a device-volume
/// channel.
Future<void> _detener() async {
if (_salidaEnCurso) return;
_salidaEnCurso = true;
final alarmas = context.read<EstadoAlarmas>();
final alarmaId = widget.alarma.id;
try {
await alarmas.finalizarEjecucion(alarmaId);
if (alarmas.error != null) {
// Verified stop failure (Finding A): the alarm is still ringing, so
// dismissing now would hide the only retry affordance. Reset the
// single-exit guard so a retry (this button again, back gesture, or
// the banner's own action below) can run the teardown again.
_salidaEnCurso = false;
if (mounted) setState(() => _falloDetencionVisible = true);
return;
}
pasoActual++;
final t = (pasoActual / pasos).clamp(0.0, 1.0);
final volumenActual = inicio + (volumenObjetivo - inicio) * t;
unawaited(_aplicarVolumenGlobal(volumenActual));
if (t >= 1) timer.cancel();
});
} catch (e) {
debugPrint('[PluriWave][alarmas] finalizar ejecucion fallo: $e');
// Unknown state (Finding A): treat exactly like a verified failure —
// stay and show the retry banner. The notification's native Stop
// action remains the out-of-band fallback, and PopScope already routes
// back through this same method on a subsequent back-press.
_salidaEnCurso = false;
if (mounted) setState(() => _falloDetencionVisible = true);
return;
}
if (mounted) _dismissScreen();
}
Future<void> _aplicarVolumenGlobal(double volumen) async {
if (!mounted) return;
final radio = context.read<EstadoRadio>();
await radio.audio.setVolumen(volumen.clamp(0.0, 1.0));
await _fallbackPlayer.setVolume(volumen.clamp(0.0, 1.0));
}
Future<void> _confirmarAudioFlutterListo() async {
if (_audioFlutterConfirmado) return;
_audioFlutterConfirmado = true;
await context.read<EstadoAlarmas>().android.confirmarAudioFlutter(
widget.alarma.id,
);
}
Future<void> _detener() async {
final radio = context.read<EstadoRadio>();
/// Retry action bound to the persistent force-stop banner (Finding A,
/// SS-3b): re-invokes the fail-safe stop directly; dismisses ONLY on a
/// confirmed success, otherwise the banner stays exactly as it was.
Future<void> _forzarDetencion() async {
if (_salidaEnCurso) return;
_salidaEnCurso = true;
final alarmas = context.read<EstadoAlarmas>();
await alarmas.forzarDetencion(widget.alarma.id);
if (alarmas.error == null) {
if (mounted) _dismissScreen();
} else {
// Verified failure (RES-2): reset the guard so the banner's own retry
// action (or another button) can run the teardown again.
_salidaEnCurso = false;
if (mounted) setState(() {});
}
}
/// Flutter-first snooze (S2-R1): routes through the canonical
/// EstadoAlarmas.posponerAlarma, which hides the native notification (same
/// stop path as dismiss) and re-programs Android.
Future<void> _posponer(int minutos) async {
if (_salidaEnCurso) return;
_salidaEnCurso = true;
final alarmas = context.read<EstadoAlarmas>();
// Captured BEFORE dismiss (Design D4): the ringing screen dismisses by
// design below, so the messenger must outlive it for the failure
// SnackBar to still be shown.
final messenger = ScaffoldMessenger.of(context);
// posponerAlarma no longer throws on a native scheduling failure (it
// records the failure into EstadoAlarmas.error and always calls
// notifyListeners instead) — the dismiss-in-finally below is now a
// structural safety net, not a workaround for an expected throw. The
// screen still closes either way (dismiss-by-design); the failure is
// reported to the user via a SnackBar, not silently swallowed.
try {
await alarmas.posponerAlarma(widget.alarma, minutos);
final error = alarmas.error;
if (error != null) {
messenger.showSnackBar(SnackBar(content: Text(error)));
}
} catch (e) {
debugPrint('[PluriWave][alarmas] posponer alarma fallo: $e');
} finally {
if (mounted) _dismissScreen();
}
}
/// Dismisses the alarm screen safely in both live-app and dead-app states.
///
/// When the alarm screen is the root activity (launched via full-screen intent
/// from a dead app), [Navigator.canPop] returns false and calling
/// [Navigator.pop] would be a no-op. In that case [SystemNavigator.pop] is
/// used to call `Activity.finish()` and return to the home screen.
void _dismissScreen() {
final navigator = Navigator.of(context);
_fallbackTimer?.cancel();
_fadeInTimer?.cancel();
await _estadoSub?.cancel();
await _fallbackPlayer.stop();
await radio.audio.pausar();
await alarmas.finalizarEjecucion(widget.alarma.id);
if (mounted) navigator.pop();
if (navigator.canPop()) {
navigator.pop();
} else {
SystemNavigator.pop();
}
}
@override
void dispose() {
_fallbackTimer?.cancel();
_fadeInTimer?.cancel();
_estadoSub?.cancel();
_fallbackPlayer.dispose();
_alarmas.removeListener(_alReconciliarFinExterno);
super.dispose();
}
@@ -154,69 +165,613 @@ class _PantallaAlarmaSonandoState extends State<PantallaAlarmaSonando> {
Widget build(BuildContext context) {
final alarma = widget.alarma;
final l10n = AppLocalizations.of(context);
return Scaffold(
backgroundColor: const Color(0xFF061722),
body: SafeArea(
child: Padding(
padding: const EdgeInsets.all(20),
child: Center(
child: PluriGlassSurface(
borderRadius: BorderRadius.circular(32),
padding: const EdgeInsets.all(24),
glowColor: const Color(0xFFFFB86B).withValues(alpha: 0.35),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Image.asset(
'assets/icons/alarmas/alarm_music.png',
width: 128,
height: 128,
),
const SizedBox(height: 16),
Text(
_hora(alarma),
style: Theme.of(context).textTheme.displayMedium?.copyWith(
fontWeight: FontWeight.w900,
letterSpacing: -2,
),
),
const SizedBox(height: 8),
Text(
alarma.nombre,
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 8),
Text(
_fallbackActivo
? 'Sonando con audio seguro interno.'
: _radioIntentada
? 'Intentando reproducir tu emisora con máxima calidad disponible.'
: 'Preparando audio seguro interno.',
textAlign: TextAlign.center,
),
const SizedBox(height: 22),
FilledButton.icon(
onPressed: _detener,
icon: const Icon(Icons.stop_rounded),
label: Text(l10n.stopAlarmAction),
),
],
),
final tokens = context.pluriTokens;
// Cold-GPU note (Design 2.4): PluriGlassSurface uses a BackdropFilter and
// the first frame after a screen-off FSI wake can stutter. The blur sigma
// is capped here, and reduced-motion users skip the entry animation
// entirely via pluriFadeIn.
return PopScope(
// System back / predictive back must behave exactly like Stop: a plain
// route pop would run only dispose(), leaving the native ring audible
// with no alarm UI left to stop it (the native service is the sole
// audio owner and is torn down via the same finalizarEjecucion path).
canPop: false,
onPopInvokedWithResult: (didPop, _) {
if (didPop) return;
unawaited(_detener());
},
child: _cuerpo(context, alarma, l10n, tokens),
);
}
Widget _cuerpo(
BuildContext context,
AlarmaMusical alarma,
AppLocalizations l10n,
PluriWaveTokens tokens,
) {
final type = context.pluriType;
// WU11 (native-alarms delta — restyle, drop live countdown label):
// full-bleed blurred art replaces the PluriGlassSurface card. Cold-GPU
// note (Design 2.4) still applies to the entry animation below, which
// is why it stays on the foreground content only, not the background.
return PluriWaveScaffold(
body: Stack(
fit: StackFit.expand,
children: [
_FondoArteDifuminado(tokens: tokens),
// Audit 9.1 (t4 line 411): the pulsing amber halo behind the
// hero content — see _HaloPulsante for why it is a BOUNDED pulse,
// not the prototype's literal `infinite` animation.
Positioned(
top: 150,
left: 0,
right: 0,
child: IgnorePointer(
child: Center(child: _HaloPulsante(tokens: tokens)),
),
),
SafeArea(
child: Padding(
padding: const EdgeInsets.fromLTRB(24, 20, 24, 24),
child: Column(
children: [
const Spacer(flex: 2),
// Audit 9.3 (t4 line 415): the schedule pill was missing
// entirely — built from `alarma.tipoProgramacion` (a field
// already on the domain model), no new plumbing.
_PildoraProgramacion(
texto: _resumenProgramacion(context, l10n, alarma),
tokens: tokens,
),
const SizedBox(height: 22),
FittedBox(
fit: BoxFit.scaleDown,
child: Text(
_hora(alarma),
key: const ValueKey('ringing-hero-time'),
// Audit 9.5 (t4 line 417): 88px/w800/ls-4/lh.95 on
// THIS screen only — a local override, not a change
// to the shared heroTime token (EditorHoraInline, the
// alarm editor's hour block, is the other consumer
// and wants height:1, t4 line 379).
style: type.heroTime.copyWith(
letterSpacing: -4,
height: 0.95,
),
),
),
const SizedBox(height: 6),
// Audit 9.4: the date line goes BELOW the hero time. The
// prototype's order is pill (t4:415-416) -> 7:30 at 88px
// (t4:417) -> "Lunes, 3 de agosto" at 14px (t4:419). An
// earlier pass placed it between the pill and the time
// and cited "t4 line 419" for it — that line number is
// where the date SITS in the source, which is precisely
// why it comes last, not first.
Text(
fechaLargaConDiaSemana(
Localizations.localeOf(context).toString(),
DateTime.now(),
),
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: Theme.of(
context,
).colorScheme.onSurface.withValues(alpha: 0.6),
),
),
const SizedBox(height: 6),
Text(
localizedAlarmName(l10n, alarma.nombre),
textAlign: TextAlign.center,
style: type.bodyStrong,
),
const SizedBox(height: 22),
ClipRRect(
borderRadius: BorderRadius.circular(_artworkRadio),
child: Image.asset(
'assets/icons/alarmas/alarm_music.png',
width: _artworkLado,
height: _artworkLado,
fit: BoxFit.cover,
errorBuilder:
(_, __, ___) => Icon(
Icons.music_note_rounded,
size: 96,
color: tokens.warmCoral,
),
),
),
const SizedBox(height: 18),
// Static status line (Design D8): sourced only from
// widget.alarma, never from a live audio/player state — the
// ring's own audio state is owned natively and this screen
// has no channel back to it.
Text(
alarma.emisora != null
? localizedStationName(l10n, alarma.emisora!.nombre)
: l10n.alarmRingingNotificationTitle,
textAlign: TextAlign.center,
// Audit 9.7 (t4 line 423): 20px/w800/ls-.3 — this used to
// be `cardTitle` (14.5px/w700), 5.5px and 100 weight
// units under spec for the station name on a full-screen
// ringing surface.
style: type.cardTitle.copyWith(
fontSize: 20,
fontWeight: FontWeight.w800,
letterSpacing: -0.3,
),
),
// WU11 (native-alarms delta — Ringing Screen Shows a
// Static Status Label): only rendered while this alarm was
// actually configured with a fade-in; a STATIC label, no
// seconds suffix, no ticking value — the native→Flutter
// progress channel that a live countdown would need is
// deliberately absent from this architecture (resolution
// 4). Not spec-tested to also disappear once the fade-in
// period elapses: this screen has no clock signal to know
// when that is, and inventing one would be exactly the
// out-of-scope plumbing being avoided.
if (alarma.fadeInSegundos > 0) ...[
const SizedBox(height: 4),
_EstadoSubidaVolumen(l10n: l10n, tokens: tokens),
],
const Spacer(flex: 3),
Align(
alignment: Alignment.centerLeft,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
// Audit 9.8 (t4 line 428): the POSPONER eyebrow
// lost its snooze icon.
Icon(
Icons.snooze_rounded,
size: 19,
color: tokens.warmCoral,
),
const SizedBox(width: 8),
Text(
l10n.snoozeAction,
style: type.eyebrowLabel.copyWith(
color: tokens.warmCoral,
),
),
],
),
),
// Issue 3 (feedback-pruebas): t4:427 wraps POSPONER's
// eyebrow, the snooze tiles and Stop in a `gap:12` flex
// column -- the same 12 on both sides, not the 10/14 pair
// this used to carry.
const SizedBox(height: 12),
_FilaSnoozeFija(
alarma: alarma,
l10n: l10n,
tokens: tokens,
onPosponer: _posponer,
),
const SizedBox(height: 12),
SizedBox(
width: double.infinity,
child: FilledButton.icon(
key: const ValueKey('ringing-stop-button'),
style: FilledButton.styleFrom(
// Audit 9.11 (t4 line 434): the prototype's Stop is a
// NEUTRAL translucent surface — not the brand cyan
// `colorScheme.primary` this used to render in. It is
// the largest element on a full-screen surface, so
// the wrong colour family was maximally visible.
backgroundColor: Colors.white.withValues(alpha: 0.08),
foregroundColor:
Theme.of(context).colorScheme.onSurface,
side: BorderSide(
color: Colors.white.withValues(alpha: 0.16),
),
minimumSize: const Size.fromHeight(76),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(
_stopButtonRadius,
),
),
),
onPressed: _detener,
icon: const Icon(Icons.stop_circle_rounded),
label: Text(l10n.stopAlarmAction),
),
),
if (_falloDetencionVisible) ...[
const SizedBox(height: 14),
_bannerFalloDetencion(context, l10n, tokens),
],
],
),
).pluriFadeIn(context),
),
],
),
);
}
/// Persistent force-stop retry banner (Finding A, spec `alarm-stop-safety`
/// / "Retryable Force-Stop Affordance"): an in-screen section rather than a
/// timed SnackBar, so it stays visible until [_forzarDetencion] confirms a
/// stop (or the screen is torn down externally) instead of auto-dismissing
/// after a fixed duration.
Widget _bannerFalloDetencion(
BuildContext context,
AppLocalizations l10n,
PluriWaveTokens tokens,
) {
return Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: tokens.warmCoral.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(16),
border: Border.all(color: tokens.warmCoral.withValues(alpha: 0.4)),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(l10n.alarmStopFailedMessage, textAlign: TextAlign.center),
const SizedBox(height: 8),
FilledButton.tonal(
onPressed: _forzarDetencion,
child: Text(l10n.alarmForceStopAction),
),
],
),
);
}
}
String _hora(AlarmaMusical alarma) =>
'${alarma.hora.toString().padLeft(2, '0')}:${alarma.minuto.toString().padLeft(2, '0')}';
/// Audit 9.11 (t4 line 434): the Stop button's radius — doesn't match any of
/// [PluriWaveTokens]'s three named radii (14/18/30), so it stays a local
/// constant here rather than growing the shared token surface for a single
/// call site (mirrors `_ArteEscuchar._radio` in `pantalla_inicio.dart`).
const _stopButtonRadius = 24.0;
/// Audit 9.6 (t4 line 421): the ringing screen's art is 180x180 with a 36
/// corner radius — neither matches [PluriWaveTokens]'s three named radii
/// (14/18/30), so this stays a local constant (same precedent as
/// [_stopButtonRadius] above).
const _artworkLado = 180.0;
const _artworkRadio = 36.0;
/// Audit 9.3: the schedule pill's text, built only from
/// [AlarmaMusical.tipoProgramacion] and the fields it already carries per
/// case (`diasSemana`, `fechaUnica`) — no new domain plumbing. Reuses the
/// `alarmScheduleOnce`/`alarmScheduleWeekdays` ARB keys, which existed
/// already but had no consumer anywhere in the app.
String _resumenProgramacion(
BuildContext context,
AppLocalizations l10n,
AlarmaMusical alarma,
) {
switch (alarma.tipoProgramacion) {
case TipoProgramacionAlarma.diaria:
return l10n.alarmScheduleDaily;
case TipoProgramacionAlarma.unica:
final localeTag = Localizations.localeOf(context).toString();
return l10n.alarmScheduleOnce(
fechaCortaLocalizada(localeTag, alarma.fechaUnica ?? DateTime.now()),
);
case TipoProgramacionAlarma.diasSemana:
final dias = (List<int>.from(alarma.diasSemana)
..sort()).map((d) => _weekdayShort(l10n, d)).join(', ');
return l10n.alarmScheduleWeekdays(dias);
}
}
// Mirrors `pantalla_alarmas.dart`'s private `_weekdayShort` (kept local
// rather than shared/exported: this screen's only other tie to the alarm
// editor is the domain model itself, and duplicating a 7-line switch is
// cheaper than adding a cross-screen import for it).
String _weekdayShort(AppLocalizations l10n, int day) => switch (day) {
DateTime.monday => l10n.weekdayShortMonday,
DateTime.tuesday => l10n.weekdayShortTuesday,
DateTime.wednesday => l10n.weekdayShortWednesday,
DateTime.thursday => l10n.weekdayShortThursday,
DateTime.friday => l10n.weekdayShortFriday,
DateTime.saturday => l10n.weekdayShortSaturday,
DateTime.sunday => l10n.weekdayShortSunday,
_ => '?',
};
/// Schedule pill (audit 9.3, t4 line 415): `alarm` icon + schedule summary
/// on a warmCoral-tinted pill, matching the prototype's
/// `rgba(244,184,96,.16)` fill / `rgba(244,184,96,.45)` border exactly.
class _PildoraProgramacion extends StatelessWidget {
const _PildoraProgramacion({required this.texto, required this.tokens});
final String texto;
final PluriWaveTokens tokens;
@override
Widget build(BuildContext context) {
return DecoratedBox(
key: const ValueKey('ringing-schedule-pill'),
decoration: BoxDecoration(
color: tokens.warmCoral.withValues(alpha: 0.16),
borderRadius: BorderRadius.circular(999),
border: Border.all(color: tokens.warmCoral.withValues(alpha: 0.45)),
),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 15, vertical: 8),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.alarm, size: 18, color: tokens.warmCoral),
const SizedBox(width: 6),
Text(
texto,
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w800,
letterSpacing: 0.84,
color: tokens.warmCoral,
),
),
],
),
),
);
}
}
String _assetFallback(SonidoInternoAlarma sonido) => switch (sonido) {
SonidoInternoAlarma.amanecer => 'assets/audio/alarm_amanecer.wav',
SonidoInternoAlarma.campanaSuave =>
'assets/audio/alarm_campana_suave.wav',
SonidoInternoAlarma.pulsoDigital => 'assets/audio/alarm_pulso_digital.wav',
};
/// Pulsing amber halo (audit 9.1, t4 line 411): a 420x420 radial gradient
/// centered behind the hero content, echoing the alarm's warmCoral accent.
///
/// BOUNDED, not infinite: the prototype's CSS is `animation: pw-pulse 2.4s
/// ease-in-out infinite`, but this screen's dismiss guard
/// (`pantalla_alarma_sonando_dismiss_guard_test.dart`, protected — must stay
/// byte-identical to `main`) calls `pumpAndSettle()` after every mount and
/// every interaction. A genuinely infinite `AnimationController.repeat()`
/// anywhere in this widget's subtree would hang every one of those calls
/// forever, with no way to fix it since that file cannot be edited (see
/// `_EstadoSubidaVolumen` above for the same reasoning applied earlier on
/// this exact screen). One grow-and-settle cycle, timed to the prototype's
/// own 2.4s cadence, delivers the same "draws the eye" motion without ever
/// leaving a frame scheduled forever. Respects reduced motion exactly like
/// every other entry animation in this app (`PluriAnimate`).
class _HaloPulsante extends StatelessWidget {
const _HaloPulsante({required this.tokens});
String _hora(AlarmaMusical alarma) =>
'${alarma.hora.toString().padLeft(2, '0')}:${alarma.minuto.toString().padLeft(2, '0')}';
final PluriWaveTokens tokens;
static const _lado = 420.0;
@override
Widget build(BuildContext context) {
final halo = Container(
key: const ValueKey('ringing-pulse-halo'),
width: _lado,
height: _lado,
decoration: BoxDecoration(
shape: BoxShape.circle,
gradient: RadialGradient(
colors: [
tokens.warmCoral.withValues(alpha: 0.2),
tokens.warmCoral.withValues(alpha: 0),
],
stops: const [0, 0.62],
),
),
);
if (MediaQuery.maybeDisableAnimationsOf(context) ?? false) {
return halo;
}
return halo
.animate()
.scaleXY(
begin: 1,
end: 1.08,
duration: 1200.ms,
curve: Curves.easeInOut,
)
.then()
.scaleXY(
begin: 1.08,
end: 1,
duration: 1200.ms,
curve: Curves.easeInOut,
);
}
}
/// Full-bleed blurred backdrop (WU11, replaces the `PluriGlassSurface` card
/// container per task 11.3). This app has no per-station artwork/favicon
/// safe to render here: `Emisora.favicon` is a network URL, and rendering
/// one via `Image.network` inside a widget test hangs/throws without a
/// mocked `HttpClient` — a hazard no other screen in this codebase accepts
/// either. The existing bundled alarm asset is reused instead, heavily
/// blurred and stretched; purely decorative, not spec-tested.
class _FondoArteDifuminado extends StatelessWidget {
const _FondoArteDifuminado({required this.tokens});
final PluriWaveTokens tokens;
@override
Widget build(BuildContext context) {
return Positioned.fill(
key: const ValueKey('ringing-background-art'),
child: Stack(
fit: StackFit.expand,
children: [
ImageFiltered(
imageFilter: ImageFilter.blur(sigmaX: 44, sigmaY: 44),
child: Opacity(
opacity: 0.5,
child: Image.asset(
'assets/icons/alarmas/alarm_music.png',
fit: BoxFit.cover,
errorBuilder: (_, __, ___) => const SizedBox.shrink(),
),
),
),
DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
tokens.deepViolet.withValues(alpha: 0.55),
tokens.deepViolet.withValues(alpha: 0.9),
tokens.deepViolet,
],
stops: const [0, 0.55, 1],
),
),
),
],
),
);
}
}
/// Static "turning up the volume" status (native-alarms delta, WU11 —
/// Ringing Screen Shows a Static Status Label): a dot + label, no
/// `AnimationController`/`Animate` anywhere in this widget. A pulsing dot
/// would reintroduce the exact "`pumpAndSettle()` never terminates" hazard
/// WU5 documented for `VisualizadorAudio`'s own repeating controller — this
/// screen must stay safe for `pumpAndSettle()` in every other existing test.
class _EstadoSubidaVolumen extends StatelessWidget {
const _EstadoSubidaVolumen({required this.l10n, required this.tokens});
final AppLocalizations l10n;
final PluriWaveTokens tokens;
@override
Widget build(BuildContext context) {
return Row(
key: const ValueKey('estado-subida-volumen'),
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 6,
height: 6,
decoration: BoxDecoration(
color: tokens.liveGreen,
shape: BoxShape.circle,
),
),
const SizedBox(width: 6),
Text(
l10n.alarmVolumeRisingStatus,
style: context.pluriType.bodyStrong.copyWith(color: tokens.liveGreen),
),
],
);
}
}
/// The ringing screen's snooze row (native-alarms delta, WU11): exactly 3
/// FIXED tiles (3/5/10 min), replacing the previous variable-length `Wrap`
/// that grew a 4th tile for a custom `snoozeMinutos`. **Design decision, not
/// spec-tested** (WU11 has no ADR): the highlighted (filled) tile is
/// whichever of the 3 matches `alarma.snoozeMinutos`; the alarm's own
/// editor-configured value still decides WHICH tile is filled, tapping any
/// tile still snoozes for exactly that tile's duration (`_posponer` is
/// called with the tapped value, never the alarm's stored default). If the
/// alarm's own value isn't one of the three — only reachable via a fixture
/// or a pre-redesign save, since the editor's own snooze picker only ever
/// offers `{3, 5, 10, current}` — 10 is the default highlight, matching the
/// mockup's own "10 min · habitual" example.
class _FilaSnoozeFija extends StatelessWidget {
const _FilaSnoozeFija({
required this.alarma,
required this.l10n,
required this.tokens,
required this.onPosponer,
});
final AlarmaMusical alarma;
final AppLocalizations l10n;
final PluriWaveTokens tokens;
final ValueChanged<int> onPosponer;
static const _opciones = [3, 5, 10];
@override
Widget build(BuildContext context) {
final destacado =
_opciones.contains(alarma.snoozeMinutos) ? alarma.snoozeMinutos : 10;
return Row(
children: [
for (final minutos in _opciones) ...[
if (minutos != _opciones.first) const SizedBox(width: 10),
_tile(minutos, minutos == destacado),
],
],
);
}
Widget _tile(int minutos, bool esDestacado) {
final forma = RoundedRectangleBorder(
borderRadius: BorderRadius.circular(tokens.radiusMd),
);
// Audit 9.10 (t4 line 433): the prototype stacks a big NUMBER over a
// small "min · habitual" unit — the SAME text-splitting conflict as
// 9.9 (permanently rejected, Engram id 2525): this flat string is
// exactly what the protected dismiss-guard test locates via
// `find.text(l10n.alarmSnoozeOptionLabel(N))` in four places, and
// splitting it into two differently-styled Text nodes would make
// that flat value vanish from the render tree.
//
// Resolved differently here than 9.9: rather than splitting THIS
// string, an entirely SEPARATE small qualifier Text is added
// alongside it (only on the destacado tile) — the original flat
// label stays a single, untouched, unstyled-differently Text node,
// still the exact widget the guard finds and taps. This delivers
// the "habitual" qualifier without the conflict 9.9 hit.
final etiqueta = Text(l10n.alarmSnoozeOptionLabel(minutos));
return Expanded(
flex: esDestacado ? 3 : 2,
child: SizedBox(
height: 76,
child:
esDestacado
? FilledButton(
onPressed: () => onPosponer(minutos),
style: FilledButton.styleFrom(
backgroundColor: tokens.warmCoral,
foregroundColor: tokens.deepViolet,
shape: forma,
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
children: [
etiqueta,
Text(
l10n.alarmSnoozeUsualLabel,
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w800,
color: tokens.deepViolet.withValues(alpha: 0.75),
),
),
],
),
)
: OutlinedButton(
onPressed: () => onPosponer(minutos),
style: OutlinedButton.styleFrom(
foregroundColor: tokens.warmCoral,
backgroundColor: tokens.warmCoral.withValues(alpha: 0.16),
side: BorderSide(
color: tokens.warmCoral.withValues(alpha: 0.4),
),
shape: forma,
),
child: etiqueta,
),
),
);
}
}
File diff suppressed because it is too large Load Diff
+294
View File
@@ -0,0 +1,294 @@
import 'dart:ui';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../estado/estado_navegacion.dart';
import '../l10n/gen/app_localizations.dart';
import '../servicios/servicio_bienvenida.dart';
import '../tema/pluriwave_theme.dart';
/// WU17: first-run welcome surface (`onboarding-welcome` spec, mockup
/// screen 14) rebuilt WITHOUT its monetization content — no "PRO" pill, no
/// "14 días" trial line, no pricing card, and no secondary "free version"
/// link (binding no-monetization requirement). Content only: logo,
/// headline, body copy, exactly 3 feature bullets, and the single
/// "Empezar a escuchar" CTA.
///
/// A full-screen ROUTE, not a modal `Dialog` — deliberately distinct from
/// the pre-existing `PluriOnboardingDialog` (an unrelated "what's new"
/// help-content modal already shown from `app.dart`'s launch flow). Both
/// surfaces coexist: [mostrarSiProcede] (WU17b) is what actually wires this
/// screen into the genuine first-launch flow, called from `app.dart` BEFORE
/// `PluriOnboardingDialog.mostrarSiProcede` on every cold start, so the two
/// never race — the once-ever welcome resolves first, then the recurring
/// what's-new dialog runs its own unrelated per-version check exactly as
/// before.
class PantallaBienvenida extends StatelessWidget {
const PantallaBienvenida({super.key});
static final ServicioBienvenida _servicio = ServicioBienvenida();
/// WU17b: shows this screen once, on the genuine first launch, then never
/// again. Mirrors `PluriOnboardingDialog.mostrarSiProcede`'s shape
/// (check-then-show-then-mark-seen) so both first-launch surfaces share
/// the same call convention from `app.dart`.
static Future<void> mostrarSiProcede(BuildContext context) async {
if (!await _servicio.debeMostrarBienvenida()) return;
if (!context.mounted) return;
await Navigator.of(
context,
).push(MaterialPageRoute<void>(builder: (_) => const PantallaBienvenida()));
await _servicio.marcarBienvenidaVista();
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final t = context.pluriTokens;
final theme = Theme.of(context);
return Scaffold(
body: Stack(
fit: StackFit.expand,
children: [
const _FondoBienvenida(),
SafeArea(
child: Padding(
padding: const EdgeInsets.fromLTRB(24, 0, 24, 28),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Audit 14.2 (t4 lines 693/706): the prototype anchors
// this content to the BOTTOM with two flexible spacers,
// so the CTA sits at the screen edge. The scrollable
// Expanded region above the CTA keeps this safe on short
// screens / large text scale instead of a rigid Spacer
// that could overflow.
Expanded(
child: SingleChildScrollView(
padding: const EdgeInsets.only(top: 48),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Audit 14.8 (t4 line 693): the prototype clips
// its logo mark to a 20px rounded rect; the build
// used to paint it square.
ClipRRect(
borderRadius: BorderRadius.circular(20),
child: Image.asset(
'assets/icons/pluriwave_app_mark.png',
key: const ValueKey('welcome-logo'),
width: 76,
height: 76,
errorBuilder:
(_, __, ___) => Icon(
Icons.graphic_eq_rounded,
size: 76,
color: t.electricMagenta,
),
),
),
const SizedBox(height: 22),
Text(
l10n.welcomeHeadline,
// Audit 14.3 (t4 line 696): 34px/ls-1.2, not
// headlineMedium's 28/ls-1.0 — a local override,
// matching the precedent already set by every
// other one-off size correction in this screen
// (14.4-14.6 below).
style: theme.textTheme.headlineMedium?.copyWith(
fontSize: 34,
fontWeight: FontWeight.w800,
height: 1.05,
letterSpacing: -1.2,
),
),
const SizedBox(height: 12),
Text(
l10n.welcomeBody,
// Audit 14.4 (t4 line 697): 14.5px, not
// bodyMedium's 14.
style: theme.textTheme.bodyMedium?.copyWith(
fontSize: 14.5,
color: theme.colorScheme.onSurface.withValues(
alpha: 0.72,
),
height: 1.5,
),
),
const SizedBox(height: 28),
FilaCaracteristicaBienvenida(
icon: Icons.equalizer_rounded,
iconColor: t.electricMagenta,
titulo: l10n.welcomeBullet1Title,
subtitulo: l10n.welcomeBullet1Subtitle,
),
// Audit 14.6 (t4 line 701): 12px between bullets,
// not 16.
const SizedBox(height: 12),
FilaCaracteristicaBienvenida(
icon: Icons.directions_car_rounded,
iconColor: t.liveGreen,
titulo: l10n.welcomeBullet2Title,
subtitulo: l10n.welcomeBullet2Subtitle,
),
const SizedBox(height: 12),
FilaCaracteristicaBienvenida(
icon: Icons.alarm_rounded,
iconColor: t.warmCoral,
titulo: l10n.welcomeBullet3Title,
subtitulo: l10n.welcomeBullet3Subtitle,
),
],
),
),
),
const SizedBox(height: 24),
SizedBox(
height: 58,
child: FilledButton(
onPressed: () => _empezar(context),
// Audit 14.7 (t4 line 715): radius 18 — Material 3's
// default FilledButton shape is a fully-round
// StadiumBorder, which the prototype does not draw.
style: FilledButton.styleFrom(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(18),
),
),
child: Text(
l10n.welcomeCtaLabel,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w800,
),
),
),
),
],
),
),
),
],
),
);
}
void _empezar(BuildContext context) {
context.read<EstadoNavegacionRaiz>().irA(RaizPluriWave.escuchar);
Navigator.of(context).pop();
}
}
/// One icon-badge + title/subtitle row. Public (not `_FilaCaracteristica`)
/// so the structural "exactly 3 feature bullets" guard in
/// `pantalla_bienvenida_test.dart` can target it via `find.byType` — the
/// same reason WU4 made `FormularioEmisoraPersonalizada` public.
class FilaCaracteristicaBienvenida extends StatelessWidget {
const FilaCaracteristicaBienvenida({
super.key,
required this.icon,
required this.iconColor,
required this.titulo,
required this.subtitulo,
});
final IconData icon;
final Color iconColor;
final String titulo;
final String subtitulo;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
width: 38,
height: 38,
decoration: BoxDecoration(
color: iconColor.withValues(alpha: 0.16),
borderRadius: BorderRadius.circular(11),
),
child: Icon(icon, size: 20, color: iconColor),
),
const SizedBox(width: 13),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
titulo,
// Audit 14.5 (t4 line 700): 13.5px/w800, not bodyMedium's
// 14/w800.
style: theme.textTheme.bodyMedium?.copyWith(
fontSize: 13.5,
fontWeight: FontWeight.w800,
),
),
Text(
subtitulo,
// Audit 14.5 (t4 line 700): 11.5px, not bodySmall's 12.
style: theme.textTheme.bodySmall?.copyWith(
fontSize: 11.5,
color: theme.colorScheme.onSurface.withValues(alpha: 0.58),
),
),
],
),
),
],
);
}
}
/// Full-bleed blurred backdrop (audit 14.1, t4 lines 687-689). Reuses the
/// bundled `aurora_wave_banner.png` — the same asset the now-deleted
/// `PluriScreenHeader` used (Tier 1, S2) — rather than the prototype's own
/// mockup-only `assets/banner.jpg`, which this app does not bundle. Mirrors
/// `_FondoArteDifuminado` in `pantalla_alarma_sonando.dart`: blur + a
/// deepViolet gradient, no noise layer, purely decorative and not
/// spec-tested beyond its own presence.
class _FondoBienvenida extends StatelessWidget {
const _FondoBienvenida();
@override
Widget build(BuildContext context) {
final tokens = context.pluriTokens;
return Positioned.fill(
key: const ValueKey('welcome-background-art'),
child: Stack(
fit: StackFit.expand,
children: [
ImageFiltered(
imageFilter: ImageFilter.blur(sigmaX: 20, sigmaY: 20),
child: Opacity(
opacity: 0.4,
child: Image.asset(
'assets/images/aurora_wave_banner.png',
fit: BoxFit.cover,
errorBuilder: (_, __, ___) => const SizedBox.shrink(),
),
),
),
DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
tokens.deepViolet.withValues(alpha: 0.45),
tokens.deepViolet.withValues(alpha: 0.9),
tokens.deepViolet,
],
stops: const [0, 0.46, 1],
),
),
),
],
),
);
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,293 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../estado/estado_alarmas.dart';
import '../l10n/gen/app_localizations.dart';
import '../servicios/diagnostico_alarmas.dart';
import '../servicios/servicio_alarmas_android.dart';
import '../tema/pluriwave_theme.dart';
import '../widgets/pluri_glass_surface.dart';
import '../widgets/pluri_layout.dart';
import '../widgets/pluri_push_scaffold.dart';
/// Full Android alarm-reliability diagnostics screen (fix/alarmas-fiabilidad).
///
/// Replaces the old one-line `_AccesoDiagnostico` button in
/// `pantalla_alarmas.dart`, which only ever surfaced 3 of the 6 fields
/// `DiagnosticoAlarmasAndroid` collects. This screen shows all five
/// diagnosable signals with a clear ok/needs-attention state, a "Fix this"
/// action that opens the right system settings screen for each failing one,
/// plus manufacturer-specific guidance for vendors known to require manually
/// enabling Autostart.
class PantallaDiagnosticoAlarmas extends StatelessWidget {
const PantallaDiagnosticoAlarmas({super.key});
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final estado = context.watch<EstadoAlarmas>();
final diag = estado.diagnostico;
return PluriPushScaffold(
title: l10n.androidReliabilityTitle,
body:
diag == null
? ListView(
padding: PluriLayout.pageContentPadding,
children: [
PluriGlassSurface(
child: Text(l10n.alarmDiagnosticsUnavailableHint),
),
],
)
: _CuerpoDiagnostico(estado: estado, diag: diag),
);
}
}
class _CuerpoDiagnostico extends StatelessWidget {
const _CuerpoDiagnostico({required this.estado, required this.diag});
final EstadoAlarmas estado;
final DiagnosticoAlarmasAndroid diag;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final items = construirItemsDiagnosticoAlarmas(
diagnostico: diag,
hayAlarmasActivas: estado.alarmas.any((alarma) => alarma.activa),
);
final mostrarAutostart = fabricanteRequiereGuiaAutostart(diag.fabricante);
return ListView(
padding: PluriLayout.pageContentPadding,
children: [
for (final item in items) ...[
_FilaDiagnostico(item: item, estado: estado, diag: diag),
const SizedBox(height: 10),
],
const SizedBox(height: 6),
PluriGlassSurface(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_FilaInformativa(
titulo: l10n.alarmDiagnosticsManufacturerLabel,
valor: diag.fabricante,
),
const SizedBox(height: 10),
_FilaInformativa(
titulo: l10n.alarmDiagnosticsSdkLabel,
valor: diag.versionSdk.toString(),
),
],
),
),
if (mostrarAutostart) ...[
const SizedBox(height: 16),
_GuiaAutostart(fabricante: diag.fabricante),
],
],
);
}
}
class _FilaDiagnostico extends StatelessWidget {
const _FilaDiagnostico({
required this.item,
required this.estado,
required this.diag,
});
final ItemDiagnosticoAlarma item;
final EstadoAlarmas estado;
final DiagnosticoAlarmasAndroid diag;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final tokens = context.pluriTokens;
final ok = item.estado == EstadoSenalDiagnostico.ok;
final color = ok ? tokens.liveGreen : Theme.of(context).colorScheme.error;
final esConteoNativo =
item.senal == SenalDiagnosticoAlarma.alarmasNativasPendientes;
return PluriGlassSurface(
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(
ok ? Icons.check_circle_rounded : Icons.warning_amber_rounded,
color: color,
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Text(
_tituloSenal(l10n, item.senal),
style: const TextStyle(fontWeight: FontWeight.w700),
),
),
Text(
ok
? l10n.statusOk
: l10n.alarmDiagnosticsNeedsAttentionStatus,
style: TextStyle(
color: color,
fontWeight: FontWeight.w700,
),
),
],
),
const SizedBox(height: 4),
if (esConteoNativo) ...[
Text(
l10n.alarmDiagnosticsNativeCountValue(
diag.alarmasNativasPendientes,
),
),
if (!ok) ...[
const SizedBox(height: 4),
Text(l10n.alarmDiagnosticsNativeCountAttentionHint),
],
] else
Text(_hintSenal(l10n, item.senal)),
if (!ok && item.accion != AccionDiagnosticoAlarma.ninguna) ...[
const SizedBox(height: 8),
Align(
alignment: Alignment.centerRight,
child: OutlinedButton(
onPressed: () => _ejecutarAccion(context, l10n),
child: Text(l10n.alarmDiagnosticsFixAction),
),
),
],
],
),
),
],
),
);
}
/// Runs the system action for [item.accion] and reloads the diagnostic
/// snapshot. Never throws across the widget boundary: every underlying
/// `PuertoAlarmasAndroid` call already reports `false` instead (native side
/// catches any intent-resolution failure), and a `false` here surfaces a
/// calm SnackBar instead of leaving the tap looking like a no-op.
Future<void> _ejecutarAccion(
BuildContext context,
AppLocalizations l10n,
) async {
final resuelto = switch (item.accion) {
AccionDiagnosticoAlarma.abrirAlarmasExactas =>
await estado.android.solicitarPermisoAlarmasExactas(),
AccionDiagnosticoAlarma.abrirNotificaciones =>
await estado.android.abrirConfiguracionNotificaciones(),
AccionDiagnosticoAlarma.abrirOptimizacionBateria =>
await estado.android.solicitarExencionBateria(),
AccionDiagnosticoAlarma.abrirPantallaCompleta =>
await estado.android.solicitarPermisoPantallaCompleta(),
AccionDiagnosticoAlarma.ninguna => true,
};
await estado.cargarDiagnostico();
if (!resuelto && context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(l10n.alarmDiagnosticsIntentUnavailable)),
);
}
}
}
String _tituloSenal(AppLocalizations l10n, SenalDiagnosticoAlarma senal) =>
switch (senal) {
SenalDiagnosticoAlarma.alarmasExactas =>
l10n.alarmDiagnosticsExactAlarmsTitle,
SenalDiagnosticoAlarma.notificaciones =>
l10n.alarmDiagnosticsNotificationsTitle,
SenalDiagnosticoAlarma.pantallaCompleta =>
l10n.alarmDiagnosticsFullScreenTitle,
SenalDiagnosticoAlarma.optimizacionBateria =>
l10n.alarmDiagnosticsBatteryTitle,
SenalDiagnosticoAlarma.alarmasNativasPendientes =>
l10n.alarmDiagnosticsNativeCountTitle,
};
/// Static one-line explanation per signal. `alarmasNativasPendientes` builds
/// its own dynamic body in [_FilaDiagnostico] instead (count + conditional
/// attention hint), so this branch is never actually rendered for it -- kept
/// only so the switch stays exhaustive over the enum.
String _hintSenal(
AppLocalizations l10n,
SenalDiagnosticoAlarma senal,
) => switch (senal) {
SenalDiagnosticoAlarma.alarmasExactas => l10n.alarmDiagnosticsExactAlarmsHint,
SenalDiagnosticoAlarma.notificaciones =>
l10n.alarmDiagnosticsNotificationsHint,
SenalDiagnosticoAlarma.pantallaCompleta =>
l10n.alarmDiagnosticsFullScreenHint,
SenalDiagnosticoAlarma.optimizacionBateria =>
l10n.alarmDiagnosticsBatteryHint,
SenalDiagnosticoAlarma.alarmasNativasPendientes => '',
};
class _FilaInformativa extends StatelessWidget {
const _FilaInformativa({required this.titulo, required this.valor});
final String titulo;
final String valor;
@override
Widget build(BuildContext context) {
return Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(titulo),
Text(valor, style: const TextStyle(fontWeight: FontWeight.w700)),
],
);
}
}
/// Manufacturer-specific autostart explanation (fix/alarmas-fiabilidad item
/// 3). Deliberately never claims the app can detect or grant this setting --
/// there is no public API for it, so this is explanation only, never an
/// action button.
class _GuiaAutostart extends StatelessWidget {
const _GuiaAutostart({required this.fabricante});
final String fabricante;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final tokens = context.pluriTokens;
return PluriGlassSurface(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(Icons.info_outline_rounded, color: tokens.warmCoral),
const SizedBox(width: 8),
Expanded(
child: Text(
l10n.alarmDiagnosticsAutostartTitle,
style: const TextStyle(fontWeight: FontWeight.w700),
),
),
],
),
const SizedBox(height: 8),
Text(l10n.alarmDiagnosticsAutostartBody(fabricante)),
],
),
);
}
}
+508 -149
View File
@@ -2,39 +2,136 @@ import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../estado/estado_radio.dart';
import '../l10n/display_names.dart';
import '../l10n/gen/app_localizations.dart';
import '../modelos/emisora.dart';
import '../modelos/grupo_favoritos.dart';
import '../widgets/pluri_glass_surface.dart';
import '../servicios/servicio_anuncios.dart';
import '../tema/pluriwave_tokens.dart';
import '../widgets/fila_emisora_plana.dart';
import '../widgets/pluri_icon.dart';
import '../widgets/pluri_layout.dart';
import '../widgets/pluri_premium_widgets.dart';
import 'package:pluriwave/widgets/tarjeta_emisora.dart';
import '../widgets/pluri_push_scaffold.dart';
import '../widgets/pluri_root_header.dart';
import '../widgets/pluri_sleep_timer_sheet.dart';
import 'ajustes/pantalla_ajustes_emisoras_personalizadas.dart';
import 'ajustes/pantalla_ajustes_grupos_favoritos.dart';
import 'reproducir_minimizado.dart';
class PantallaFavoritos extends StatelessWidget {
/// WU4, `favorites-organization` spec: a chip-filtered flat list replacing
/// the previous stacked per-group panels, with drag-to-reorder, a `swap_vert`
/// sort action, group management, and the custom-station CTA all reachable
/// from this root screen. Favoritos is the one root that keeps its bottom
/// tab bar (design ADR-2's documented exemption) — this file constructs no
/// `PluriPushScaffold` and stays a plain body widget for that reason.
class PantallaFavoritos extends StatefulWidget {
const PantallaFavoritos({super.key});
@override
State<PantallaFavoritos> createState() => _PantallaFavoritosState();
}
class _PantallaFavoritosState extends State<PantallaFavoritos> {
/// Ephemeral UI state only (design's "State is for ephemeral UI only"
/// ruling) — null means the "All" chip is active.
String? _grupoSeleccionadoId;
Future<void> _abrirFormularioEmisoraPersonalizada() async {
// ad-display spec "Interstitial Before Manual Station Add" (design.md
// ADR-6): fires on the CTA tap, before the form even opens — a no-op
// for premium (ServicioAnuncios' own entitlement gate).
await context.read<ServicioAnuncios>().intentarInterstitial();
if (!mounted) return;
await showModalBottomSheet(
context: context,
isScrollControlled: true,
useSafeArea: true,
showDragHandle: true,
builder: (ctx) => const FormularioEmisoraPersonalizada(),
);
}
void _abrirGestionDeListas() {
PluriPushScaffold.push(
context,
(_) => const PantallaAjustesGruposFavoritos(),
);
}
Future<void> _elegirOrden(OrdenEmisoras criterio) =>
context.read<EstadoRadio>().ordenarFavoritos(criterio);
/// Translates a drag within the currently FILTERED view into the absolute
/// global position [EstadoRadio.reordenarFavorito] expects, so a drag
/// while a group chip is active still produces a coherent global order
/// (other groups' relative order is left untouched).
///
/// [newIndex] arrives in `ReorderableListView.onReorder`'s pre-removal
/// coordinate space: dragging downwards reports the slot the row would
/// occupy while it is still in the list. The logic below indexes into the
/// list AFTER the row is removed, so shift by one in that direction first.
void _onReorder(
List<Emisora> filtrados,
List<Emisora> favoritos,
int oldIndex,
int newIndex,
) {
if (newIndex > oldIndex) newIndex -= 1;
final movido = filtrados[oldIndex];
final restantes = List<Emisora>.from(filtrados)..removeAt(oldIndex);
// `ServicioFavoritos.reordenar` removes the station first and THEN
// inserts at the index it is given, so the target index must be
// expressed in the global list WITHOUT the moved station. Locating the
// neighbour in the untrimmed list instead drifts by one whenever the
// moved station sits before it.
final globalSinMovido =
favoritos.where((e) => e.uuid != movido.uuid).toList();
final int nuevoIndiceGlobal;
if (restantes.isEmpty) {
nuevoIndiceGlobal = globalSinMovido.length;
} else if (newIndex >= restantes.length) {
nuevoIndiceGlobal =
globalSinMovido.indexWhere((e) => e.uuid == restantes.last.uuid) + 1;
} else {
nuevoIndiceGlobal = globalSinMovido.indexWhere(
(e) => e.uuid == restantes[newIndex].uuid,
);
}
context.read<EstadoRadio>().reordenarFavorito(
movido.uuid,
nuevoIndiceGlobal,
);
}
@override
Widget build(BuildContext context) {
final estado = context.watch<EstadoRadio>();
final favoritos = estado.listaFavoritos;
final grupos = estado.gruposFavoritos;
// S4-R5: no root watch — select only the fields this screen reads. The
// getters are identity-memoized, so playback notifications that do not
// change favorites/groups no longer rebuild the screen.
final favoritos = context.select<EstadoRadio, List<Emisora>>(
(e) => e.listaFavoritosManual,
);
final grupos = context.select<EstadoRadio, List<GrupoFavoritos>>(
(e) => e.gruposFavoritos,
);
final l10n = AppLocalizations.of(context);
if (favoritos.isEmpty) {
return ListView(
padding: PluriLayout.pageListPadding,
children: [
PluriScreenHeader(
// S1 (Tier 1 visual fidelity): the prototype has no global
// AppBar — this root now draws its own 56px title row instead of
// relying on app.dart's removed shared chrome (which is also
// where the sleep-timer action used to live).
// S2 (Tier 1 visual fidelity): PluriScreenHeader (the glass hero
// this used to be) is retired — it is not in the prototype at
// all, and carried no functional action on this screen.
PluriRootHeader(
title: l10n.favoritesTitle,
subtitle: l10n.favoritesHeaderSubtitle,
glyph: PluriIconGlyph.favorites,
trailing: PluriStatusPill(
icon: Icons.favorite_rounded,
label: l10n.favoritesCollection,
),
onSleepTimer: () => showPluriSleepTimerSheet(context),
),
SizedBox(
height: 320,
@@ -44,130 +141,259 @@ class PantallaFavoritos extends StatelessWidget {
subtitle: l10n.favoritesEmptySubtitle,
),
),
Padding(
padding: PluriLayout.pageContentPadding,
child: _CtaEmisoraPersonalizada(
onTap: _abrirFormularioEmisoraPersonalizada,
),
),
],
);
}
final gruposVisibles = grupos.isEmpty
? const [
GrupoFavoritos(
id: GrupoFavoritos.sinAsignarId,
nombre: 'Sin asignar',
orden: 0,
protegido: true,
),
]
: grupos;
final gruposVisibles =
grupos.isEmpty
? [
GrupoFavoritos(
id: GrupoFavoritos.sinAsignarId,
nombre: l10n.favoriteGroupsUnassigned,
orden: 0,
protegido: true,
),
]
: grupos;
return CustomScrollView(
slivers: [
SliverToBoxAdapter(
child: PluriScreenHeader(
title: l10n.favoritesTitle,
subtitle: l10n.favoritesHeaderSubtitle,
glyph: PluriIconGlyph.favorites,
trailing: PluriStatusPill(
icon: Icons.library_music_rounded,
label: l10n.favoritesSavedCount(favoritos.length),
// Defensive: a group selected before it was deleted elsewhere (e.g. via
// the pushed management screen) falls back to "All" instead of showing
// an empty list with no chip highlighted.
final seleccionEfectiva =
gruposVisibles.any((g) => g.id == _grupoSeleccionadoId)
? _grupoSeleccionadoId
: null;
final filtrados =
seleccionEfectiva == null
? favoritos
: favoritos
.where((e) => e.grupoFavoritosId == seleccionEfectiva)
.toList();
return ReorderableListView(
buildDefaultDragHandles: false,
// Issue 3 (feedback-pruebas): zero horizontal here, matching every
// other root's PluriLayout.pageListPadding convention (Alarmas,
// Ajustes, and this screen's OWN empty-state branch above).
// ReorderableListView.padding wraps header/children/footer UNIFORMLY,
// so a single horizontal value here can never be simultaneously right
// for PluriRootHeader (self-padded, wants none), the reorderable rows
// (want row tier, applied per item below) and the footer CTA (wants
// card tier, applied on the footer's own Padding below). The previous
// `PluriLayout.horizontal` doubled up on top of PluriRootHeader's own
// internal inset, pushing "Favorites" in by 36px instead of the 20px
// every other root uses for its title.
padding: const EdgeInsets.only(bottom: PluriLayout.bottomChromeInset),
header: Padding(
padding: const EdgeInsets.only(bottom: 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// S1/S2 (Tier 1 visual fidelity): see the empty-state branch
// above — PluriScreenHeader is retired everywhere.
//
// Audit 4.1 (t4:216): the prototype's two header icon actions
// (create_new_folder, swap_vert) now live in PluriRootHeader's
// own actions slot -- they used to be scattered as an
// ActionChip inside the chip strip and a PopupMenuButton
// sharing a Row with it. The back arrow the prototype also
// draws stays absent (binding decision: this root keeps its
// bottom tab bar, unlike the prototype's own pushed shape).
PluriRootHeader(
title: l10n.favoritesTitle,
onSleepTimer: () => showPluriSleepTimerSheet(context),
actions: [
IconButton(
key: const ValueKey('favorites-manage-groups-action'),
icon: const Icon(Icons.create_new_folder_rounded),
tooltip: l10n.favoriteGroupsManage,
onPressed: _abrirGestionDeListas,
),
PopupMenuButton<OrdenEmisoras>(
icon: const Icon(Icons.swap_vert_rounded),
tooltip: l10n.stationOrderTitle,
onSelected: _elegirOrden,
itemBuilder:
(context) => [
PopupMenuItem(
value: OrdenEmisoras.nombre,
child: Text(l10n.stationOrderByName),
),
PopupMenuItem(
value: OrdenEmisoras.calidad,
child: Text(l10n.stationOrderByQuality),
),
],
),
],
),
const SizedBox(height: 12),
// Issue 3 (feedback-pruebas): t4:218 draws this chip strip at
// title-tier (20px) horizontal inset, directly on the page
// background -- it now needs its OWN inset since the list's
// padding no longer supplies one.
Padding(
padding: const EdgeInsets.symmetric(
horizontal: PluriLayout.titleHorizontal,
),
child: _FilaChipsGrupos(
grupos: gruposVisibles,
favoritos: favoritos,
seleccionado: seleccionEfectiva,
onSeleccionar:
(id) => setState(() => _grupoSeleccionadoId = id),
),
),
],
),
),
footer: Padding(
// Issue 3 (feedback-pruebas): card tier (16, matching every other
// screen's dashed CTA) now that the list's own padding no longer
// supplies it, plus t4:234's 8px gap above the CTA
// (PluriLayout.compactGap) instead of the previous unwired literal
// 4 -- the ONLY state of this screen with a nonzero top gap before
// its own content used a value that matched neither this screen's
// own empty-state branch nor the prototype.
padding: const EdgeInsets.fromLTRB(
PluriLayout.horizontal,
PluriLayout.compactGap,
PluriLayout.horizontal,
0,
),
child: _CtaEmisoraPersonalizada(
onTap: _abrirFormularioEmisoraPersonalizada,
),
),
onReorder:
(oldIndex, newIndex) =>
_onReorder(filtrados, favoritos, oldIndex, newIndex),
children: [
for (var i = 0; i < filtrados.length; i++)
// Issue 3 (feedback-pruebas): row tier (12), not card tier -- the
// key moves to this wrapper (ReorderableListView identifies each
// child by its own top-level key) since FilaEmisoraPlana rows are
// documented (audit 4.3) as flat, background-less rows, the same
// tier Buscar's results list already uses for the same widget.
Padding(
key: ValueKey(filtrados[i].uuid),
padding: const EdgeInsets.symmetric(
horizontal: PluriLayout.rowHorizontal,
),
child: _FilaFavorito(
index: i,
emisora: filtrados[i],
grupos: gruposVisibles,
grupoActual: gruposVisibles.firstWhere(
(g) => g.id == filtrados[i].grupoFavoritosId,
orElse: () => gruposVisibles.first,
),
),
),
),
SliverPadding(
padding: const EdgeInsets.fromLTRB(
PluriLayout.horizontal,
4,
PluriLayout.horizontal,
PluriLayout.bottomChromeInset,
),
sliver: SliverList(
delegate: SliverChildListDelegate([
for (final grupo in gruposVisibles) ...[
_GrupoFavoritosPanel(
grupo: grupo,
grupos: gruposVisibles,
emisoras: favoritos
.where((e) => e.grupoFavoritosId == grupo.id)
.toList(),
),
const SizedBox(height: 12),
],
]),
),
),
],
);
}
}
class _GrupoFavoritosPanel extends StatelessWidget {
const _GrupoFavoritosPanel({
required this.grupo,
class _FilaChipsGrupos extends StatelessWidget {
const _FilaChipsGrupos({
required this.grupos,
required this.emisoras,
required this.favoritos,
required this.seleccionado,
required this.onSeleccionar,
});
final GrupoFavoritos grupo;
final List<GrupoFavoritos> grupos;
final List<Emisora> emisoras;
final List<Emisora> favoritos;
final String? seleccionado;
final ValueChanged<String?> onSeleccionar;
String _nombreVisible(AppLocalizations l10n, GrupoFavoritos grupo) =>
grupo.esSinAsignar ? l10n.favoriteGroupsUnassigned : grupo.nombre;
/// Audit 4.2 (t4:219-221): active `#21D4D9`/`#062126` w800, inactive
/// `listSurface` + a faint border / w700 -- was Material's own
/// `ChoiceChip` theming (a plain checkbox-style selected fill).
Widget _chip({
required String label,
required bool selected,
required VoidCallback onTap,
}) {
return ChoiceChip(
label: Text(label),
labelStyle: TextStyle(
fontWeight: selected ? FontWeight.w800 : FontWeight.w700,
color: selected ? const Color(0xFF062126) : const Color(0xFFF2F7FA),
),
selected: selected,
showCheckmark: false,
selectedColor: PluriWaveTokens.brand,
backgroundColor: PluriWaveTokens.dark.listSurface,
side: BorderSide(
color:
selected
? Colors.transparent
: Colors.white.withValues(alpha: 0.09),
),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
onSelected: (_) => onTap(),
);
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final theme = Theme.of(context);
return PluriGlassSurface(
padding: const EdgeInsets.all(10),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
return SizedBox(
height: 40,
child: ListView(
scrollDirection: Axis.horizontal,
children: [
Row(
children: [
Icon(grupo.esSinAsignar ? Icons.lock_rounded : Icons.folder_rounded),
const SizedBox(width: 8),
Expanded(
child: Text(
_nombreVisible(l10n, grupo),
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w900,
),
),
Padding(
padding: const EdgeInsets.only(right: 8),
child: _chip(
label: l10n.favoriteGroupsChipLabel(
l10n.favoritesFilterAllLabel,
favoritos.length,
),
Text('${emisoras.length}'),
],
selected: seleccionado == null,
onTap: () => onSeleccionar(null),
),
),
const SizedBox(height: 8),
if (emisoras.isEmpty)
for (final grupo in grupos)
Padding(
padding: const EdgeInsets.only(bottom: 4),
child: Text(
l10n.favoritesEmptyTitle,
style: theme.textTheme.bodySmall,
padding: const EdgeInsets.only(right: 8),
child: _chip(
label: l10n.favoriteGroupsChipLabel(
_nombreVisible(l10n, grupo),
favoritos.where((e) => e.grupoFavoritosId == grupo.id).length,
),
selected: seleccionado == grupo.id,
onTap: () => onSeleccionar(grupo.id),
),
)
else
for (var i = 0; i < emisoras.length; i++) ...[
_FavoritoItem(
emisora: emisoras[i],
grupos: grupos,
grupoActual: grupo,
),
if (i < emisoras.length - 1) const SizedBox(height: 8),
],
),
],
),
);
}
}
class _FavoritoItem extends StatelessWidget {
const _FavoritoItem({
class _FilaFavorito extends StatelessWidget {
const _FilaFavorito({
required this.index,
required this.emisora,
required this.grupos,
required this.grupoActual,
});
final int index;
final Emisora emisora;
final List<GrupoFavoritos> grupos;
final GrupoFavoritos grupoActual;
@@ -180,30 +406,31 @@ class _FavoritoItem extends StatelessWidget {
final seleccionado = await showModalBottomSheet<String>(
context: context,
showDragHandle: true,
builder: (ctx) => SafeArea(
child: ListView(
shrinkWrap: true,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(20, 4, 20, 12),
child: Text(
l10n.favoriteGroupsAssign,
style: Theme.of(ctx).textTheme.titleLarge,
),
),
for (final grupo in grupos)
ListTile(
leading: Icon(
grupo.id == emisora.grupoFavoritosId
? Icons.radio_button_checked_rounded
: Icons.radio_button_off_rounded,
builder:
(ctx) => SafeArea(
child: ListView(
shrinkWrap: true,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(20, 4, 20, 12),
child: Text(
l10n.favoriteGroupsAssign,
style: Theme.of(ctx).textTheme.titleLarge,
),
),
title: Text(_nombreVisible(l10n, grupo)),
onTap: () => Navigator.pop(ctx, grupo.id),
),
],
),
),
for (final grupo in grupos)
ListTile(
leading: Icon(
grupo.id == emisora.grupoFavoritosId
? Icons.radio_button_checked_rounded
: Icons.radio_button_off_rounded,
),
title: Text(_nombreVisible(l10n, grupo)),
onTap: () => Navigator.pop(ctx, grupo.id),
),
],
),
),
);
if (seleccionado == null || !context.mounted) return;
await context.read<EstadoRadio>().asignarGrupoFavorito(
@@ -212,10 +439,14 @@ class _FavoritoItem extends StatelessWidget {
);
if (!context.mounted) return;
final destino = grupos.firstWhere((g) => g.id == seleccionado);
final stationName = localizedStationName(l10n, emisora.nombre);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
l10n.favoriteGroupsAssigned(emisora.nombre, _nombreVisible(l10n, destino)),
l10n.favoriteGroupsAssigned(
stationName,
_nombreVisible(l10n, destino),
),
),
),
);
@@ -224,46 +455,174 @@ class _FavoritoItem extends StatelessWidget {
Future<void> _eliminar(BuildContext context) async {
final l10n = AppLocalizations.of(context);
final estado = context.read<EstadoRadio>();
final stationName = localizedStationName(l10n, emisora.nombre);
await estado.favoritos.eliminar(emisora.uuid);
await estado.cargarFavoritos();
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(l10n.favoritesRemovedMessage(emisora.nombre))),
SnackBar(content: Text(l10n.favoritesRemovedMessage(stationName))),
);
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
return Row(
children: [
Expanded(
child: TarjetaEmisora(
key: Key(emisora.uuid),
emisora: emisora,
esCompacta: true,
onTap: () => reproducirMinimizado(context, emisora),
),
final meta = [
emisora.pais,
emisora.idioma,
].where((s) => s != null && s.isNotEmpty).join(' · ');
// Item 23 / audit 4.3 (t4:226-232): a flat, background-less row --
// drag handle, square art, name+meta, and a circular play affordance --
// replacing the full glass TarjetaEmisora card and its two stacked
// filledTonal buttons. "Move to list"/"Remove from favorites" keep their
// EXACT prior logic (`_asignar`/`_eliminar`, untouched), now reachable
// from an overflow menu instead of two always-visible buttons -- the
// prototype's row has no such menu, but dropping either capability
// entirely would be a functional regression, not a fidelity fix.
return FilaEmisoraPlana(
key: Key(emisora.uuid),
emisora: emisora,
meta: meta,
onTap: () => reproducirMinimizado(context, emisora),
leading: ReorderableDragStartListener(
index: index,
child: Icon(
// t4:227: drag_indicator, not drag_handle, at 22px/28%.
Icons.drag_indicator_rounded,
size: 22,
color: Theme.of(
context,
).colorScheme.onSurface.withValues(alpha: 0.28),
),
const SizedBox(width: 6),
Column(
mainAxisSize: MainAxisSize.min,
children: [
IconButton.filledTonal(
tooltip: l10n.favoriteGroupsAssignSubtitle(
_nombreVisible(l10n, grupoActual),
),
icon: const Icon(Icons.drive_file_move_rounded),
onPressed: () => _asignar(context),
),
IconButton.filledTonal(
tooltip: l10n.favoritesRemoveTooltip,
icon: const Icon(Icons.delete_outline_rounded),
onPressed: () => _eliminar(context),
),
],
),
trailing: [
BotonReproducirCircular(
onPressed: () => reproducirMinimizado(context, emisora),
),
PopupMenuButton<String>(
icon: Icon(
Icons.more_vert_rounded,
size: 20,
color: Theme.of(
context,
).colorScheme.onSurface.withValues(alpha: 0.45),
),
// NO `constraints:` here. That property sizes the POPUP MENU, not
// the button — a tightFor(38x42) clipped every menu item down to
// its first letter ("M" for "Mover a lista", "E" for "Eliminar de
// favoritos"), which is what users actually saw. Constrain the
// tap target instead.
padding: EdgeInsets.zero,
iconSize: 20,
onSelected: (accion) {
if (accion == 'assign') _asignar(context);
if (accion == 'remove') _eliminar(context);
},
itemBuilder:
(context) => [
PopupMenuItem(
value: 'assign',
child: Text(l10n.favoriteGroupsAssign),
),
PopupMenuItem(
value: 'remove',
child: Text(l10n.favoritesRemoveTooltip),
),
],
),
],
);
}
}
/// The dashed "Añadir emisora personalizada" CTA (favorites-organization
/// spec, "Custom-Station CTA Preserved") — opens the SAME add-station form
/// used from Settings' Emisoras personalizadas screen
/// ([FormularioEmisoraPersonalizada]), not a duplicate.
class _CtaEmisoraPersonalizada extends StatelessWidget {
const _CtaEmisoraPersonalizada({required this.onTap});
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
// Audit 4.5 (t4 line 235): the border and the label/icon are TWO
// different opacities in the prototype — `rgba(255,255,255,.16)` for
// the dashed stroke, `rgba(242,247,250,.6)` for the text/icon — not one
// shared 50% colour for both.
final colorBorde = Colors.white.withValues(alpha: 0.16);
final colorTexto = Theme.of(
context,
).colorScheme.onSurface.withValues(alpha: 0.6);
return CustomPaint(
painter: _DashedBorderPainter(color: colorBorde),
child: Material(
color: Colors.transparent,
child: InkWell(
borderRadius: BorderRadius.circular(16),
onTap: onTap,
child: Padding(
key: const Key('custom-station-cta-padding'),
padding: const EdgeInsets.all(14),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.add_rounded, size: 20, color: colorTexto),
const SizedBox(width: 8),
Text(
l10n.customStationsAddCta,
style: TextStyle(
fontSize: 13.5,
fontWeight: FontWeight.w800,
color: colorTexto,
),
),
],
),
),
),
),
);
}
}
class _DashedBorderPainter extends CustomPainter {
const _DashedBorderPainter({required this.color});
final Color color;
static const _radius = 16.0;
static const _dashWidth = 6.0;
static const _gapWidth = 4.0;
@override
void paint(Canvas canvas, Size size) {
final rrect = RRect.fromRectAndRadius(
Offset.zero & size,
const Radius.circular(_radius),
);
final path = Path()..addRRect(rrect);
final paint =
Paint()
..color = color
..style = PaintingStyle.stroke
..strokeWidth = 1.5;
for (final metric in path.computeMetrics()) {
var distance = 0.0;
while (distance < metric.length) {
final next = distance + _dashWidth;
canvas.drawPath(
metric.extractPath(distance, next.clamp(0.0, metric.length)),
paint,
);
distance = next + _gapWidth;
}
}
}
@override
bool shouldRepaint(covariant _DashedBorderPainter oldDelegate) =>
oldDelegate.color != color;
}
+547
View File
@@ -0,0 +1,547 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:just_audio/just_audio.dart';
import 'package:provider/provider.dart';
import 'package:share_plus/share_plus.dart' show Share, XFile;
import '../estado/estado_grabacion.dart';
import '../l10n/gen/app_localizations.dart';
import '../modelos/archivo_grabacion.dart';
import '../tema/pluriwave_tokens.dart';
import '../widgets/pluri_glass_surface.dart';
import '../widgets/pluri_icon.dart';
import '../widgets/pluri_layout.dart';
import '../widgets/pluri_premium_widgets.dart';
import '../widgets/pluri_push_scaffold.dart';
import 'ajustes/pantalla_ajustes_grabaciones.dart';
/// Inline-preview playback + duration lookup for a single recording file at
/// a time (WU15, recordings-library spec — "Row playback starts and
/// stops"). Kept separate from `ServicioAudio` (never touched — that class
/// is coupled to live radio-stream transport/reconnect, unrelated to
/// previewing an already-finished local recording).
///
/// The real implementation ([_ReproductorGrabacionesJustAudio]) wraps
/// `just_audio.AudioPlayer`, which needs platform `MethodChannel`s this
/// suite does not mock — the same constraint `cola_local_test.dart`
/// documents for `PluriWaveAudioHandler` — so it is static-review-only.
/// Every test in `pantalla_grabaciones_test.dart` injects a fake instead.
abstract class ReproductorGrabaciones {
/// Path currently loaded/playing, or null.
String? get rutaActual;
/// True while [rutaActual] is actively playing (not just loaded/paused).
bool get reproduciendo;
/// Loads [ruta]'s metadata and returns its duration, without playing it.
Future<Duration?> duracionDe(String ruta);
/// Starts playback of [ruta]. If [ruta] is already the one playing, this
/// pauses it instead — the row's play/pause affordance is a toggle.
Future<void> alternar(String ruta);
Future<void> detener();
Future<void> dispose();
}
class _ReproductorGrabacionesJustAudio implements ReproductorGrabaciones {
final AudioPlayer _player = AudioPlayer();
String? _rutaActual;
@override
String? get rutaActual => _rutaActual;
@override
bool get reproduciendo => _player.playing;
@override
Future<Duration?> duracionDe(String ruta) async {
final sonda = AudioPlayer();
try {
return await sonda.setFilePath(ruta);
} finally {
await sonda.dispose();
}
}
@override
Future<void> alternar(String ruta) async {
if (_rutaActual == ruta && _player.playing) {
await _player.pause();
return;
}
if (_rutaActual != ruta) {
await _player.setFilePath(ruta);
_rutaActual = ruta;
}
await _player.play();
}
@override
Future<void> detener() async {
await _player.stop();
_rutaActual = null;
}
@override
Future<void> dispose() => _player.dispose();
}
/// WU15: the recordings library — storage usage, browsable rows with
/// inline playback, and a "⋮" menu constrained to exactly
/// Rename/Share/Delete (`recordings-library` spec). Distinct from
/// `PantallaAjustesGrabaciones` (WU3b), which is the folder/size-limit
/// SETTINGS screen, not this browsable file list.
class PantallaGrabaciones extends StatefulWidget {
const PantallaGrabaciones({
super.key,
ReproductorGrabaciones? reproductor,
Future<void> Function(String ruta)? compartir,
}) : _reproductorInyectado = reproductor,
_compartirInyectado = compartir;
final ReproductorGrabaciones? _reproductorInyectado;
final Future<void> Function(String ruta)? _compartirInyectado;
@override
State<PantallaGrabaciones> createState() => _PantallaGrabacionesState();
}
class _PantallaGrabacionesState extends State<PantallaGrabaciones> {
late final ReproductorGrabaciones _reproductor =
widget._reproductorInyectado ?? _ReproductorGrabacionesJustAudio();
late final Future<void> Function(String ruta) _compartir =
widget._compartirInyectado ?? (ruta) => Share.shareXFiles([XFile(ruta)]);
late Future<List<ArchivoGrabacion>> _grabaciones;
final Map<String, Future<Duration?>> _duracionCache = {};
@override
void initState() {
super.initState();
_recargar();
}
void _recargar() {
_duracionCache.clear();
_grabaciones = context.read<EstadoGrabacion>().listarGrabaciones();
}
@override
void dispose() {
unawaited(_reproductor.dispose());
super.dispose();
}
Future<Duration?> _duracionPara(String ruta) =>
_duracionCache.putIfAbsent(ruta, () => _reproductor.duracionDe(ruta));
Future<void> _alternarReproduccion(String ruta) async {
await _reproductor.alternar(ruta);
if (!mounted) return;
setState(() {});
}
Future<void> _manejarAccion(String accion, ArchivoGrabacion archivo) async {
// Yield one microtask before opening any dialog: `PopupMenuButton`'s own
// route is still popping off the Navigator at the moment `onSelected`
// fires, and pushing a new route (showDialog) synchronously against
// that in-flight pop can race its close transition.
await Future<void>.delayed(Duration.zero);
if (accion == 'rename') {
await _renombrar(archivo);
return;
}
if (accion == 'share') {
await _compartir(archivo.ruta);
return;
}
if (accion == 'delete') {
await _eliminar(archivo);
}
}
Future<void> _renombrar(ArchivoGrabacion archivo) async {
final nuevoNombre = await showDialog<String>(
context: context,
builder: (_) => _DialogoRenombrarGrabacion(nombreActual: archivo.nombre),
);
if (nuevoNombre == null || nuevoNombre.trim().isEmpty) return;
if (!mounted) return;
await context.read<EstadoGrabacion>().renombrarGrabacion(
archivo.ruta,
nuevoNombre.trim(),
);
if (!mounted) return;
setState(_recargar);
}
Future<void> _eliminar(ArchivoGrabacion archivo) async {
final l10n = AppLocalizations.of(context);
final confirmar = await showDialog<bool>(
context: context,
builder:
(ctx) => AlertDialog(
title: Text(l10n.recordingDeleteConfirmTitle),
content: Text(l10n.recordingDeleteConfirmMessage),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: Text(l10n.cancelAction),
),
FilledButton(
onPressed: () => Navigator.pop(ctx, true),
child: Text(l10n.recordingActionDelete),
),
],
),
);
if (confirmar != true) return;
if (!mounted) return;
await context.read<EstadoGrabacion>().eliminarGrabacion(archivo.ruta);
if (!mounted) return;
setState(_recargar);
}
String _formatearDuracion(Duration? d) {
if (d == null) return '--:--';
final m = d.inMinutes.remainder(60).toString().padLeft(2, '0');
final s = d.inSeconds.remainder(60).toString().padLeft(2, '0');
final h = d.inHours;
return h > 0 ? '$h:$m:$s' : '$m:$s';
}
String _formatearFecha(DateTime fecha) {
final dia = fecha.day.toString().padLeft(2, '0');
final mes = fecha.month.toString().padLeft(2, '0');
return '$dia/$mes/${fecha.year}';
}
String _formatearBytes(int bytes) {
if (bytes < 1024) return '$bytes B';
if (bytes < 1024 * 1024) return '${(bytes / 1024).toStringAsFixed(1)} KB';
return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB';
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
return PluriPushScaffold(
title: l10n.recordingsLibraryTitle,
actions: [
IconButton(
// Audit 12.1 (t4:610): the header action is `folder_open` at
// 22px, not a generic gear.
icon: const Icon(Icons.folder_open_rounded, size: 22),
tooltip: l10n.recordingsLibrarySettingsTooltip,
onPressed:
() => PluriPushScaffold.push(
context,
(_) => const PantallaAjustesGrabaciones(),
),
),
],
body: FutureBuilder<List<ArchivoGrabacion>>(
future: _grabaciones,
builder: (context, snap) {
final archivos = snap.data ?? const <ArchivoGrabacion>[];
return ListView(
padding: PluriLayout.pageContentPadding,
children: [
_BarraDeAlmacenamiento(archivos: archivos),
// Issue 3 (feedback-pruebas): t4:617 draws a 16px gap between
// the storage card and the rows below it, not 12.
const SizedBox(
height: 16,
key: ValueKey('grabaciones-storage-gap'),
),
if (snap.connectionState == ConnectionState.done &&
archivos.isEmpty)
PluriEmptyState(
glyph: PluriIconGlyph.player,
title: l10n.recordingsLibraryEmptyTitle,
subtitle: l10n.recordingsLibraryEmptySubtitle,
)
else
// t4:618: `gap:2px` between rows.
PluriPanelColumn(
gap: 2,
children: [
for (final archivo in archivos)
_FilaGrabacion(
archivo: archivo,
reproduciendo:
_reproductor.rutaActual == archivo.ruta &&
_reproductor.reproduciendo,
duracion: _duracionPara(archivo.ruta),
formatearDuracion: _formatearDuracion,
formatearFecha: _formatearFecha,
formatearBytes: _formatearBytes,
onAlternarReproduccion:
() => _alternarReproduccion(archivo.ruta),
onAccionMenu:
(accion) => _manejarAccion(accion, archivo),
),
],
),
],
);
},
),
);
}
}
class _BarraDeAlmacenamiento extends StatelessWidget {
const _BarraDeAlmacenamiento({required this.archivos});
final List<ArchivoGrabacion> archivos;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final estado = context.watch<EstadoGrabacion>();
final usadoBytes = archivos.fold<int>(0, (s, a) => s + a.tamanoBytes);
final totalBytes = estado.maxBytes <= 0 ? 1 : estado.maxBytes;
final fraccion = (usadoBytes / totalBytes).clamp(0.0, 1.0);
final usadoMb = (usadoBytes / (1024 * 1024)).round();
final totalMb = (totalBytes / (1024 * 1024)).round();
return PluriGlassSurface(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Audit 12.2 (t4 line 613): the bold "used of total" headline
// sits ABOVE the bar -- was the bar first, then this same string
// rendered small below it as the only caption.
Text(
l10n.recordingsLibraryStorageCaption(usadoMb, totalMb),
style: Theme.of(
context,
).textTheme.bodySmall?.copyWith(fontWeight: FontWeight.w800),
),
const SizedBox(height: 8),
// Audit 12.2: 6px bar, radius 3 (was minHeight 8, radius 8).
ClipRRect(
borderRadius: BorderRadius.circular(3),
child: LinearProgressIndicator(value: fraccion, minHeight: 6),
),
const SizedBox(height: 7),
// Audit 12.3 (t4 line 613): the real caption names the folder
// and the purge policy -- the generic "X of Y used" line moved
// up to become the headline above, it never described either of
// those.
Text(
l10n.recordingsLibraryStorageFolderCaption,
style: Theme.of(context).textTheme.bodySmall,
),
],
),
);
}
}
class _FilaGrabacion extends StatelessWidget {
const _FilaGrabacion({
required this.archivo,
required this.reproduciendo,
required this.duracion,
required this.formatearDuracion,
required this.formatearFecha,
required this.formatearBytes,
required this.onAlternarReproduccion,
required this.onAccionMenu,
});
final ArchivoGrabacion archivo;
final bool reproduciendo;
final Future<Duration?> duracion;
final String Function(Duration?) formatearDuracion;
final String Function(DateTime) formatearFecha;
final String Function(int) formatearBytes;
final VoidCallback onAlternarReproduccion;
final ValueChanged<String> onAccionMenu;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final theme = Theme.of(context);
// Item 23 / audit 12.4 (t4:616-619): a flat, background-less row --
// 44x44/radius-12 art placeholder (recordings carry no per-station
// favicon, so this is a themed fallback square, not invented artwork),
// name, meta line, a 24px play/pause affordance, and the SAME "-"
// menu (Rename/Share/Delete) as before, just restyled.
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 10),
child: Row(
children: [
ClipRRect(
key: const ValueKey('fila-grabacion-arte'),
borderRadius: BorderRadius.circular(12),
child: Container(
width: 44,
height: 44,
color: theme.colorScheme.primaryContainer,
child: Icon(
Icons.radio_rounded,
size: 22,
color: theme.colorScheme.onPrimaryContainer,
),
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// t4:619: 14.5px/w700.
Text(
archivo.nombre,
style: const TextStyle(
fontSize: 14.5,
fontWeight: FontWeight.w700,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
// t4:619: 12px/rgba(242,247,250,.55).
FutureBuilder<Duration?>(
future: duracion,
builder: (context, snap) {
return Text(
'${formatearFecha(archivo.fecha)} · '
'${formatearDuracion(snap.data)} · '
'${formatearBytes(archivo.tamanoBytes)}',
style: TextStyle(
fontSize: 12,
color: theme.colorScheme.onSurface.withValues(
alpha: 0.55,
),
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
);
},
),
],
),
),
// t4:619: play_circle 24px brand teal in a 42x42 target.
SizedBox(
width: 42,
height: 42,
child: IconButton(
padding: EdgeInsets.zero,
tooltip: reproduciendo ? l10n.pauseAction : l10n.playAction,
icon: Icon(
reproduciendo
? Icons.pause_circle_filled_rounded
: Icons.play_circle_fill_rounded,
size: 24,
color: PluriWaveTokens.brand,
),
onPressed: onAlternarReproduccion,
),
),
// t4:619: more_vert 20px/45% in a 38x42 target.
SizedBox(
width: 38,
height: 42,
child: PopupMenuButton<String>(
padding: EdgeInsets.zero,
icon: Icon(
Icons.more_vert_rounded,
size: 20,
color: theme.colorScheme.onSurface.withValues(alpha: 0.45),
),
onSelected: onAccionMenu,
itemBuilder:
(context) => [
PopupMenuItem(
value: 'rename',
child: Text(l10n.recordingActionRename),
),
PopupMenuItem(
value: 'share',
child: Text(l10n.recordingActionShare),
),
PopupMenuItem(
value: 'delete',
child: Text(l10n.recordingActionDelete),
),
],
),
),
],
),
);
}
}
/// Owns its own [TextEditingController] and disposes it in its own
/// [State.dispose] — the SAFE pattern documented for this codebase (see
/// `_DialogoEdicionDispositivo` in `pantalla_ajustes_salida_audio.dart`),
/// deliberately NOT the pre-existing anti-pattern (dispose right after the
/// sheet/dialog Future resolves, racing the close animation) already
/// tracked elsewhere in this codebase as a separate, un-fixed defect.
class _DialogoRenombrarGrabacion extends StatefulWidget {
const _DialogoRenombrarGrabacion({required this.nombreActual});
final String nombreActual;
@override
State<_DialogoRenombrarGrabacion> createState() =>
_DialogoRenombrarGrabacionState();
}
class _DialogoRenombrarGrabacionState
extends State<_DialogoRenombrarGrabacion> {
late final _controller = TextEditingController(text: widget.nombreActual);
String? _error;
@override
void dispose() {
_controller.dispose();
super.dispose();
}
void _confirmar(AppLocalizations l10n) {
final valor = _controller.text.trim();
if (valor.isEmpty) {
setState(() => _error = l10n.recordingRenameEmptyError);
return;
}
Navigator.pop(context, valor);
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
return AlertDialog(
title: Text(l10n.recordingRenameDialogTitle),
content: TextField(
controller: _controller,
autofocus: true,
decoration: InputDecoration(
labelText: l10n.recordingRenameLabel,
errorText: _error,
border: const OutlineInputBorder(),
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: Text(l10n.cancelAction),
),
FilledButton(
onPressed: () => _confirmar(l10n),
child: Text(l10n.recordingActionRename),
),
],
);
}
}
File diff suppressed because it is too large Load Diff
+343
View File
@@ -0,0 +1,343 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../estado/estado_busqueda.dart';
import '../l10n/gen/app_localizations.dart';
import '../modelos/pais_radio.dart';
import '../tema/pluriwave_theme.dart';
import '../tema/pluriwave_tokens.dart';
import '../widgets/pluri_layout.dart';
import '../widgets/pluri_push_scaffold.dart';
/// WU7, `station-discovery-browse` spec — "Países Browser Over the Verified
/// Countries Contract". Pure display over `EstadoBusqueda.cargarPaises()`
/// (in-memory cache guard, so re-entering this screen never re-fetches):
/// a "Tus idiomas" shortlist above the full alphabetical list, both with
/// live station counts already parsed from the API's `stationcount`
/// **string** field (`PaisRadio.fromApi`, Engram id 2500).
class PantallaPaises extends StatefulWidget {
const PantallaPaises({super.key, this.onPaisSeleccionado});
/// Item 24 / audit 5.2 (t4:256-269): every row is tappable (the prototype
/// draws a `chevron_right` on all of them). Optional so this screen stays
/// usable stand-alone; the one production call site
/// (`pantalla_buscar.dart`'s "Explorar por" grid) wires this to filter
/// search results by the tapped country and pop back.
final ValueChanged<PaisRadio>? onPaisSeleccionado;
@override
State<PantallaPaises> createState() => _PantallaPaisesState();
}
class _PantallaPaisesState extends State<PantallaPaises> {
/// Audit 5.7 (t4:252): the header's `search` action. Null means "not
/// searching" — an ephemeral UI concern (design's "State is for
/// ephemeral UI only" ruling), never persisted.
bool _buscando = false;
final _controladorBusqueda = TextEditingController();
/// One representative country per app-supported locale (the same 13
/// locales as `pantalla_ajustes_idioma.dart`'s `_idiomas` list). "Tus
/// idiomas" is named by the proposal/spec but its derivation is not
/// otherwise specified — this reuses the app's own existing language
/// identity rather than inventing a separate curated country list.
static const _paisPorIdioma = <String, String>{
'en': 'US',
'es': 'ES',
'zh': 'CN',
'hi': 'IN',
'ar': 'SA',
'pt': 'PT',
'fr': 'FR',
'ru': 'RU',
'de': 'DE',
'ja': 'JP',
'id': 'ID',
'bn': 'BD',
'it': 'IT',
};
@override
void initState() {
super.initState();
// `cargarPaises()` calls `notifyListeners()` before its first `await`
// (its loading-flag flip) — doing that synchronously inside `initState`
// would trigger "setState() or markNeedsBuild() called during build."
// Deferred to a post-frame callback, matching the established pattern
// for triggering a load from a screen's `initState`/`build`
// (`pantalla_alarmas.dart`'s `_favoritosSolicitados` guard).
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) context.read<EstadoBusqueda>().cargarPaises();
});
}
@override
void dispose() {
_controladorBusqueda.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final estado = context.watch<EstadoBusqueda>();
final query = _controladorBusqueda.text.trim().toLowerCase();
final paisesFiltrados =
query.isEmpty
? estado.paises
: estado.paises
.where(
(p) =>
p.nombre.toLowerCase().contains(query) ||
p.codigoIso.toLowerCase().contains(query),
)
.toList();
return PluriPushScaffold(
title: l10n.countriesScreenTitle,
// Audit 5.7 (t4:252): a `search` header action -- toggles an inline
// filter field over the SAME country list, rather than a decorative
// no-op button. `PluriPushScaffold.titleOverride` stays reserved for
// its one documented exception (the player's "EN DIRECTO" pill) --
// the search field lives in the body instead, not the AppBar title.
actions: [
IconButton(
key: const ValueKey('countries-search-toggle'),
icon: Icon(_buscando ? Icons.close_rounded : Icons.search_rounded),
tooltip: l10n.navSearch,
onPressed:
() => setState(() {
_buscando = !_buscando;
if (!_buscando) _controladorBusqueda.clear();
}),
),
],
body:
estado.cargandoPaises && estado.paises.isEmpty
? const Center(child: CircularProgressIndicator())
: ListView(
padding: PluriLayout.pageContentPadding,
children: [
if (_buscando)
Padding(
padding: const EdgeInsets.only(bottom: 12),
child: TextField(
key: const ValueKey('countries-search-field'),
controller: _controladorBusqueda,
autofocus: true,
decoration: InputDecoration(
hintText: l10n.countriesSearchHint,
prefixIcon: const Icon(Icons.search_rounded),
),
onChanged: (_) => setState(() {}),
),
),
if (query.isEmpty) ...[
_seccionTusIdiomas(context, estado.paises, l10n),
// Issue 3 (feedback-pruebas): t4:260 draws a 14px gap
// between "Tus idiomas" and "Todos", not 16.
const SizedBox(
height: 14,
key: ValueKey('paises-seccion-gap'),
),
_seccionTodos(context, estado.paises, l10n),
] else
_seccionTodos(context, paisesFiltrados, l10n),
],
),
);
}
void _seleccionar(PaisRadio pais) {
widget.onPaisSeleccionado?.call(pais);
}
Widget _seccionTusIdiomas(
BuildContext context,
List<PaisRadio> paises,
AppLocalizations l10n,
) {
final porCodigo = {for (final p in paises) p.codigoIso: p};
final destacados =
_paisPorIdioma.values
.map((codigo) => porCodigo[codigo])
.whereType<PaisRadio>()
.toList();
if (destacados.isEmpty) return const SizedBox.shrink();
final type = context.pluriType;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Audit 5.4 (t4:254): an eyebrow OUTSIDE any card, title-tier
// (20px) padding -- was titleMedium w900 inside a PluriGlassSurface.
Padding(
padding: const EdgeInsets.fromLTRB(
PluriLayout.titleHorizontal,
0,
PluriLayout.titleHorizontal,
8,
),
child: Text(
l10n.countriesYourLanguagesTitle,
style: type.eyebrowLabel,
),
),
Padding(
padding: const EdgeInsets.symmetric(
horizontal: PluriLayout.rowHorizontal,
),
child: Column(
children: [
// Item 24 / audit 5.1 (t4:255-258): a column of tappable ISO
// rows, not a Wrap of non-interactive Chips.
for (final pais in destacados)
_FilaPais(
pais: pais,
l10n: l10n,
// Item 24 / audit 5.5 (t4:256): the first row is
// highlighted.
destacado: pais == destacados.first,
onTap: () => _seleccionar(pais),
),
],
),
),
],
);
}
Widget _seccionTodos(
BuildContext context,
List<PaisRadio> paises,
AppLocalizations l10n,
) {
final type = context.pluriType;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Audit 5.4 (t4:260-261): "TODOS · 238" -- an eyebrow OUTSIDE any
// card, carrying the total country count, which never rendered
// anywhere before.
Padding(
padding: const EdgeInsets.fromLTRB(
PluriLayout.titleHorizontal,
0,
PluriLayout.titleHorizontal,
8,
),
child: Text(
'${l10n.countriesAllTitle} · ${paises.length}',
style: type.eyebrowLabel,
),
),
Padding(
padding: const EdgeInsets.symmetric(
horizontal: PluriLayout.rowHorizontal,
),
child: Column(
children: [
// Item 24 / audit 5.1-5.3 (t4:262-269): the same tappable ISO
// row as "Tus idiomas" -- not the previous ListTile, which
// had no ISO column and no onTap.
for (final pais in paises)
_FilaPais(
pais: pais,
l10n: l10n,
onTap: () => _seleccionar(pais),
),
],
),
),
],
);
}
}
/// Item 24 / audit 5.1-5.3, 5.5 (t4:256-269): a tappable row -- ISO code
/// column, name, station count, and a chevron -- shared by "Tus idiomas"
/// and "Todos". [destacado] applies the t4:256 highlight (teal-tinted
/// background, bold name) reserved for the very first "Tus idiomas" row.
class _FilaPais extends StatelessWidget {
const _FilaPais({
required this.pais,
required this.l10n,
this.destacado = false,
this.onTap,
});
final PaisRadio pais;
final AppLocalizations l10n;
final bool destacado;
final VoidCallback? onTap;
@override
Widget build(BuildContext context) {
final onSurface = Theme.of(context).colorScheme.onSurface;
return DecoratedBox(
decoration: BoxDecoration(
color:
destacado
? PluriWaveTokens.brand.withValues(alpha: 0.1)
: Colors.transparent,
borderRadius: BorderRadius.circular(14),
),
child: Material(
type: MaterialType.transparency,
child: InkWell(
borderRadius: BorderRadius.circular(14),
onTap: onTap,
child: Padding(
padding: const EdgeInsets.all(12),
child: Row(
children: [
// t4:256-258: 26px/lh1, centred in a 34-wide column.
SizedBox(
width: 34,
child: Text(
pais.codigoIso,
textAlign: TextAlign.center,
style: const TextStyle(fontSize: 26, height: 1),
),
),
const SizedBox(width: 14),
Expanded(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
pais.nombre,
style: TextStyle(
fontSize: 15.5,
fontWeight:
destacado ? FontWeight.w800 : FontWeight.w700,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 2),
Text(
l10n.stationsCount(pais.numeroEmisoras),
style: TextStyle(
fontSize: 12,
color: onSurface.withValues(alpha: 0.55),
),
),
],
),
),
Icon(
Icons.chevron_right_rounded,
size: 20,
color: onSurface.withValues(alpha: 0.4),
),
],
),
),
),
),
);
}
}
File diff suppressed because it is too large Load Diff
+363
View File
@@ -0,0 +1,363 @@
import 'package:flutter/material.dart';
import '../l10n/gen/app_localizations.dart';
import '../servicios/servicio_tutorial_ayuda.dart';
import '../tema/pluriwave_theme.dart';
import '../tema/pluriwave_tokens.dart';
/// 9-screen help/tutorial carousel (mockup screens 5b..5h, reading order
/// 1..9). Reachable two ways:
/// - [mostrarSiProcede]: the genuine first-launch sequence in `app.dart`,
/// run once ever (both fresh installs AND existing installs upgrading to
/// this version), via [ServicioTutorialAyuda]'s plain one-time flag.
/// - Manually from Ajustes > Info > "Ayuda y tutorial", constructed directly
/// with `primerArranque: false`.
///
/// [primerArranque] only changes the LAST page's CTA label -- "Empezar a
/// escuchar" on a first-launch entry, "Cerrar" otherwise. Every other
/// behaviour (Saltar pops immediately, Siguiente advances) is identical
/// regardless of entry point; both cases simply pop the route when
/// finished, letting whatever screen is already mounted underneath show.
class PantallaTutorialAyuda extends StatefulWidget {
const PantallaTutorialAyuda({super.key, required this.primerArranque});
final bool primerArranque;
static final ServicioTutorialAyuda _servicio = ServicioTutorialAyuda();
static const int cantidadPaginas = 9;
/// Shows this carousel once, on the genuine first-launch sequence, then
/// never again. Mirrors `PantallaBienvenida.mostrarSiProcede`'s shape
/// (check-then-show-then-mark-seen).
static Future<void> mostrarSiProcede(BuildContext context) async {
if (!await _servicio.debeMostrarTutorial()) return;
if (!context.mounted) return;
await Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (_) => const PantallaTutorialAyuda(primerArranque: true),
),
);
await _servicio.marcarTutorialVisto();
}
@override
State<PantallaTutorialAyuda> createState() => _PantallaTutorialAyudaState();
}
class _PantallaTutorialAyudaState extends State<PantallaTutorialAyuda> {
final _controller = PageController();
int _pagina = 0;
@override
void dispose() {
_controller.dispose();
super.dispose();
}
bool get _esUltimaPagina =>
_pagina == PantallaTutorialAyuda.cantidadPaginas - 1;
void _saltar() => Navigator.of(context).pop();
void _siguiente() {
if (_esUltimaPagina) {
Navigator.of(context).pop();
return;
}
_controller.nextPage(
duration: const Duration(milliseconds: 260),
curve: Curves.easeOutCubic,
);
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final paginas = _construirPaginas(l10n);
return Scaffold(
body: SafeArea(
child: Column(
children: [
SizedBox(
height: 48,
child: Align(
alignment: Alignment.centerRight,
child:
_esUltimaPagina
? null
: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: TextButton(
onPressed: _saltar,
child: Text(l10n.tutorialSkipAction),
),
),
),
),
Expanded(
child: PageView.builder(
controller: _controller,
itemCount: PantallaTutorialAyuda.cantidadPaginas,
onPageChanged: (indice) => setState(() => _pagina = indice),
itemBuilder:
(context, indice) =>
TarjetaPaginaTutorial(datos: paginas[indice]),
),
),
Padding(
padding: const EdgeInsets.symmetric(vertical: 20),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
for (
var i = 0;
i < PantallaTutorialAyuda.cantidadPaginas;
i++
)
PuntoIndicadorTutorial(activo: i == _pagina),
],
),
),
Padding(
padding: const EdgeInsets.fromLTRB(24, 0, 24, 24),
child: SizedBox(
height: 58,
width: double.infinity,
child: FilledButton(
onPressed: _siguiente,
style: FilledButton.styleFrom(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(18),
),
),
child: Text(
_esUltimaPagina
? (widget.primerArranque
? l10n.welcomeCtaLabel
: l10n.closeAction)
: l10n.tutorialNextAction,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w800,
),
),
),
),
),
],
),
),
);
}
List<DatosPaginaTutorial> _construirPaginas(AppLocalizations l10n) {
final t = PluriWaveTokens.dark;
return [
DatosPaginaTutorial(
icono: Icons.favorite_rounded,
color: t.electricMagenta,
titulo: l10n.tutorialPage1Headline,
cuerpo: l10n.tutorialPage1Body,
),
DatosPaginaTutorial(
icono: Icons.equalizer_rounded,
color: t.liveGreen,
titulo: l10n.tutorialPage2Headline,
cuerpo: l10n.tutorialPage2Body,
),
DatosPaginaTutorial(
icono: Icons.mic_rounded,
color: t.warmCoral,
titulo: l10n.tutorialPage3Headline,
cuerpo: l10n.tutorialPage3Body,
),
DatosPaginaTutorial(
icono: Icons.alarm_rounded,
color: t.offlineAccent,
titulo: l10n.tutorialPage4Headline,
cuerpo: l10n.tutorialPage4Body,
),
DatosPaginaTutorial(
icono: Icons.directions_car_rounded,
color: PluriWaveTokens.skyBlue,
titulo: l10n.tutorialPage5Headline,
cuerpo: l10n.tutorialPage5Body,
),
DatosPaginaTutorial(
icono: Icons.wifi_tethering_rounded,
color: t.liveGreen,
titulo: l10n.tutorialPage6Headline,
cuerpo: l10n.tutorialPage6Body,
),
DatosPaginaTutorial(
icono: Icons.snooze_rounded,
color: t.warmCoral,
titulo: l10n.tutorialPage7Headline,
cuerpo: l10n.tutorialPage7Body,
),
DatosPaginaTutorial(
icono: Icons.add_link_rounded,
color: PluriWaveTokens.skyBlue,
titulo: l10n.tutorialPage8Headline,
cuerpo: l10n.tutorialPage8Body,
),
DatosPaginaTutorial(
icono: Icons.check_circle_rounded,
color: t.electricMagenta,
titulo: l10n.tutorialPage9Headline,
// Last page only: the "watch it again" reminder banner (design
// ADR text, mockup screen 5h). No progress-dot advancement beyond
// this page -- it is the final one.
bannerCuerpo: l10n.tutorialPage9BannerBody,
),
];
}
}
/// Content for a single carousel page: icon badge, headline, body, and --
/// only on the last page -- the "watch it again" reminder banner.
class DatosPaginaTutorial {
const DatosPaginaTutorial({
required this.icono,
required this.color,
required this.titulo,
this.cuerpo,
this.bannerCuerpo,
});
final IconData icono;
final Color color;
final String titulo;
/// Body copy below the headline. `null` on the last page (mockup screen
/// 5h), which shows only the headline plus [bannerCuerpo] -- no separate
/// body paragraph.
final String? cuerpo;
final String? bannerCuerpo;
}
/// One carousel page: a 150x150 rounded-square icon badge, headline, body,
/// and -- when [DatosPaginaTutorial.bannerCuerpo] is set -- the reminder
/// banner. Public (not `_TarjetaPagina`) so tests can target pages by type,
/// same reason `FilaCaracteristicaBienvenida` is public.
class TarjetaPaginaTutorial extends StatelessWidget {
const TarjetaPaginaTutorial({super.key, required this.datos});
final DatosPaginaTutorial datos;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 32),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Container(
width: 150,
height: 150,
decoration: BoxDecoration(
color: datos.color.withValues(alpha: 0.13),
borderRadius: BorderRadius.circular(32),
),
child: Icon(datos.icono, size: 72, color: datos.color),
),
const SizedBox(height: 32),
Text(
datos.titulo,
textAlign: TextAlign.center,
style: theme.textTheme.headlineSmall?.copyWith(
fontSize: 24,
fontWeight: FontWeight.w800,
letterSpacing: -0.4,
height: 1.15,
),
),
if (datos.cuerpo case final cuerpo?) ...[
const SizedBox(height: 12),
Text(
cuerpo,
textAlign: TextAlign.center,
style: theme.textTheme.bodyMedium?.copyWith(
fontSize: 14,
height: 1.5,
color: theme.colorScheme.onSurface.withValues(alpha: 0.7),
),
),
],
if (datos.bannerCuerpo case final bannerCuerpo?) ...[
const SizedBox(height: 24),
_BannerRecordatorio(texto: bannerCuerpo),
],
],
),
);
}
}
class _BannerRecordatorio extends StatelessWidget {
const _BannerRecordatorio({required this.texto});
final String texto;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.06),
borderRadius: BorderRadius.circular(16),
border: Border.all(color: Colors.white.withValues(alpha: 0.1)),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(
Icons.info_outline_rounded,
size: 20,
color: theme.colorScheme.onSurface.withValues(alpha: 0.7),
),
const SizedBox(width: 10),
Expanded(
child: Text(
texto,
style: theme.textTheme.bodySmall?.copyWith(
fontSize: 12.5,
height: 1.4,
color: theme.colorScheme.onSurface.withValues(alpha: 0.72),
),
),
),
],
),
);
}
}
/// One dot in the 9-dot progress indicator: wider and teal when [activo],
/// small and translucent otherwise. Public so tests can assert "exactly 9
/// dots" via `find.byType`.
class PuntoIndicadorTutorial extends StatelessWidget {
const PuntoIndicadorTutorial({super.key, required this.activo});
final bool activo;
@override
Widget build(BuildContext context) {
final t = context.pluriTokens;
return AnimatedContainer(
duration: const Duration(milliseconds: 200),
margin: const EdgeInsets.symmetric(horizontal: 3),
width: activo ? 24 : 8,
height: 8,
decoration: BoxDecoration(
color:
activo ? t.electricMagenta : Colors.white.withValues(alpha: 0.24),
borderRadius: BorderRadius.circular(4),
),
);
}
}

Some files were not shown because too many files have changed in this diff Show More