Compare commits
409
Commits
2e17dfd511
..
PRO
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8c4f15528c | ||
|
|
cbc54e915b | ||
|
|
86dd20b184 | ||
|
|
6b91ad88e8 | ||
|
|
05f70af7f1 | ||
|
|
c30bbacbbc | ||
|
|
192a3aca0e | ||
|
|
ab3554b746 | ||
|
|
8a71bc237f | ||
|
|
8e155cc0ac | ||
|
|
575ba793ae | ||
|
|
a5572d2cbd | ||
|
|
98b24d84cd | ||
|
|
72c5777508 | ||
|
|
b69041f32a | ||
|
|
9681a47e83 | ||
|
|
4ca2813267 | ||
|
|
a2bed18937 | ||
|
|
e57f7bb17b | ||
|
|
fdddd95199 | ||
|
|
1bfd5a2348 | ||
|
|
4ea5d2056c | ||
|
|
b5940b2758 | ||
|
|
524b8f0035 | ||
|
|
9efa6d8937 | ||
|
|
55fe50d07d | ||
|
|
2e15d05431 | ||
|
|
e9f47d47c2 | ||
|
|
080d342de0 | ||
|
|
689f3e8123 | ||
|
|
70ee13d540 | ||
|
|
9cfa5ac17d | ||
|
|
94f354a7c1 | ||
|
|
d81fabbe27 | ||
|
|
aa0b242374 | ||
|
|
186ff45105 | ||
|
|
f4a1fac45a | ||
|
|
ea005434d2 | ||
|
|
d754e28ddf | ||
|
|
1da417fdf5 | ||
|
|
950c9fda58 | ||
|
|
0949525859 | ||
|
|
0ef6ce35b4 | ||
|
|
dc62ef6adc | ||
|
|
28f47d6340 | ||
|
|
ea0c6c8a9c | ||
|
|
c3cc4120c0 | ||
|
|
7a29026992 | ||
|
|
9914aced92 | ||
|
|
4cc42af9d1 | ||
|
|
e0fa2d695a | ||
|
|
9d8f426fc8 | ||
|
|
ca5f243524 | ||
|
|
1e97a94602 | ||
|
|
107739caa3 | ||
|
|
b3bd71be84 | ||
|
|
0a47c327f1 | ||
|
|
53126bdbe7 | ||
|
|
ec6ccb2db8 | ||
|
|
8e00dc0c7c | ||
|
|
f01c0911f7 | ||
|
|
62f7804d6d | ||
|
|
57f89c130f | ||
|
|
02cfd48992 | ||
|
|
72a291d0c0 | ||
|
|
adb2a1d1bc | ||
|
|
346cd2b6b9 | ||
|
|
e337f6166c | ||
|
|
54d87190fe | ||
|
|
968377f1c7 | ||
|
|
e3638ea4a7 | ||
|
|
a93e5b192b | ||
|
|
1d5332453f | ||
|
|
42bb2b4a54 | ||
|
|
490ae29bd4 | ||
|
|
7d86ddfae9 | ||
|
|
3398d02a43 | ||
|
|
2480c57bfc | ||
|
|
1d8c9c57bc | ||
|
|
c1afe72aec | ||
|
|
809b4c6eb4 | ||
|
|
14985f6417 | ||
|
|
a6cdf0e72c | ||
|
|
924a5cab21 | ||
|
|
1e7c0daa90 | ||
|
|
bca0a9bbb7 | ||
|
|
80538900db | ||
|
|
61c78b5497 | ||
|
|
41d637b890 | ||
|
|
2bafc7e5ac | ||
|
|
c4a39ee8ed | ||
|
|
7054a4c871 | ||
|
|
ddb15623a0 | ||
|
|
3a4dc3b4b1 | ||
|
|
04300592e0 | ||
|
|
cb76f09257 | ||
|
|
df9252a621 | ||
|
|
ae3f96c0d1 | ||
|
|
a9da855601 | ||
|
|
1c2b8e0e15 | ||
|
|
34d04bbc1e | ||
|
|
e962b8ac58 | ||
|
|
f2f706b342 | ||
|
|
9581ec97d7 | ||
|
|
d3999a20fb | ||
|
|
f2d7e98813 | ||
|
|
cacd3ece57 | ||
|
|
eac4528141 | ||
|
|
abc6b47ffb | ||
|
|
dca19cd1ab | ||
|
|
1b126d5147 | ||
|
|
6da3e69f7e | ||
|
|
b0271fa953 | ||
|
|
6bb16da449 | ||
|
|
61c97858a1 | ||
|
|
802b62f578 | ||
|
|
6ecd503aae | ||
|
|
b09d644a2c | ||
|
|
344af83e16 | ||
|
|
257f1bbc68 | ||
|
|
f4f9e87970 | ||
|
|
597701f497 | ||
|
|
4d54908be6 | ||
|
|
a949b4503d | ||
|
|
e297413145 | ||
|
|
015a20a823 | ||
|
|
cfd8bc9e6a | ||
|
|
445e4518f7 | ||
|
|
d945e1a313 | ||
|
|
c507218462 | ||
|
|
f2528c930b | ||
|
|
a8dca83cd9 | ||
|
|
7722f204ca | ||
|
|
c107c0e18a | ||
|
|
fd1b91fe9e | ||
|
|
47d0b8a053 | ||
|
|
88bd251eba | ||
|
|
cd7f73056e | ||
|
|
3f80291e78 | ||
|
|
88818cf88c | ||
|
|
f19666508d | ||
|
|
8423ccdd0c | ||
|
|
049ab78acb | ||
|
|
ef9705a30e | ||
|
|
25405564ee | ||
|
|
dd463cf2bb | ||
|
|
c8b2c4d2d6 | ||
|
|
eba4eba397 | ||
|
|
4168dc5019 | ||
|
|
491585ad12 | ||
|
|
9eff760462 | ||
|
|
1b0bea5492 | ||
|
|
6822432a51 | ||
|
|
eea8ec31e6 | ||
|
|
55636f7c74 | ||
|
|
4b89c9af07 | ||
|
|
f2b02c3ce2 | ||
|
|
db6f4a3a11 | ||
|
|
3bb92c0536 | ||
|
|
9a75027d57 | ||
|
|
93b7ec2af9 | ||
|
|
5bbf750b63 | ||
|
|
7faf56900f | ||
|
|
ec93e45310 | ||
|
|
fdb7eb1d51 | ||
|
|
431f13063d | ||
|
|
fc866d7ec9 | ||
|
|
727e18737a | ||
|
|
c6ab295c54 | ||
|
|
e75f010b98 | ||
|
|
c7e1a212ca | ||
|
|
d1a911e587 | ||
|
|
4be2156e58 | ||
|
|
b365035e10 | ||
|
|
f24be19e4f | ||
|
|
7343071fca | ||
|
|
42d35a2541 | ||
|
|
f61b0b9163 | ||
|
|
36d7d5f692 | ||
|
|
dcb415b3f8 | ||
|
|
c7d137c82e | ||
|
|
dcd8488874 | ||
|
|
94b1e901d1 | ||
|
|
533896b9fa | ||
|
|
b5b9829faa | ||
|
|
e036f99a61 | ||
|
|
01615f9751 | ||
|
|
7ff156852f | ||
|
|
038ec7f3b8 | ||
|
|
8fcb734ab0 | ||
|
|
ca2400f0d4 | ||
|
|
163241d4a1 | ||
|
|
56123ea51b | ||
|
|
8ecfc928b3 | ||
|
|
3303bd387c | ||
|
|
955682271c | ||
|
|
4537497e83 | ||
|
|
2b28c4daed | ||
|
|
2255374291 | ||
|
|
653848899f | ||
|
|
e5d461af53 | ||
|
|
a49b2b6519 | ||
|
|
fcba592352 | ||
|
|
e94e64faca | ||
|
|
684e8e8ff9 | ||
|
|
e1505d7aaa | ||
|
|
9840205a83 | ||
|
|
dcb716cbc3 | ||
|
|
cd77ec256e | ||
|
|
615a5aac92 | ||
|
|
97e38becfe | ||
|
|
7ebe0b77a4 | ||
|
|
e0164f68b5 | ||
|
|
f5a211492a | ||
|
|
d0660a4966 | ||
|
|
5a5f1655a9 | ||
|
|
78a7415dd8 | ||
|
|
b8f078bc14 | ||
|
|
34388d364b | ||
|
|
c01c518541 | ||
|
|
40c2763061 | ||
|
|
3ec41bb31e | ||
|
|
2959941485 | ||
|
|
f3d744aeed | ||
|
|
9b415e73b0 | ||
|
|
862197ab48 | ||
|
|
dc21732027 | ||
|
|
c9fe0ad651 | ||
|
|
e1732af222 | ||
|
|
a2121d84bd | ||
|
|
332c2192cd | ||
|
|
61d873f035 | ||
|
|
9dfcf0b428 | ||
|
|
a09d614f52 | ||
|
|
9a2eb57a0e | ||
|
|
6653e044c7 | ||
|
|
c6f16c81b5 | ||
|
|
9bd828139f | ||
|
|
3a803ce2bf | ||
|
|
504a13641f | ||
|
|
ebdde7df01 | ||
|
|
589fc54580 | ||
|
|
c1903623be | ||
|
|
48ece948ff | ||
|
|
bd2b7d8e02 | ||
|
|
e99ecffb0c | ||
|
|
610016e935 | ||
|
|
ef90a3b849 | ||
|
|
172b2a42ac | ||
|
|
6dba89432e | ||
|
|
35df016aa3 | ||
|
|
433373ad7f | ||
|
|
f58cf8739f | ||
|
|
675b7fb4b7 | ||
|
|
17f8e69529 | ||
|
|
b183b3f3e5 | ||
|
|
c8f6162deb | ||
|
|
2403da3c2e | ||
|
|
d12dd49afe | ||
|
|
9b8209ac93 | ||
|
|
30f4445235 | ||
|
|
4042cf5ffd | ||
|
|
c94bc3d770 | ||
|
|
4f91f490b8 | ||
|
|
2391fb767b | ||
|
|
39ead7bea4 | ||
|
|
eee2ae98d0 | ||
|
|
1e33a79724 | ||
|
|
321362b1bf | ||
|
|
d0abe32eef | ||
|
|
37dee8cb5a | ||
|
|
87acfae069 | ||
|
|
f950da789a | ||
|
|
3d48da77f5 | ||
|
|
c3b7a302e7 | ||
|
|
29f7d54e85 | ||
|
|
0f9a6a1719 | ||
|
|
163ff69f7a | ||
|
|
3473034b58 | ||
|
|
90b75c1825 | ||
|
|
689a386403 | ||
|
|
fb7fe8774b | ||
|
|
12967e9894 | ||
|
|
7daa6cfdb6 | ||
|
|
09e0216874 | ||
|
|
2a5030431b | ||
|
|
dca3a1107e | ||
|
|
49def4b276 | ||
|
|
dfd40ca937 | ||
|
|
85dd043cd4 | ||
|
|
352eb9fc37 | ||
|
|
e030a0975d | ||
|
|
725169cd31 | ||
|
|
977cbcd8cc | ||
|
|
6ae7e378c4 | ||
|
|
99897ec848 | ||
|
|
9bfa9ac408 | ||
|
|
90cd232ad2 | ||
|
|
066fedb7bc | ||
|
|
f368bcc777 | ||
|
|
f9003436ea | ||
|
|
c193650cc4 | ||
|
|
08cae2a5d4 | ||
|
|
07c6e32af0 | ||
|
|
35bb180612 | ||
|
|
43781274ce | ||
|
|
03de273369 | ||
|
|
f1bb54d25a | ||
|
|
38d78fc4f8 | ||
|
|
448fbec354 | ||
|
|
d8e67a5204 | ||
|
|
e84cd2d7ed | ||
|
|
9c7cf4e261 | ||
|
|
597c98d0e7 | ||
|
|
8741cdba5f | ||
|
|
3fd5080cd1 | ||
|
|
41b95fed44 | ||
|
|
a788eabfcb | ||
|
|
6f07e27905 | ||
|
|
8a4a8bd5d7 | ||
|
|
884567beaa | ||
|
|
5bd861d7fb | ||
|
|
bd7f883118 | ||
|
|
836fb44adb | ||
|
|
2e64740b26 | ||
|
|
f73a12ad48 | ||
|
|
86225cbc68 | ||
|
|
e84a41acd4 | ||
|
|
1b1b692f04 | ||
|
|
5f65d068a8 | ||
|
|
3ebb41aa9d | ||
|
|
812922d7f1 | ||
|
|
5291221fc8 | ||
|
|
2c28f1696a | ||
|
|
b294b287c7 | ||
|
|
b7af1064cc | ||
|
|
c65497e58a | ||
|
|
762e740c89 | ||
|
|
48e74e6bfe | ||
|
|
d824ef5c21 | ||
|
|
45b7fc8741 | ||
|
|
2255c18ce8 | ||
|
|
13ad736917 | ||
|
|
a34182fdaf | ||
|
|
65c1ac2085 | ||
|
|
23ab3494a7 | ||
|
|
7eaa87b462 | ||
|
|
f6ea4e64b9 | ||
|
|
efbf289f6b | ||
|
|
0804a612ec | ||
|
|
79f6f8ef38 | ||
|
|
b9539223de | ||
|
|
66a19525bd | ||
|
|
a6e1177752 | ||
|
|
acd903d9a8 | ||
|
|
251d3fd3cd | ||
|
|
43f61d7c21 | ||
|
|
39693ce995 | ||
|
|
159334f997 | ||
|
|
41b35c7f44 | ||
|
|
747738d20a | ||
|
|
0b18540935 | ||
|
|
8cca7c3daa | ||
|
|
158203fee9 | ||
|
|
b17c582572 | ||
|
|
224763bca3 | ||
|
|
aef4e02c1f | ||
|
|
d3763eaec5 | ||
|
|
8f7ca8059b | ||
|
|
bfa95a1e57 | ||
|
|
0ab63731d0 | ||
|
|
a31dc07318 | ||
|
|
bccc5c48b8 | ||
|
|
e5b6d8acb3 | ||
|
|
8f2bf2bdd6 | ||
|
|
c78af4b1e8 | ||
|
|
28b663bbe7 | ||
|
|
a8a2db4b64 | ||
|
|
7cfde24811 | ||
|
|
da9d32849c | ||
|
|
6acbd7ca93 | ||
|
|
cc98f3f331 | ||
|
|
5877c2a4ee | ||
|
|
f5cf261f12 | ||
|
|
ffd09a2179 | ||
|
|
481944815f | ||
|
|
89ff6a3912 | ||
|
|
4ffd73d136 | ||
|
|
58922de6fc | ||
|
|
71978de68f | ||
|
|
d1d4afb88f | ||
|
|
4632d53eb8 | ||
|
|
8f42e67b48 | ||
|
|
f7753c8402 | ||
|
|
8a032e6e62 | ||
|
|
202bef3539 | ||
|
|
52855e75c2 | ||
|
|
0416b301b2 | ||
|
|
0380bbb1e7 | ||
|
|
079e19f0ee | ||
|
|
f3e9487215 | ||
|
|
b5acf97ba4 | ||
|
|
cf9422dff3 | ||
|
|
957615dcd6 | ||
|
|
089b8b4227 | ||
|
|
a5475ce118 | ||
|
|
00fe49c309 | ||
|
|
643ba1eb45 | ||
|
|
7abc8c3b0f |
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 |
@@ -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="." />
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
@@ -12,5 +12,6 @@ import UIKit
|
||||
|
||||
func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) {
|
||||
GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry)
|
||||
AudioDevicesPlugin.register(with: engineBridge.pluginRegistry.registrar(forPlugin: "AudioDevicesPlugin")!)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
+598
-558
File diff suppressed because it is too large
Load Diff
@@ -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
File diff suppressed because it is too large
Load Diff
+749
-249
File diff suppressed because it is too large
Load Diff
+718
-218
File diff suppressed because it is too large
Load Diff
+504
-4
@@ -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
@@ -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
File diff suppressed because it is too large
Load Diff
+749
-249
File diff suppressed because it is too large
Load Diff
+724
-224
File diff suppressed because it is too large
Load Diff
+720
-220
File diff suppressed because it is too large
Load Diff
+749
-249
File diff suppressed because it is too large
Load Diff
@@ -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
File diff suppressed because it is too large
Load Diff
+749
-249
File diff suppressed because it is too large
Load Diff
+749
-249
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||
}
|
||||
@@ -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
+1610
-128
File diff suppressed because it is too large
Load Diff
+1621
-127
File diff suppressed because it is too large
Load Diff
+1618
-111
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
+1637
-121
File diff suppressed because it is too large
Load Diff
+1615
-128
File diff suppressed because it is too large
Load Diff
+1613
-114
File diff suppressed because it is too large
Load Diff
+1623
-110
File diff suppressed because it is too large
Load Diff
+1572
-143
File diff suppressed because it is too large
Load Diff
+1617
-119
File diff suppressed because it is too large
Load Diff
+1632
-127
File diff suppressed because it is too large
Load Diff
+1558
-145
File diff suppressed because it is too large
Load Diff
+376
-34
@@ -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
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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)';
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
+278
-1351
File diff suppressed because it is too large
Load Diff
@@ -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,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+1530
-706
File diff suppressed because it is too large
Load Diff
@@ -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],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
+1414
-111
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)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
+865
-270
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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
Reference in New Issue
Block a user