Compare commits
131
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
b365035e10 |
@@ -109,9 +109,131 @@ 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
|
||||
|
||||
|
||||
@@ -202,8 +202,22 @@ class AlarmScheduler(private val context: Context) {
|
||||
)
|
||||
)
|
||||
Log.d(tag, "alarm.schedule preNotice OK id=${spec.id}")
|
||||
NativeSchedulingFailures.clear(
|
||||
appContext,
|
||||
spec.id,
|
||||
NativeSchedulingFailures.TYPE_PRE_NOTICE
|
||||
)
|
||||
} catch (_: SecurityException) {
|
||||
// Silent before this fix: the main alarm can still arm via
|
||||
// setAlarmClock (exempt from the exact-alarm permission), so
|
||||
// the alarm itself rings while its 30-minute reminder simply
|
||||
// never appears, with nothing surfaced anywhere but logcat.
|
||||
Log.w(tag, "alarm.schedule preNotice SecurityException id=${spec.id}")
|
||||
NativeSchedulingFailures.record(
|
||||
appContext,
|
||||
spec.id,
|
||||
NativeSchedulingFailures.TYPE_PRE_NOTICE
|
||||
)
|
||||
}
|
||||
} else if (spec.triggerAtMillis > now) {
|
||||
appContext.sendBroadcast(
|
||||
@@ -322,19 +336,64 @@ class AlarmScheduler(private val context: Context) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The occurrence a "close this one" action is really acting on, never a
|
||||
* future one.
|
||||
*
|
||||
* Reported on-device: pressing Posponer left the alarm snoozed for ~1444
|
||||
* minutes (24h04m) instead of the configured few. The chain, all inside
|
||||
* this file: [onAlarmFired] runs from the receiver BEFORE the ringing
|
||||
* notification exists, and it persists `snoozeOriginMillis = null` plus a
|
||||
* `triggerAtMillis` already advanced to TOMORROW by
|
||||
* [computeNextTriggerMillis]. The snooze anchor was then plain
|
||||
* `spec.snoozeOriginMillis ?: spec.triggerAtMillis`, so it picked up
|
||||
* tomorrow. The old clamp (`if (target > now) target else now + minutes`)
|
||||
* could not catch it: it only rescues anchors in the PAST, and an anchor
|
||||
* +24h out sails straight through.
|
||||
*
|
||||
* [maxAheadMillis] is how far ahead an occurrence may legitimately sit for
|
||||
* the calling surface: ~0 (just the shared imminence tolerance) for the
|
||||
* ringing notification, but a full [PRE_NOTICE_MILLIS] for the pre-notice
|
||||
* notification, whose occurrence has genuinely not happened yet.
|
||||
*
|
||||
* Mirrors `EstadoAlarmas._ocurrenciaSonando` on the Dart side, which was
|
||||
* added in a9da855 for the exact same defect after 9c7cf4e had fixed only
|
||||
* one of two adjacent callers. The native lane never got that guard.
|
||||
* `lastHandledAtMillis` is the last fallback because [onAlarmFired] sets
|
||||
* it to the occurrence that just rang -- note it is NOT purely native
|
||||
* state (scheduleAlarm takes it from the Dart channel), so `now` has to
|
||||
* remain the floor.
|
||||
*/
|
||||
private fun anchorOccurrenceMillis(
|
||||
spec: NativeAlarmSpec,
|
||||
now: Long,
|
||||
maxAheadMillis: Long = 0L
|
||||
): Long {
|
||||
val limit = now + maxAheadMillis + IMMINENT_TOLERANCE_MILLIS
|
||||
fun usable(candidate: Long?): Long? = candidate?.takeIf { it <= limit }
|
||||
return usable(spec.snoozeOriginMillis)
|
||||
?: usable(spec.triggerAtMillis)
|
||||
?: usable(spec.lastHandledAtMillis)
|
||||
?: now
|
||||
}
|
||||
|
||||
/**
|
||||
* Snoozes using the SAME anchor as [postponeNext] (Design 2.2): the
|
||||
* occurrence time + minutes, clamped to now + minutes when the target is
|
||||
* already past. Returns the resulting snooze so the caller can report it
|
||||
* back to Flutter (single source of truth), or null if the spec is gone.
|
||||
*
|
||||
* The occurrence comes from [anchorOccurrenceMillis] with no forward
|
||||
* allowance: this is the RINGING notification's button, so the occurrence
|
||||
* it closes has already arrived.
|
||||
*/
|
||||
fun snooze(id: String, minutes: Int): NativeSnoozeResult? {
|
||||
cancelAutoSilence(id)
|
||||
val spec = readSpec(id) ?: return null
|
||||
val safeMinutes = sanitizeSnoozeMinutes(minutes)
|
||||
val occurrenceAt = spec.snoozeOriginMillis ?: spec.triggerAtMillis
|
||||
val target = occurrenceAt + safeMinutes * 60_000L
|
||||
val now = System.currentTimeMillis()
|
||||
val occurrenceAt = anchorOccurrenceMillis(spec, now)
|
||||
val target = occurrenceAt + safeMinutes * 60_000L
|
||||
val snoozeUntil = if (target > now) target else now + safeMinutes * 60_000L
|
||||
Log.d(
|
||||
tag,
|
||||
@@ -355,12 +414,24 @@ class AlarmScheduler(private val context: Context) {
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Postpones from the PRE-NOTICE notification, whose occurrence has
|
||||
* legitimately not arrived yet -- it is armed [PRE_NOTICE_MILLIS] ahead.
|
||||
* So unlike [snooze] this allows an anchor that far forward, but no
|
||||
* further: an anchor beyond that window is a spec already advanced to a
|
||||
* later day, which is exactly the state that produced the reported ~24h
|
||||
* snooze. See [anchorOccurrenceMillis].
|
||||
*/
|
||||
fun postponeNext(id: String, minutes: Int): Long? {
|
||||
val spec = readSpec(id) ?: return null
|
||||
val safeMinutes = sanitizeSnoozeMinutes(minutes)
|
||||
val occurrenceAt = spec.snoozeOriginMillis ?: spec.triggerAtMillis
|
||||
val target = occurrenceAt + safeMinutes * 60_000L
|
||||
val now = System.currentTimeMillis()
|
||||
val occurrenceAt = anchorOccurrenceMillis(
|
||||
spec,
|
||||
now,
|
||||
maxAheadMillis = PRE_NOTICE_MILLIS
|
||||
)
|
||||
val target = occurrenceAt + safeMinutes * 60_000L
|
||||
val snoozeUntil = if (target > now) target else now + safeMinutes * 60_000L
|
||||
Log.d(
|
||||
tag,
|
||||
@@ -846,8 +917,22 @@ class AlarmScheduler(private val context: Context) {
|
||||
// the native recompute inside scheduleSpec.
|
||||
scheduleSpec(spec, persistOnSuccess = true, trustDartTrigger = true)
|
||||
Log.d(tag, "alarm.reschedule OK id=$id")
|
||||
NativeSchedulingFailures.clear(
|
||||
appContext,
|
||||
id,
|
||||
NativeSchedulingFailures.TYPE_RESCHEDULE
|
||||
)
|
||||
} catch (error: Throwable) {
|
||||
// Silent before this fix: one alarm's reschedule failure used
|
||||
// to just log and move to the next id, leaving that ONE
|
||||
// alarm unscheduled after a reboot/unlock/app-update with no
|
||||
// signal anywhere but logcat.
|
||||
Log.e(tag, "alarm.reschedule failed id=$id", error)
|
||||
NativeSchedulingFailures.record(
|
||||
appContext,
|
||||
id,
|
||||
NativeSchedulingFailures.TYPE_RESCHEDULE
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -855,6 +940,18 @@ class AlarmScheduler(private val context: Context) {
|
||||
fun pendingAlarmCount(): Int =
|
||||
prefs().getStringSet(KEY_IDS, emptySet()).orEmpty().size
|
||||
|
||||
/**
|
||||
* Scheduling-reliability failures the native side recorded on its own
|
||||
* (fix/alarmas-fallos-silenciosos, item 2): pre-notice, foreground-
|
||||
* service start, and post-boot/unlock reschedule failures never go
|
||||
* through a Dart method-channel call that could throw, so they are
|
||||
* persisted here instead and synced by Flutter on the next app launch
|
||||
* -- mirroring [handledOccurrences]/[nativeSnoozeStates]'s own
|
||||
* cold-start-sync shape.
|
||||
*/
|
||||
fun scheduleFailures(): List<Map<String, Any>> =
|
||||
NativeSchedulingFailures.all(appContext)
|
||||
|
||||
fun handledOccurrences(): List<Map<String, Any>> =
|
||||
prefs().getStringSet(KEY_HANDLED_IDS, emptySet()).orEmpty()
|
||||
.mapNotNull { id ->
|
||||
@@ -1265,3 +1362,91 @@ class AlarmScheduler(private val context: Context) {
|
||||
|
||||
private fun JSONObject.optNullableLong(name: String): Long? =
|
||||
if (has(name) && !isNull(name)) optLong(name) else null
|
||||
|
||||
/**
|
||||
* Persisted store for scheduling-reliability failures the native side
|
||||
* catches and previously only logged (fix/alarmas-fallos-silenciosos, item
|
||||
* 2): the pre-notice `SecurityException` (AlarmScheduler.schedulePreNotice),
|
||||
* a refused foreground-service start (PluriWaveAlarmService.start), and a
|
||||
* per-alarm reschedule failure after boot/unlock
|
||||
* (AlarmScheduler.reschedulePersistedAlarms). A separate top-level object
|
||||
* (not nested in [AlarmScheduler]'s own instance state) so
|
||||
* [PluriWaveAlarmService]'s companion object -- which has no [AlarmScheduler]
|
||||
* instance of its own -- can record a failure too, using the exact same
|
||||
* [Context]-scoped, device-protected-storage `SharedPreferences` file
|
||||
* [AlarmScheduler] itself reads/writes (same `PREFS` name, kept in sync by
|
||||
* hand since Kotlin constants cannot be shared across files without a third
|
||||
* file).
|
||||
*
|
||||
* Only the LATEST failure per alarm is kept (mirrors the Dart-side
|
||||
* `ExcepcionAlarma` "latest wins" semantics) -- this is a reliability
|
||||
* signal, not an audit log.
|
||||
*/
|
||||
object NativeSchedulingFailures {
|
||||
private const val PREFS = "pluriwave_alarm_scheduler"
|
||||
private const val KEY_FAILURE_IDS = "schedule_failure_alarm_ids"
|
||||
private const val KEY_FAILURE_TYPE_PREFIX = "schedule_failure_type_"
|
||||
private const val KEY_FAILURE_AT_PREFIX = "schedule_failure_at_"
|
||||
|
||||
/** Mirrors Dart's `ExcepcionAlarma.tipoFalloPreaviso`. */
|
||||
const val TYPE_PRE_NOTICE = "preNoticeFailed"
|
||||
|
||||
/** Mirrors Dart's `ExcepcionAlarma.tipoFalloServicioSonido`. */
|
||||
const val TYPE_FOREGROUND_SERVICE = "foregroundServiceFailed"
|
||||
|
||||
/** Mirrors Dart's `ExcepcionAlarma.tipoFalloReprogramacionArranque`. */
|
||||
const val TYPE_RESCHEDULE = "rescheduleAfterBootFailed"
|
||||
|
||||
private fun prefs(context: Context) =
|
||||
context.applicationContext.createDeviceProtectedStorageContext()
|
||||
.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||
|
||||
fun record(context: Context, id: String, type: String) {
|
||||
if (id.isBlank()) return
|
||||
val store = prefs(context)
|
||||
val ids = store.getStringSet(KEY_FAILURE_IDS, emptySet()).orEmpty().toMutableSet()
|
||||
ids.add(id)
|
||||
store.edit()
|
||||
.putStringSet(KEY_FAILURE_IDS, ids)
|
||||
.putString("$KEY_FAILURE_TYPE_PREFIX$id", type)
|
||||
.putLong("$KEY_FAILURE_AT_PREFIX$id", System.currentTimeMillis())
|
||||
.apply()
|
||||
Log.w("PluriWave", "alarm.scheduleFailure recorded id=$id type=$type")
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears the failure recorded for [id] ONLY when its current type is
|
||||
* [type] -- type-scoped on purpose (mirrors the Dart-side
|
||||
* `limpiarFalloProgramacion`), so a foreground-service success never
|
||||
* erases an unrelated, still-outstanding pre-notice failure for the
|
||||
* same alarm.
|
||||
*/
|
||||
fun clear(context: Context, id: String, type: String) {
|
||||
val store = prefs(context)
|
||||
val storedType = store.getString("$KEY_FAILURE_TYPE_PREFIX$id", null)
|
||||
if (storedType != type) return
|
||||
val ids = store.getStringSet(KEY_FAILURE_IDS, emptySet()).orEmpty().toMutableSet()
|
||||
if (!ids.remove(id)) return
|
||||
store.edit()
|
||||
.putStringSet(KEY_FAILURE_IDS, ids)
|
||||
.remove("$KEY_FAILURE_TYPE_PREFIX$id")
|
||||
.remove("$KEY_FAILURE_AT_PREFIX$id")
|
||||
.apply()
|
||||
}
|
||||
|
||||
fun all(context: Context): List<Map<String, Any>> {
|
||||
val store = prefs(context)
|
||||
return store.getStringSet(KEY_FAILURE_IDS, emptySet()).orEmpty().mapNotNull { id ->
|
||||
val type = store.getString("$KEY_FAILURE_TYPE_PREFIX$id", null)
|
||||
?: return@mapNotNull null
|
||||
val at = store.getLong("$KEY_FAILURE_AT_PREFIX$id", 0L)
|
||||
.takeIf { it > 0L }
|
||||
?: return@mapNotNull null
|
||||
mapOf(
|
||||
"alarmId" to id,
|
||||
"type" to type,
|
||||
"atMillis" to at
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -232,6 +232,10 @@ class MainActivity : AudioServiceActivity() {
|
||||
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")
|
||||
@@ -246,6 +250,10 @@ class MainActivity : AudioServiceActivity() {
|
||||
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) {
|
||||
@@ -751,6 +759,39 @@ class MainActivity : AudioServiceActivity() {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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()) {
|
||||
|
||||
@@ -183,7 +183,16 @@ class PluriWaveAlarmService : Service() {
|
||||
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()
|
||||
// Second documented clear site (feedback item, READ-5): this
|
||||
// branch never reaches stopEverything(), so without the same
|
||||
@@ -197,6 +206,11 @@ class PluriWaveAlarmService : Service() {
|
||||
stopSelf()
|
||||
return
|
||||
}
|
||||
NativeSchedulingFailures.clear(
|
||||
this,
|
||||
alarmId,
|
||||
NativeSchedulingFailures.TYPE_FOREGROUND_SERVICE
|
||||
)
|
||||
startAudio(
|
||||
alarmId,
|
||||
stationName,
|
||||
@@ -754,6 +768,7 @@ class PluriWaveAlarmService : Service() {
|
||||
|
||||
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)
|
||||
@@ -761,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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,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_*" />
|
||||
@@ -16,6 +16,7 @@ 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';
|
||||
@@ -281,10 +282,20 @@ class _PaginaPrincipalState extends State<_PaginaPrincipal>
|
||||
// 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();
|
||||
}
|
||||
|
||||
|
||||
+289
-26
@@ -90,6 +90,7 @@ class EstadoAlarmas extends ChangeNotifier {
|
||||
);
|
||||
await _sincronizarTodas();
|
||||
await cargarDiagnostico();
|
||||
await cargarFallosNativos();
|
||||
_activarRefresco();
|
||||
} catch (e) {
|
||||
_error = 'No se pudieron cargar las alarmas: $e';
|
||||
@@ -117,8 +118,11 @@ 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();
|
||||
}
|
||||
@@ -198,6 +202,89 @@ class EstadoAlarmas extends ChangeNotifier {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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));
|
||||
}
|
||||
@@ -229,29 +316,74 @@ class EstadoAlarmas extends ChangeNotifier {
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> posponerAlarma(AlarmaMusical alarma, int minutos) async {
|
||||
_error = null;
|
||||
// The snooze anchors to the occurrence that is RINGING — never a future
|
||||
// one. When the native fire works, the fire-time sync advances
|
||||
// proximaEjecucion to the NEXT day before the user can even tap snooze,
|
||||
// so anchoring to proximaEjecucion re-armed "posponer 3" a full day out
|
||||
// (observed on-device: snooze armed for tomorrow 23:02). The ringing
|
||||
// occurrence is the newest candidate not meaningfully in the future:
|
||||
// snoozeOrigen (a re-snooze keeps the original anchor), then
|
||||
// proximaEjecucion (watchdog path: still today's just-due occurrence),
|
||||
// then ultimaEjecucionGestionada (native-fire path: the sync recorded
|
||||
// the ringing occurrence there), then now.
|
||||
/// 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(
|
||||
ServicioProgramacionAlarmas.toleranciaDisparoInminente,
|
||||
margen + ServicioProgramacionAlarmas.toleranciaDisparoInminente,
|
||||
);
|
||||
DateTime? sonando(DateTime? candidata) =>
|
||||
candidata != null && !candidata.isAfter(limite) ? candidata : null;
|
||||
final ejecucion =
|
||||
sonando(alarma.snoozeOrigen) ??
|
||||
sonando(alarma.proximaEjecucion) ??
|
||||
sonando(alarma.ultimaEjecucionGestionada) ??
|
||||
return sonando(propuesta) ??
|
||||
sonando(alarma?.snoozeOrigen) ??
|
||||
sonando(alarma?.proximaEjecucion) ??
|
||||
sonando(alarma?.ultimaEjecucionGestionada) ??
|
||||
ahora;
|
||||
}
|
||||
|
||||
Future<void> posponerAlarma(AlarmaMusical alarma, int minutos) async {
|
||||
_error = null;
|
||||
final ejecucion = _ocurrenciaSonando(alarma);
|
||||
debugPrint(
|
||||
'[PluriWave][alarmas] posponer id=${alarma.id} minutos=$minutos ejecucion=${ejecucion.toIso8601String()}',
|
||||
);
|
||||
@@ -267,14 +399,34 @@ class EstadoAlarmas extends ChangeNotifier {
|
||||
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,
|
||||
@@ -282,14 +434,23 @@ class EstadoAlarmas extends ChangeNotifier {
|
||||
) 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);
|
||||
@@ -298,10 +459,12 @@ class EstadoAlarmas extends ChangeNotifier {
|
||||
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();
|
||||
}
|
||||
@@ -310,11 +473,11 @@ class EstadoAlarmas extends ChangeNotifier {
|
||||
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
|
||||
@@ -447,6 +610,36 @@ 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.
|
||||
@@ -546,6 +739,60 @@ class EstadoAlarmas extends ChangeNotifier {
|
||||
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
|
||||
@@ -622,8 +869,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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -18,6 +18,23 @@ import '../servicios/servicio_grabacion_radio.dart';
|
||||
/// `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';
|
||||
}
|
||||
|
||||
class EstadoGrabacion extends ChangeNotifier {
|
||||
EstadoGrabacion({
|
||||
ServicioGrabacionRadio? servicio,
|
||||
@@ -72,7 +89,13 @@ class EstadoGrabacion extends ChangeNotifier {
|
||||
|
||||
Future<void> iniciar({Duration? duracion}) async {
|
||||
final actual = _emisoraActual();
|
||||
if (actual == null) {
|
||||
// `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;
|
||||
}
|
||||
|
||||
@@ -394,7 +394,14 @@ class EstadoRadio extends ChangeNotifier {
|
||||
_cargandoPopulares = false;
|
||||
// Design "live snapshot the source prefers": Android Auto's `Todas`
|
||||
// folder mirrors the same populares list the phone just loaded.
|
||||
_fuenteAuto?.actualizarSnapshot(todas: _populares);
|
||||
//
|
||||
// Fix `android-auto-orden`: pushes the SORTED [populares] getter, not
|
||||
// the raw [_populares] field — the same [_ordenListas] setting the
|
||||
// phone's own discovery lists (e.g. Buscar's `tendencias`) already
|
||||
// sort by must also govern this folder's order, not the API's raw
|
||||
// arrival order. `navegacion_auto.dart`'s `hijos()` no longer
|
||||
// re-sorts, so whatever order arrives here IS what the driver sees.
|
||||
_fuenteAuto?.actualizarSnapshot(todas: populares);
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
@@ -402,7 +409,13 @@ class EstadoRadio extends ChangeNotifier {
|
||||
Future<void> cargarFavoritos() async {
|
||||
_listaFavoritos = await favoritos.obtenerTodos();
|
||||
await _normalizarEmisoraPreferida();
|
||||
_fuenteAuto?.actualizarSnapshot(favoritos: _listaFavoritos);
|
||||
// Fix `android-auto-orden`: pushes the documented manual-order accessor
|
||||
// explicitly. [listaFavoritosManual] is backed by the same list as
|
||||
// [_listaFavoritos] today (obtenerTodos() already returns the persisted
|
||||
// manual order), but naming the intent here — "the exact order the
|
||||
// Favoritos screen shows and reorders" — keeps this call from silently
|
||||
// drifting onto a re-sorted list in a future refactor.
|
||||
_fuenteAuto?.actualizarSnapshot(favoritos: listaFavoritosManual);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
@@ -520,6 +533,16 @@ class EstadoRadio extends ChangeNotifier {
|
||||
await prefs.setString(_keyOrdenListas, orden.name);
|
||||
// Search owns its own listeners (S4-R3) but sorts with this preference.
|
||||
busqueda.notificarCambioOrden();
|
||||
// Fix `android-auto-orden`: Todas/Mis emisoras' Android Auto order is
|
||||
// derived from this same setting (see cargarPopulares/
|
||||
// _cargarEmisorasCustom above) — without an immediate re-push, a live
|
||||
// car session would keep showing the OLD order until the next full
|
||||
// reload instead of updating right away, same as the phone does via
|
||||
// this method's own memoized getters.
|
||||
_fuenteAuto?.actualizarSnapshot(
|
||||
todas: populares,
|
||||
misEmisoras: emisorasCustom,
|
||||
);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
@@ -659,7 +682,9 @@ class EstadoRadio extends ChangeNotifier {
|
||||
detalle: 'resolucion de ruta',
|
||||
razon: e.toString(),
|
||||
);
|
||||
_fuenteAuto?.actualizarSnapshot(misEmisoras: _emisorasCustom);
|
||||
// Fix `android-auto-orden`: pushes the SORTED [emisorasCustom] getter
|
||||
// (see the doc on this method's other 3 identical call sites below).
|
||||
_fuenteAuto?.actualizarSnapshot(misEmisoras: emisorasCustom);
|
||||
notifyListeners();
|
||||
return;
|
||||
}
|
||||
@@ -685,7 +710,8 @@ class EstadoRadio extends ChangeNotifier {
|
||||
razon: e.toString(),
|
||||
);
|
||||
}
|
||||
_fuenteAuto?.actualizarSnapshot(misEmisoras: _emisorasCustom);
|
||||
// Fix `android-auto-orden`: sorted getter, not the raw field.
|
||||
_fuenteAuto?.actualizarSnapshot(misEmisoras: emisorasCustom);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
@@ -701,7 +727,8 @@ class EstadoRadio extends ChangeNotifier {
|
||||
if (!await archivo.exists()) {
|
||||
_emisorasCustom = [];
|
||||
_customDegradado = false;
|
||||
_fuenteAuto?.actualizarSnapshot(misEmisoras: _emisorasCustom);
|
||||
// Fix `android-auto-orden`: sorted getter, not the raw field.
|
||||
_fuenteAuto?.actualizarSnapshot(misEmisoras: emisorasCustom);
|
||||
notifyListeners();
|
||||
return null;
|
||||
}
|
||||
@@ -714,7 +741,8 @@ class EstadoRadio extends ChangeNotifier {
|
||||
detalle: archivo.path,
|
||||
razon: e.toString(),
|
||||
);
|
||||
_fuenteAuto?.actualizarSnapshot(misEmisoras: _emisorasCustom);
|
||||
// Fix `android-auto-orden`: sorted getter, not the raw field.
|
||||
_fuenteAuto?.actualizarSnapshot(misEmisoras: emisorasCustom);
|
||||
notifyListeners();
|
||||
return null;
|
||||
}
|
||||
|
||||
+69
-2
@@ -604,7 +604,27 @@
|
||||
"endLabel": "النهاية",
|
||||
"equalizerDisable": "تعطيل المعادل",
|
||||
"helpTitle": "المساعدة والشرح",
|
||||
"helpSubtitle": "راجع ميزات PluriWave والنصائح والمستجدات.",
|
||||
"helpSubtitle": "9 شاشات · شاهده مجددًا متى شئت",
|
||||
"tutorialSkipAction": "تخطي",
|
||||
"tutorialNextAction": "التالي",
|
||||
"tutorialPage1Headline": "احفظ محطاتك ونظّمها في مجموعات",
|
||||
"tutorialPage1Body": "اضغط على أيقونة القلب لحفظ محطة. في «محطاتك» يمكنك إنشاء مجموعات مثل «كل صباح» أو «السيارة» وإعادة ترتيبها بالسحب.",
|
||||
"tutorialPage2Headline": "معادل صوت عام وآخر لكل محطة",
|
||||
"tutorialPage2Body": "من الإعدادات تحدد المعادل الصوتي العام. ومن تشغيل محطة معينة يمكنك ضبط إعداد خاص بها، له الأولوية على الإعداد العام.",
|
||||
"tutorialPage3Headline": "سجّل ما تستمع إليه",
|
||||
"tutorialPage3Body": "من شريط أدوات المشغل، يحفظ زر «تسجيل» البث الأصلي. ستجد تسجيلاتك في الإعدادات › التسجيلات.",
|
||||
"tutorialPage4Headline": "منبهات تتكيّف معك",
|
||||
"tutorialPage4Body": "استيقظ على محطتك المفضلة، وأجّل المنبه 3 أو 5 أو 10 دقائق، وأضف فترات إجازة ليتخطى بعض المنبهات نفسه تلقائيًا.",
|
||||
"tutorialPage5Headline": "محطاتك المفضلة، في السيارة أيضًا",
|
||||
"tutorialPage5Body": "اربط هاتفك بـ Android Auto لتجد المفضلة وجميع المحطات ومحطاتك والموسيقى المحلية، بأزرار كبيرة مصممة للقيادة.",
|
||||
"tutorialPage6Headline": "يعيد الاتصال تلقائيًا",
|
||||
"tutorialPage6Body": "إذا انقطعت الإشارة، تعيد PluriWave المحاولة تلقائيًا وتستمر في عرض محطاتك المفضلة المحفوظة حتى بدون اتصال.",
|
||||
"tutorialPage7Headline": "اختر مدة التأجيل",
|
||||
"tutorialPage7Body": "عند رنين المنبه، لا يوجد خيار تأجيل واحد فقط: تختار 3 أو 5 أو 10 دقائق حسب ما تحتاجه في تلك اللحظة.",
|
||||
"tutorialPage8Headline": "لم تجدها؟ أضفها بنفسك",
|
||||
"tutorialPage8Body": "من «محطاتك» ← إضافة محطة مخصصة، الصق رابط بث محطة غير موجودة في نتائج البحث. تُحفظ في «محطاتك»، وتكون متاحة في السيارة أيضًا.",
|
||||
"tutorialPage9Headline": "تمّ، أصبحت تعرف الأساسيات",
|
||||
"tutorialPage9BannerBody": "لمشاهدة هذا الشرح مرة أخرى في أي وقت: الإعدادات ← المعلومات ← المساعدة والشرح.",
|
||||
"indefiniteOption": "غير محدد",
|
||||
"invalidNumber": "رقم غير صالح",
|
||||
"nameLabel": "الاسم",
|
||||
@@ -830,5 +850,52 @@
|
||||
"welcomeHeadline": "عالمك، على الهواء مباشرة",
|
||||
"yourStationsTitle": "محطاتك",
|
||||
"nowListeningLabel": "الاستماع الآن",
|
||||
"popularNowTitle": "الأكثر شيوعًا الآن"
|
||||
"popularNowTitle": "الأكثر شيوعًا الآن",
|
||||
"eqCustomActionEnableLabel": "تفعيل الموازن",
|
||||
"eqCustomActionDisableLabel": "إيقاف الموازن",
|
||||
"eqCustomActionPresetLabel": "الإعداد المسبق: {preset}",
|
||||
"@eqCustomActionPresetLabel": {
|
||||
"placeholders": {
|
||||
"preset": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"alarmCardVacationPausedBadge": "متوقفة مؤقتًا بسبب الإجازة",
|
||||
"alarmCardSchedulingFailedMessage": "لم يتم تسجيل هذا المنبه في النظام، لذا قد لا يرن.",
|
||||
"alarmCardPreNoticeFailedMessage": "تمت جدولة هذا المنبه، لكن تعذّر ضبط تذكيره المسبق.",
|
||||
"alarmDiagnosticsExactAlarmsTitle": "جدولة المنبه بدقة",
|
||||
"alarmDiagnosticsExactAlarmsHint": "يتيح رنين المنبه في الدقيقة المحددة تمامًا، حتى عندما يكون الهاتف في وضع السكون.",
|
||||
"alarmDiagnosticsNotificationsTitle": "الإشعارات",
|
||||
"alarmDiagnosticsNotificationsHint": "ضرورية لعرض المنبه والتنبيه المسبق.",
|
||||
"alarmDiagnosticsFullScreenTitle": "عرض المنبه بملء الشاشة",
|
||||
"alarmDiagnosticsFullScreenHint": "يتيح ظهور شاشة الرنين تلقائيًا، حتى عندما يكون الهاتف مقفلاً.",
|
||||
"alarmDiagnosticsBatteryTitle": "تحسين استهلاك البطارية",
|
||||
"alarmDiagnosticsBatteryHint": "يمنع النظام من إغلاق PluriWave في الخلفية حتى يتمكن المنبه من الرنين.",
|
||||
"alarmDiagnosticsNativeCountTitle": "المنبهات المسجَّلة لدى Android",
|
||||
"alarmDiagnosticsNativeCountValue": "المسجَّل حاليًا: {count}",
|
||||
"@alarmDiagnosticsNativeCountValue": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"alarmDiagnosticsNativeCountAttentionHint": "لديك منبه مفعَّل، لكن لا يوجد أي منبه مسجَّل في النظام بعد. أعد فتح PluriWave، أو عالج النقاط أعلاه أولاً.",
|
||||
"alarmDiagnosticsManufacturerLabel": "الشركة المصنِّعة",
|
||||
"alarmDiagnosticsSdkLabel": "إصدار Android (SDK)",
|
||||
"alarmDiagnosticsNeedsAttentionStatus": "يحتاج إلى انتباه",
|
||||
"alarmDiagnosticsAutostartTitle": "خطوة يدوية إضافية على هذا الهاتف",
|
||||
"alarmDiagnosticsAutostartBody": "غالبًا ما تُغلق هواتف {manufacturer} التطبيقات العاملة في الخلفية لتوفير البطارية. لا يوجد إعداد يمكن لـ PluriWave تفعيله بنفسه — عليك أن تُفعِّل بنفسك خاصية التشغيل التلقائي (تُعرف أحيانًا باسم \"Autostart\" أو \"النشاط في الخلفية\") لتطبيق PluriWave. ابحث عنها في الإعدادات، ضمن التطبيقات أو البطارية، أو في تطبيق الأمان الخاص بالهاتف.",
|
||||
"@alarmDiagnosticsAutostartBody": {
|
||||
"placeholders": {
|
||||
"manufacturer": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"alarmDiagnosticsFixAction": "إصلاح",
|
||||
"alarmDiagnosticsIntentUnavailable": "تعذّر فتح شاشة الإعدادات هذه على هذا الهاتف. حاول البحث عنها يدويًا في الإعدادات.",
|
||||
"alarmDiagnosticsUnavailableHint": "لم نتمكّن بعد من التحقق من إعدادات المنبه الخاصة بك.",
|
||||
"autoEqDisableOption": "تعطيل"
|
||||
}
|
||||
|
||||
+69
-2
@@ -604,7 +604,27 @@
|
||||
"endLabel": "শেষ",
|
||||
"equalizerDisable": "ইকুয়ালাইজার বন্ধ করুন",
|
||||
"helpTitle": "সহায়তা ও টিউটোরিয়াল",
|
||||
"helpSubtitle": "PluriWave-এর ফিচার, টিপস ও নতুন বিষয়গুলো দেখুন।",
|
||||
"helpSubtitle": "৯টি স্ক্রিন · যখন খুশি আবার দেখুন",
|
||||
"tutorialSkipAction": "এড়িয়ে যান",
|
||||
"tutorialNextAction": "পরবর্তী",
|
||||
"tutorialPage1Headline": "আপনার স্টেশন সংরক্ষণ করুন এবং গ্রুপে ভাগ করুন",
|
||||
"tutorialPage1Body": "একটি স্টেশন সংরক্ষণ করতে হার্ট আইকনে ট্যাপ করুন। «আপনার স্টেশন»-এ আপনি «প্রতিদিন সকালে» বা «গাড়ি»-এর মতো গ্রুপ তৈরি করতে পারেন এবং টেনে সেগুলোর ক্রম বদলাতে পারেন।",
|
||||
"tutorialPage2Headline": "একটি সাধারণ ইকুয়ালাইজার, প্রতিটি স্টেশনের জন্য আরেকটি",
|
||||
"tutorialPage2Body": "সেটিংসে আপনি সাধারণ ইকুয়ালাইজার ঠিক করেন। আর কোনো নির্দিষ্ট স্টেশন চালানোর সময় আপনি সেটির নিজস্ব সেটিং দিতে পারেন, যা সাধারণ সেটিংয়ের চেয়ে অগ্রাধিকার পায়।",
|
||||
"tutorialPage3Headline": "যা শুনছেন তা রেকর্ড করুন",
|
||||
"tutorialPage3Body": "প্লেয়ারের টুল ট্রে থেকে «রেকর্ড করুন» মূল স্ট্রিম সংরক্ষণ করে। আপনার রেকর্ডিং পাবেন সেটিংস › রেকর্ডিং-এ।",
|
||||
"tutorialPage4Headline": "এমন অ্যালার্ম যা আপনার সাথে মানিয়ে নেয়",
|
||||
"tutorialPage4Body": "আপনার প্রিয় স্টেশনে জেগে উঠুন, ৩, ৫ বা ১০ মিনিট স্নুজ করুন, এবং ছুটির সময়সীমা যোগ করুন যাতে কিছু অ্যালার্ম নিজে থেকেই বাদ পড়ে।",
|
||||
"tutorialPage5Headline": "আপনার প্রিয়গুলো, গাড়িতেও",
|
||||
"tutorialPage5Body": "আপনার ফোন Android Auto-এর সাথে সংযুক্ত করুন এবং পাবেন প্রিয়, সব স্টেশন, আপনার স্টেশন এবং আপনার লোকাল মিউজিক, গাড়ি চালানোর উপযোগী বড় বোতামসহ।",
|
||||
"tutorialPage6Headline": "নিজে থেকেই আবার সংযুক্ত হয়",
|
||||
"tutorialPage6Body": "সিগন্যাল কেটে গেলে, PluriWave স্বয়ংক্রিয়ভাবে আবার চেষ্টা করে এবং সংযোগ ছাড়াই আপনার সংরক্ষিত প্রিয়গুলো দেখাতে থাকে।",
|
||||
"tutorialPage7Headline": "কতক্ষণ স্নুজ করবেন তা বেছে নিন",
|
||||
"tutorialPage7Body": "অ্যালার্ম বাজলে, «স্নুজ»-এর একটিমাত্র বিকল্প নেই: আপনি সেই মুহূর্তে প্রয়োজন অনুযায়ী ৩, ৫ বা ১০ মিনিট বেছে নেন।",
|
||||
"tutorialPage8Headline": "খুঁজে পাচ্ছেন না? নিজেই যোগ করুন",
|
||||
"tutorialPage8Body": "«আপনার স্টেশন» → কাস্টম স্টেশন যোগ করুন-এ, খোঁজে না পাওয়া স্টেশনের স্ট্রিম URL পেস্ট করুন। এটি «আপনার স্টেশন»-এ সংরক্ষিত হয় এবং গাড়িতেও পাওয়া যায়।",
|
||||
"tutorialPage9Headline": "শেষ, এখন আপনি মূল বিষয়গুলো জানেন",
|
||||
"tutorialPage9BannerBody": "এই টিউটোরিয়ালটি আবার দেখতে: সেটিংস → তথ্য → সহায়তা ও টিউটোরিয়াল।",
|
||||
"indefiniteOption": "অনির্দিষ্ট",
|
||||
"invalidNumber": "অবৈধ সংখ্যা",
|
||||
"nameLabel": "নাম",
|
||||
@@ -830,5 +850,52 @@
|
||||
"welcomeHeadline": "আপনার বিশ্ব, সরাসরি",
|
||||
"yourStationsTitle": "আপনার স্টেশন",
|
||||
"nowListeningLabel": "এখন শোনা হচ্ছে",
|
||||
"popularNowTitle": "এখন জনপ্রিয়"
|
||||
"popularNowTitle": "এখন জনপ্রিয়",
|
||||
"eqCustomActionEnableLabel": "ইকুয়ালাইজার চালু করুন",
|
||||
"eqCustomActionDisableLabel": "ইকুয়ালাইজার বন্ধ করুন",
|
||||
"eqCustomActionPresetLabel": "প্রিসেট: {preset}",
|
||||
"@eqCustomActionPresetLabel": {
|
||||
"placeholders": {
|
||||
"preset": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"alarmCardVacationPausedBadge": "ছুটির কারণে বিরত",
|
||||
"alarmCardSchedulingFailedMessage": "এই অ্যালার্মটি সিস্টেমে নিবন্ধন করা যায়নি, তাই এটি নাও বাজতে পারে।",
|
||||
"alarmCardPreNoticeFailedMessage": "এই অ্যালার্মটি নির্ধারিত হয়েছে, তবে এর আগাম রিমাইন্ডার সেট করা যায়নি।",
|
||||
"alarmDiagnosticsExactAlarmsTitle": "নির্ভুল অ্যালার্ম শিডিউলিং",
|
||||
"alarmDiagnosticsExactAlarmsHint": "ফোন ঘুমন্ত অবস্থায় থাকলেও অ্যালার্মকে ঠিক নির্ধারিত মিনিটে বাজতে দেয়।",
|
||||
"alarmDiagnosticsNotificationsTitle": "বিজ্ঞপ্তি",
|
||||
"alarmDiagnosticsNotificationsHint": "অ্যালার্ম এবং আগাম সতর্কবার্তা দেখানোর জন্য প্রয়োজনীয়।",
|
||||
"alarmDiagnosticsFullScreenTitle": "অ্যালার্মের ফুল-স্ক্রিন প্রদর্শন",
|
||||
"alarmDiagnosticsFullScreenHint": "ফোন লক থাকলেও রিং হওয়ার স্ক্রিনটি স্বয়ংক্রিয়ভাবে দেখা দিতে দেয়।",
|
||||
"alarmDiagnosticsBatteryTitle": "ব্যাটারি অপ্টিমাইজেশন",
|
||||
"alarmDiagnosticsBatteryHint": "সিস্টেমকে ব্যাকগ্রাউন্ডে PluriWave বন্ধ করা থেকে আটকায়, যাতে অ্যালার্মটি তবুও বাজতে পারে।",
|
||||
"alarmDiagnosticsNativeCountTitle": "Android-এ নিবন্ধিত অ্যালার্ম",
|
||||
"alarmDiagnosticsNativeCountValue": "বর্তমানে নিবন্ধিত: {count}",
|
||||
"@alarmDiagnosticsNativeCountValue": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"alarmDiagnosticsNativeCountAttentionHint": "আপনার একটি অ্যালার্ম চালু আছে, কিন্তু এখনও কোনোটিই সিস্টেমে নিবন্ধিত হয়নি। PluriWave আবার খুলুন, অথবা প্রথমে উপরের বিষয়গুলো ঠিক করুন।",
|
||||
"alarmDiagnosticsManufacturerLabel": "নির্মাতা",
|
||||
"alarmDiagnosticsSdkLabel": "Android সংস্করণ (SDK)",
|
||||
"alarmDiagnosticsNeedsAttentionStatus": "মনোযোগ প্রয়োজন",
|
||||
"alarmDiagnosticsAutostartTitle": "এই ফোনে আরেকটি ম্যানুয়াল ধাপ",
|
||||
"alarmDiagnosticsAutostartBody": "{manufacturer} ফোন ব্যাটারি বাঁচাতে প্রায়ই ব্যাকগ্রাউন্ডে চলা অ্যাপ বন্ধ করে দেয়। এমন কোনো সেটিং নেই যা PluriWave নিজে থেকে চালু করতে পারে — আপনাকে নিজে থেকেই PluriWave-এর জন্য অটোস্টার্ট (কখনও কখনও \"Auto-start\" বা \"ব্যাকগ্রাউন্ড অ্যাক্টিভিটি\" নামেও পরিচিত) চালু করতে হবে। সেটিংসে, অ্যাপস বা ব্যাটারির মধ্যে, অথবা ফোনের নিজস্ব সিকিউরিটি অ্যাপে এটি খুঁজুন।",
|
||||
"@alarmDiagnosticsAutostartBody": {
|
||||
"placeholders": {
|
||||
"manufacturer": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"alarmDiagnosticsFixAction": "সমাধান করুন",
|
||||
"alarmDiagnosticsIntentUnavailable": "এই ফোনে সেই সেটিংস স্ক্রিনটি খোলা যায়নি। সেটিংসে ম্যানুয়ালি খুঁজে দেখার চেষ্টা করুন।",
|
||||
"alarmDiagnosticsUnavailableHint": "আমরা এখনও আপনার অ্যালার্ম সেটিংস পরীক্ষা করতে পারিনি।",
|
||||
"autoEqDisableOption": "বন্ধ করুন"
|
||||
}
|
||||
|
||||
+69
-2
@@ -604,7 +604,27 @@
|
||||
"endLabel": "Ende",
|
||||
"equalizerDisable": "Equalizer deaktivieren",
|
||||
"helpTitle": "Hilfe und Tutorial",
|
||||
"helpSubtitle": "Funktionen, Tipps und Neuigkeiten von PluriWave ansehen.",
|
||||
"helpSubtitle": "9 Bildschirme · jederzeit erneut ansehen",
|
||||
"tutorialSkipAction": "Überspringen",
|
||||
"tutorialNextAction": "Weiter",
|
||||
"tutorialPage1Headline": "Speichere deine Sender und gruppiere sie",
|
||||
"tutorialPage1Body": "Tippe auf das Herz, um einen Sender zu speichern. Unter „Deine Sender“ kannst du Gruppen wie „Jeden Morgen“ oder „Auto“ anlegen und sie per Ziehen neu anordnen.",
|
||||
"tutorialPage2Headline": "Ein Basis-Equalizer und einer pro Sender",
|
||||
"tutorialPage2Body": "In den Einstellungen legst du den allgemeinen Equalizer fest. Und während der Wiedergabe eines bestimmten Senders kannst du ihm eine eigene Einstellung geben, die Vorrang vor der allgemeinen hat.",
|
||||
"tutorialPage3Headline": "Nimm auf, was du gerade hörst",
|
||||
"tutorialPage3Body": "Über die Werkzeugleiste des Players speichert „Aufnehmen“ den Original-Stream. Deine Aufnahmen findest du unter Einstellungen › Aufnahmen.",
|
||||
"tutorialPage4Headline": "Alarme, die sich dir anpassen",
|
||||
"tutorialPage4Body": "Wach mit deinem Lieblingssender auf, schlummere 3, 5 oder 10 Minuten weiter und füge Urlaubszeiträume hinzu, damit manche Alarme von selbst ausgesetzt werden.",
|
||||
"tutorialPage5Headline": "Deine Favoriten, auch im Auto",
|
||||
"tutorialPage5Body": "Verbinde dein Handy mit Android Auto und finde Favoriten, Alle Sender, Deine Sender und deine lokale Musik, mit großen, fürs Fahren gemachten Tasten.",
|
||||
"tutorialPage6Headline": "Verbindet sich von selbst neu",
|
||||
"tutorialPage6Body": "Bricht das Signal ab, versucht PluriWave automatisch erneut und zeigt weiterhin deine gespeicherten Favoriten, auch ohne Verbindung.",
|
||||
"tutorialPage7Headline": "Wähle, wie lange du schlummerst",
|
||||
"tutorialPage7Body": "Wenn ein Alarm klingelt, gibt es nicht nur ein „Schlummern“: Du wählst 3, 5 oder 10 Minuten, je nachdem, was du gerade brauchst.",
|
||||
"tutorialPage8Headline": "Findest du ihn nicht? Füge ihn selbst hinzu",
|
||||
"tutorialPage8Body": "Füge unter „Deine Sender“ → Eigenen Sender hinzufügen die Stream-URL eines Senders ein, der nicht in der Suche auftaucht. Er wird unter „Deine Sender“ gespeichert und ist auch im Auto verfügbar.",
|
||||
"tutorialPage9Headline": "Fertig, du kennst jetzt das Wichtigste",
|
||||
"tutorialPage9BannerBody": "Um dieses Tutorial jederzeit erneut anzusehen: Einstellungen → Info → Hilfe und Tutorial.",
|
||||
"indefiniteOption": "Unbegrenzt",
|
||||
"invalidNumber": "Ungültige Zahl",
|
||||
"nameLabel": "Name",
|
||||
@@ -830,5 +850,52 @@
|
||||
"welcomeHeadline": "Deine Welt, live",
|
||||
"yourStationsTitle": "Deine Sender",
|
||||
"nowListeningLabel": "Läuft gerade",
|
||||
"popularNowTitle": "Jetzt beliebt"
|
||||
"popularNowTitle": "Jetzt beliebt",
|
||||
"eqCustomActionEnableLabel": "Equalizer aktivieren",
|
||||
"eqCustomActionDisableLabel": "Equalizer deaktivieren",
|
||||
"eqCustomActionPresetLabel": "Voreinstellung: {preset}",
|
||||
"@eqCustomActionPresetLabel": {
|
||||
"placeholders": {
|
||||
"preset": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"alarmCardVacationPausedBadge": "Wegen Urlaub pausiert",
|
||||
"alarmCardSchedulingFailedMessage": "Dieser Alarm konnte nicht im System registriert werden, daher klingelt er möglicherweise nicht.",
|
||||
"alarmCardPreNoticeFailedMessage": "Dieser Alarm ist geplant, aber seine Vorwarnung konnte nicht eingerichtet werden.",
|
||||
"alarmDiagnosticsExactAlarmsTitle": "Genaue Alarmplanung",
|
||||
"alarmDiagnosticsExactAlarmsHint": "Lässt den Alarm genau zur eingestellten Minute klingeln, auch wenn das Telefon im Ruhezustand ist.",
|
||||
"alarmDiagnosticsNotificationsTitle": "Benachrichtigungen",
|
||||
"alarmDiagnosticsNotificationsHint": "Nötig, um den Alarm und den Vorab-Hinweis anzuzeigen.",
|
||||
"alarmDiagnosticsFullScreenTitle": "Alarmanzeige im Vollbildmodus",
|
||||
"alarmDiagnosticsFullScreenHint": "Lässt den Klingelbildschirm automatisch erscheinen, auch wenn das Telefon gesperrt ist.",
|
||||
"alarmDiagnosticsBatteryTitle": "Akku-Optimierung",
|
||||
"alarmDiagnosticsBatteryHint": "Verhindert, dass das System PluriWave im Hintergrund beendet, damit der Alarm trotzdem klingeln kann.",
|
||||
"alarmDiagnosticsNativeCountTitle": "Bei Android registrierte Alarme",
|
||||
"alarmDiagnosticsNativeCountValue": "Aktuell registriert: {count}",
|
||||
"@alarmDiagnosticsNativeCountValue": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"alarmDiagnosticsNativeCountAttentionHint": "Du hast einen Alarm aktiviert, aber es ist noch keiner beim System registriert. Öffne PluriWave erneut oder behebe zuerst die Punkte oben.",
|
||||
"alarmDiagnosticsManufacturerLabel": "Hersteller",
|
||||
"alarmDiagnosticsSdkLabel": "Android-Version (SDK)",
|
||||
"alarmDiagnosticsNeedsAttentionStatus": "Erfordert Aufmerksamkeit",
|
||||
"alarmDiagnosticsAutostartTitle": "Noch ein manueller Schritt auf diesem Telefon",
|
||||
"alarmDiagnosticsAutostartBody": "{manufacturer}-Telefone schließen oft Apps im Hintergrund, um Akku zu sparen. Es gibt keine Einstellung, die PluriWave selbst aktivieren kann — du musst den Autostart (manchmal auch \"Auto-Start\" oder \"Hintergrundaktivität\" genannt) für PluriWave selbst einschalten. Schau in den Einstellungen unter Apps oder Akku, oder in der Sicherheits-App des Telefons nach.",
|
||||
"@alarmDiagnosticsAutostartBody": {
|
||||
"placeholders": {
|
||||
"manufacturer": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"alarmDiagnosticsFixAction": "Beheben",
|
||||
"alarmDiagnosticsIntentUnavailable": "Diese Einstellungsseite konnte auf diesem Telefon nicht geöffnet werden. Suche sie manuell in den Einstellungen.",
|
||||
"alarmDiagnosticsUnavailableHint": "Wir konnten deine Alarmeinstellungen noch nicht prüfen.",
|
||||
"autoEqDisableOption": "Deaktivieren"
|
||||
}
|
||||
|
||||
+69
-2
@@ -716,7 +716,27 @@
|
||||
"endLabel": "End",
|
||||
"equalizerDisable": "Disable equalizer",
|
||||
"helpTitle": "Help and tutorial",
|
||||
"helpSubtitle": "Review PluriWave features, tips and what’s new.",
|
||||
"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",
|
||||
@@ -830,5 +850,52 @@
|
||||
"welcomeBullet2Subtitle": "Your favorites and local music in the car",
|
||||
"welcomeBullet3Title": "Music alarms",
|
||||
"welcomeBullet3Subtitle": "With gradual volume rise and vacation mode",
|
||||
"welcomeCtaLabel": "Start listening"
|
||||
"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"
|
||||
}
|
||||
|
||||
+69
-2
@@ -672,7 +672,27 @@
|
||||
"endLabel": "Fin",
|
||||
"equalizerDisable": "Desactivar ecualizador",
|
||||
"helpTitle": "Ayuda y tutorial",
|
||||
"helpSubtitle": "Repasá funciones, consejos y novedades de PluriWave.",
|
||||
"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",
|
||||
@@ -789,5 +809,52 @@
|
||||
"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"
|
||||
"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"
|
||||
}
|
||||
|
||||
+69
-2
@@ -604,7 +604,27 @@
|
||||
"endLabel": "Fin",
|
||||
"equalizerDisable": "Désactiver l’égaliseur",
|
||||
"helpTitle": "Aide et tutoriel",
|
||||
"helpSubtitle": "Revoyez les fonctions, conseils et nouveautés de PluriWave.",
|
||||
"helpSubtitle": "9 écrans · à revoir quand vous voulez",
|
||||
"tutorialSkipAction": "Passer",
|
||||
"tutorialNextAction": "Suivant",
|
||||
"tutorialPage1Headline": "Enregistrez vos stations et regroupez-les",
|
||||
"tutorialPage1Body": "Touchez le cœur pour enregistrer une station. Dans « Vos stations », vous pouvez créer des groupes comme « Chaque matin » ou « Voiture » et les réorganiser en les faisant glisser.",
|
||||
"tutorialPage2Headline": "Un égaliseur général et un autre par station",
|
||||
"tutorialPage2Body": "Dans Réglages, vous définissez l'égaliseur général. Et depuis la lecture d'une station précise, vous pouvez lui donner son propre réglage, qui prévaut sur le général.",
|
||||
"tutorialPage3Headline": "Enregistrez ce que vous écoutez",
|
||||
"tutorialPage3Body": "Depuis la barre d'outils du lecteur, « Enregistrer » sauvegarde le flux original. Retrouvez vos enregistrements dans Réglages › Enregistrements.",
|
||||
"tutorialPage4Headline": "Des alarmes qui s'adaptent à vous",
|
||||
"tutorialPage4Body": "Réveillez-vous avec votre station préférée, reportez de 3, 5 ou 10 minutes, et ajoutez des périodes de vacances pour que certaines alarmes se sautent d'elles-mêmes.",
|
||||
"tutorialPage5Headline": "Vos favoris, aussi en voiture",
|
||||
"tutorialPage5Body": "Connectez votre téléphone avec Android Auto et retrouvez Favoris, Toutes les stations, Vos stations et votre Musique locale, avec de grands boutons pensés pour la conduite.",
|
||||
"tutorialPage6Headline": "Se reconnecte toute seule",
|
||||
"tutorialPage6Body": "Si le signal se coupe, PluriWave retente automatiquement et continue d'afficher vos favoris enregistrés même sans connexion.",
|
||||
"tutorialPage7Headline": "Choisissez combien de temps reporter",
|
||||
"tutorialPage7Body": "Quand une alarme sonne, il n'y a pas un seul « reporter » : vous choisissez 3, 5 ou 10 minutes selon ce dont vous avez besoin à ce moment-là.",
|
||||
"tutorialPage8Headline": "Vous ne la trouvez pas ? Ajoutez-la vous-même",
|
||||
"tutorialPage8Body": "Dans « Vos stations » → Ajouter une station personnalisée, collez l'URL du flux d'une station absente des résultats de recherche. Elle est enregistrée dans « Vos stations », aussi disponible en voiture.",
|
||||
"tutorialPage9Headline": "Voilà, vous connaissez l'essentiel",
|
||||
"tutorialPage9BannerBody": "Pour revoir ce tutoriel quand vous le souhaitez : Réglages → Informations → Aide et tutoriel.",
|
||||
"indefiniteOption": "Indéfinie",
|
||||
"invalidNumber": "Nombre invalide",
|
||||
"nameLabel": "Nom",
|
||||
@@ -830,5 +850,52 @@
|
||||
"welcomeHeadline": "Votre monde, en direct",
|
||||
"yourStationsTitle": "Vos stations",
|
||||
"nowListeningLabel": "En cours d'écoute",
|
||||
"popularNowTitle": "Populaire maintenant"
|
||||
"popularNowTitle": "Populaire maintenant",
|
||||
"eqCustomActionEnableLabel": "Activer l'égaliseur",
|
||||
"eqCustomActionDisableLabel": "Désactiver l'égaliseur",
|
||||
"eqCustomActionPresetLabel": "Préréglage : {preset}",
|
||||
"@eqCustomActionPresetLabel": {
|
||||
"placeholders": {
|
||||
"preset": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"alarmCardVacationPausedBadge": "En pause pour les vacances",
|
||||
"alarmCardSchedulingFailedMessage": "Cette alarme n'a pas pu être enregistrée dans le système ; elle risque donc de ne pas sonner.",
|
||||
"alarmCardPreNoticeFailedMessage": "Cette alarme est programmée, mais son rappel anticipé n'a pas pu être activé.",
|
||||
"alarmDiagnosticsExactAlarmsTitle": "Programmation d'alarme précise",
|
||||
"alarmDiagnosticsExactAlarmsHint": "Permet à l'alarme de sonner à la minute exacte choisie, même si le téléphone est en veille.",
|
||||
"alarmDiagnosticsNotificationsTitle": "Notifications",
|
||||
"alarmDiagnosticsNotificationsHint": "Nécessaires pour afficher l'alarme et l'avis anticipé.",
|
||||
"alarmDiagnosticsFullScreenTitle": "Affichage plein écran de l'alarme",
|
||||
"alarmDiagnosticsFullScreenHint": "Permet à l'écran de sonnerie de s'afficher automatiquement, même si le téléphone est verrouillé.",
|
||||
"alarmDiagnosticsBatteryTitle": "Optimisation de la batterie",
|
||||
"alarmDiagnosticsBatteryHint": "Empêche le système de fermer PluriWave en arrière-plan pour que l'alarme puisse quand même sonner.",
|
||||
"alarmDiagnosticsNativeCountTitle": "Alarmes enregistrées auprès d'Android",
|
||||
"alarmDiagnosticsNativeCountValue": "Actuellement enregistrées : {count}",
|
||||
"@alarmDiagnosticsNativeCountValue": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"alarmDiagnosticsNativeCountAttentionHint": "Une alarme est activée, mais aucune n'est encore enregistrée auprès du système. Rouvrez PluriWave, ou corrigez d'abord les points ci-dessus.",
|
||||
"alarmDiagnosticsManufacturerLabel": "Fabricant",
|
||||
"alarmDiagnosticsSdkLabel": "Version d'Android (SDK)",
|
||||
"alarmDiagnosticsNeedsAttentionStatus": "Nécessite votre attention",
|
||||
"alarmDiagnosticsAutostartTitle": "Encore une étape manuelle sur ce téléphone",
|
||||
"alarmDiagnosticsAutostartBody": "Les téléphones {manufacturer} ferment souvent les applications en arrière-plan pour économiser la batterie. Aucun réglage ne permet à PluriWave de s'activer lui-même : vous devez activer vous-même le démarrage automatique (parfois appelé \"Autostart\" ou \"Activité en arrière-plan\") pour PluriWave. Cherchez dans les Paramètres, sous Applications ou Batterie, ou dans l'application Sécurité du téléphone.",
|
||||
"@alarmDiagnosticsAutostartBody": {
|
||||
"placeholders": {
|
||||
"manufacturer": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"alarmDiagnosticsFixAction": "Corriger",
|
||||
"alarmDiagnosticsIntentUnavailable": "Impossible d'ouvrir cet écran de paramètres sur ce téléphone. Essayez de le trouver manuellement dans les Paramètres.",
|
||||
"alarmDiagnosticsUnavailableHint": "Nous n'avons pas encore pu vérifier vos paramètres d'alarme.",
|
||||
"autoEqDisableOption": "Désactiver"
|
||||
}
|
||||
|
||||
+69
-2
@@ -604,7 +604,27 @@
|
||||
"endLabel": "समाप्ति",
|
||||
"equalizerDisable": "इक्वलाइज़र बंद करें",
|
||||
"helpTitle": "मदद और ट्यूटोरियल",
|
||||
"helpSubtitle": "PluriWave की सुविधाएँ, सुझाव और नया क्या है देखें।",
|
||||
"helpSubtitle": "9 स्क्रीन · जब चाहें दोबारा देखें",
|
||||
"tutorialSkipAction": "छोड़ें",
|
||||
"tutorialNextAction": "अगला",
|
||||
"tutorialPage1Headline": "अपने स्टेशन सहेजें और उन्हें समूहों में बाँटें",
|
||||
"tutorialPage1Body": "किसी स्टेशन को सहेजने के लिए दिल के आइकॉन पर टैप करें। «आपके स्टेशन» में आप «हर सुबह» या «कार» जैसे समूह बना सकते हैं और उन्हें खींचकर पुनः क्रमबद्ध कर सकते हैं।",
|
||||
"tutorialPage2Headline": "एक सामान्य इक्वलाइज़र, और हर स्टेशन के लिए एक अलग",
|
||||
"tutorialPage2Body": "सेटिंग्स में आप सामान्य इक्वलाइज़र सेट करते हैं। और किसी खास स्टेशन को चलाते समय आप उसे अपनी खुद की सेटिंग दे सकते हैं, जो सामान्य सेटिंग पर प्राथमिकता रखती है।",
|
||||
"tutorialPage3Headline": "जो सुन रहे हैं उसे रिकॉर्ड करें",
|
||||
"tutorialPage3Body": "प्लेयर की टूल ट्रे से, «रिकॉर्ड करें» मूल स्ट्रीम को सहेजता है। अपनी रिकॉर्डिंग सेटिंग्स › रिकॉर्डिंग में पाएँ।",
|
||||
"tutorialPage4Headline": "अलार्म जो आपके अनुसार ढलते हैं",
|
||||
"tutorialPage4Body": "अपने पसंदीदा स्टेशन के साथ जागें, 3, 5 या 10 मिनट के लिए स्नूज़ करें, और छुट्टियों की अवधि जोड़ें ताकि कुछ अलार्म खुद-ब-खुद छूट जाएँ।",
|
||||
"tutorialPage5Headline": "आपके पसंदीदा, कार में भी",
|
||||
"tutorialPage5Body": "अपने फ़ोन को Android Auto से जोड़ें और पाएँ पसंदीदा, सभी स्टेशन, आपके स्टेशन और आपका लोकल म्यूज़िक, ड्राइविंग के लिए बने बड़े बटनों के साथ।",
|
||||
"tutorialPage6Headline": "खुद ही फिर से जुड़ जाता है",
|
||||
"tutorialPage6Body": "अगर सिग्नल कट जाए, तो PluriWave अपने आप फिर कोशिश करता है और बिना कनेक्शन के भी आपके सहेजे गए पसंदीदा दिखाता रहता है।",
|
||||
"tutorialPage7Headline": "चुनें कितनी देर के लिए स्नूज़ करना है",
|
||||
"tutorialPage7Body": "जब कोई अलार्म बजता है, तो «स्नूज़» का कोई एक ही विकल्प नहीं होता: आप उस पल की ज़रूरत के अनुसार 3, 5 या 10 मिनट चुनते हैं।",
|
||||
"tutorialPage8Headline": "नहीं मिल रहा? खुद जोड़ें",
|
||||
"tutorialPage8Body": "«आपके स्टेशन» → कस्टम स्टेशन जोड़ें में, खोज में न मिलने वाले स्टेशन का स्ट्रीम URL पेस्ट करें। यह «आपके स्टेशन» में सहेजा जाता है और कार में भी उपलब्ध रहता है।",
|
||||
"tutorialPage9Headline": "हो गया, अब आप ज़रूरी बातें जानते हैं",
|
||||
"tutorialPage9BannerBody": "इस ट्यूटोरियल को दोबारा देखने के लिए: सेटिंग्स → जानकारी → मदद और ट्यूटोरियल।",
|
||||
"indefiniteOption": "अनिश्चित",
|
||||
"invalidNumber": "अमान्य संख्या",
|
||||
"nameLabel": "नाम",
|
||||
@@ -830,5 +850,52 @@
|
||||
"welcomeHeadline": "आपकी दुनिया, लाइव",
|
||||
"yourStationsTitle": "आपके स्टेशन",
|
||||
"nowListeningLabel": "अभी सुन रहे हैं",
|
||||
"popularNowTitle": "अभी लोकप्रिय"
|
||||
"popularNowTitle": "अभी लोकप्रिय",
|
||||
"eqCustomActionEnableLabel": "इक्वलाइज़र चालू करें",
|
||||
"eqCustomActionDisableLabel": "इक्वलाइज़र बंद करें",
|
||||
"eqCustomActionPresetLabel": "प्रीसेट: {preset}",
|
||||
"@eqCustomActionPresetLabel": {
|
||||
"placeholders": {
|
||||
"preset": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"alarmCardVacationPausedBadge": "छुट्टी के कारण रोका गया",
|
||||
"alarmCardSchedulingFailedMessage": "यह अलार्म सिस्टम में दर्ज नहीं हो सका, इसलिए यह शायद न बजे।",
|
||||
"alarmCardPreNoticeFailedMessage": "यह अलार्म शेड्यूल किया गया है, लेकिन इसकी पूर्व-चेतावनी सेट नहीं हो सकी।",
|
||||
"alarmDiagnosticsExactAlarmsTitle": "सटीक अलार्म शेड्यूलिंग",
|
||||
"alarmDiagnosticsExactAlarmsHint": "फ़ोन के सुप्त मोड में होने पर भी अलार्म को ठीक तय किए गए मिनट पर बजने देता है।",
|
||||
"alarmDiagnosticsNotificationsTitle": "सूचनाएं",
|
||||
"alarmDiagnosticsNotificationsHint": "अलार्म और पूर्व-चेतावनी सूचना दिखाने के लिए ज़रूरी।",
|
||||
"alarmDiagnosticsFullScreenTitle": "अलार्म की फ़ुल-स्क्रीन डिस्प्ले",
|
||||
"alarmDiagnosticsFullScreenHint": "फ़ोन लॉक होने पर भी अलार्म स्क्रीन को अपने आप दिखने देता है।",
|
||||
"alarmDiagnosticsBatteryTitle": "बैटरी ऑप्टिमाइज़ेशन",
|
||||
"alarmDiagnosticsBatteryHint": "सिस्टम को बैकग्राउंड में PluriWave बंद करने से रोकता है, ताकि अलार्म फिर भी बज सके।",
|
||||
"alarmDiagnosticsNativeCountTitle": "Android में दर्ज अलार्म",
|
||||
"alarmDiagnosticsNativeCountValue": "अभी दर्ज: {count}",
|
||||
"@alarmDiagnosticsNativeCountValue": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"alarmDiagnosticsNativeCountAttentionHint": "आपने एक अलार्म चालू किया है, लेकिन अभी तक कोई भी सिस्टम में दर्ज नहीं हुआ है। PluriWave को दोबारा खोलें, या पहले ऊपर दी गई बातों को ठीक करें।",
|
||||
"alarmDiagnosticsManufacturerLabel": "निर्माता",
|
||||
"alarmDiagnosticsSdkLabel": "Android वर्शन (SDK)",
|
||||
"alarmDiagnosticsNeedsAttentionStatus": "ध्यान देने की ज़रूरत है",
|
||||
"alarmDiagnosticsAutostartTitle": "इस फ़ोन पर एक और मैन्युअल चरण",
|
||||
"alarmDiagnosticsAutostartBody": "{manufacturer} फ़ोन बैटरी बचाने के लिए अक्सर बैकग्राउंड में चल रहे ऐप बंद कर देते हैं। ऐसी कोई सेटिंग नहीं है जिसे PluriWave खुद चालू कर सके — आपको PluriWave के लिए खुद ऑटोस्टार्ट (कभी-कभी \"Auto-start\" या \"बैकग्राउंड एक्टिविटी\" भी कहा जाता है) चालू करना होगा। इसे सेटिंग्स में, ऐप्स या बैटरी के अंदर, या फ़ोन के अपने सिक्योरिटी ऐप में देखें।",
|
||||
"@alarmDiagnosticsAutostartBody": {
|
||||
"placeholders": {
|
||||
"manufacturer": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"alarmDiagnosticsFixAction": "ठीक करें",
|
||||
"alarmDiagnosticsIntentUnavailable": "इस फ़ोन पर वह सेटिंग्स स्क्रीन नहीं खोली जा सकी। कृपया सेटिंग्स में इसे खुद ढूंढने की कोशिश करें।",
|
||||
"alarmDiagnosticsUnavailableHint": "हम अभी तक आपकी अलार्म सेटिंग्स जांच नहीं पाए हैं।",
|
||||
"autoEqDisableOption": "बंद करें"
|
||||
}
|
||||
|
||||
+69
-2
@@ -604,7 +604,27 @@
|
||||
"endLabel": "Selesai",
|
||||
"equalizerDisable": "Nonaktifkan equalizer",
|
||||
"helpTitle": "Bantuan dan tutorial",
|
||||
"helpSubtitle": "Tinjau fitur, tips, dan hal baru di PluriWave.",
|
||||
"helpSubtitle": "9 layar · lihat lagi kapan saja",
|
||||
"tutorialSkipAction": "Lewati",
|
||||
"tutorialNextAction": "Berikutnya",
|
||||
"tutorialPage1Headline": "Simpan stasiun Anda dan kelompokkan",
|
||||
"tutorialPage1Body": "Ketuk ikon hati untuk menyimpan stasiun. Di «Stasiun Anda» Anda bisa membuat grup seperti «Setiap pagi» atau «Mobil» dan mengurutkannya ulang dengan menyeret.",
|
||||
"tutorialPage2Headline": "Satu equalizer umum, dan satu lagi per stasiun",
|
||||
"tutorialPage2Body": "Di Pengaturan, Anda menentukan equalizer umum. Dan dari pemutaran stasiun tertentu, Anda bisa memberinya pengaturan sendiri, yang lebih diutamakan daripada pengaturan umum.",
|
||||
"tutorialPage3Headline": "Rekam apa yang sedang Anda dengarkan",
|
||||
"tutorialPage3Body": "Dari baki alat pemutar, «Rekam» menyimpan stream asli. Temukan rekaman Anda di Pengaturan › Rekaman.",
|
||||
"tutorialPage4Headline": "Alarm yang menyesuaikan diri dengan Anda",
|
||||
"tutorialPage4Body": "Bangun dengan stasiun favorit Anda, tunda 3, 5, atau 10 menit, dan tambahkan rentang liburan agar sebagian alarm melewati dirinya sendiri.",
|
||||
"tutorialPage5Headline": "Favorit Anda, juga di mobil",
|
||||
"tutorialPage5Body": "Sambungkan ponsel Anda dengan Android Auto dan temukan Favorit, Semua stasiun, Stasiun Anda, dan Musik Lokal Anda, dengan tombol besar yang dirancang untuk berkendara.",
|
||||
"tutorialPage6Headline": "Tersambung ulang dengan sendirinya",
|
||||
"tutorialPage6Body": "Jika sinyal terputus, PluriWave mencoba lagi secara otomatis dan tetap menampilkan favorit tersimpan Anda meski tanpa koneksi.",
|
||||
"tutorialPage7Headline": "Pilih berapa lama menunda",
|
||||
"tutorialPage7Body": "Saat alarm berbunyi, tidak ada satu «tunda» saja: Anda memilih 3, 5, atau 10 menit sesuai yang Anda butuhkan saat itu.",
|
||||
"tutorialPage8Headline": "Tidak menemukannya? Tambahkan sendiri",
|
||||
"tutorialPage8Body": "Di «Stasiun Anda» → Tambah stasiun khusus, tempel URL stream stasiun yang tidak ada di hasil pencarian. Stasiun ini disimpan di «Stasiun Anda», juga tersedia di mobil.",
|
||||
"tutorialPage9Headline": "Selesai, Anda sudah tahu yang penting",
|
||||
"tutorialPage9BannerBody": "Untuk melihat tutorial ini lagi kapan saja: Pengaturan → Informasi → Bantuan dan tutorial.",
|
||||
"indefiniteOption": "Tidak terbatas",
|
||||
"invalidNumber": "Nomor tidak valid",
|
||||
"nameLabel": "Nama",
|
||||
@@ -830,5 +850,52 @@
|
||||
"welcomeHeadline": "Duniamu, secara langsung",
|
||||
"yourStationsTitle": "Stasiun Anda",
|
||||
"nowListeningLabel": "Sedang mendengarkan",
|
||||
"popularNowTitle": "Populer sekarang"
|
||||
"popularNowTitle": "Populer sekarang",
|
||||
"eqCustomActionEnableLabel": "Aktifkan equalizer",
|
||||
"eqCustomActionDisableLabel": "Nonaktifkan equalizer",
|
||||
"eqCustomActionPresetLabel": "Prasetel: {preset}",
|
||||
"@eqCustomActionPresetLabel": {
|
||||
"placeholders": {
|
||||
"preset": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"alarmCardVacationPausedBadge": "Dijeda karena liburan",
|
||||
"alarmCardSchedulingFailedMessage": "Alarm ini tidak dapat didaftarkan ke sistem, sehingga mungkin tidak berbunyi.",
|
||||
"alarmCardPreNoticeFailedMessage": "Alarm ini sudah dijadwalkan, tetapi pengingat awalnya tidak dapat diatur.",
|
||||
"alarmDiagnosticsExactAlarmsTitle": "Penjadwalan alarm presisi",
|
||||
"alarmDiagnosticsExactAlarmsHint": "Membuat alarm berbunyi tepat pada menit yang kamu atur, meski ponsel dalam mode tidur.",
|
||||
"alarmDiagnosticsNotificationsTitle": "Notifikasi",
|
||||
"alarmDiagnosticsNotificationsHint": "Diperlukan untuk menampilkan alarm dan pemberitahuan dini.",
|
||||
"alarmDiagnosticsFullScreenTitle": "Tampilan alarm layar penuh",
|
||||
"alarmDiagnosticsFullScreenHint": "Membuat layar alarm muncul otomatis, meski ponsel terkunci.",
|
||||
"alarmDiagnosticsBatteryTitle": "Optimisasi baterai",
|
||||
"alarmDiagnosticsBatteryHint": "Mencegah sistem menutup PluriWave di latar belakang, sehingga alarm tetap bisa berbunyi.",
|
||||
"alarmDiagnosticsNativeCountTitle": "Alarm yang terdaftar di Android",
|
||||
"alarmDiagnosticsNativeCountValue": "Terdaftar saat ini: {count}",
|
||||
"@alarmDiagnosticsNativeCountValue": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"alarmDiagnosticsNativeCountAttentionHint": "Kamu punya alarm yang aktif, tapi belum ada yang terdaftar di sistem. Buka lagi PluriWave, atau perbaiki dulu poin-poin di atas.",
|
||||
"alarmDiagnosticsManufacturerLabel": "Produsen",
|
||||
"alarmDiagnosticsSdkLabel": "Versi Android (SDK)",
|
||||
"alarmDiagnosticsNeedsAttentionStatus": "Perlu perhatian",
|
||||
"alarmDiagnosticsAutostartTitle": "Satu langkah manual lagi di ponsel ini",
|
||||
"alarmDiagnosticsAutostartBody": "Ponsel {manufacturer} sering menutup aplikasi yang berjalan di latar belakang untuk menghemat baterai. Tidak ada pengaturan yang bisa diaktifkan PluriWave sendiri — kamu perlu mengaktifkan sendiri Autostart (kadang disebut \"Mulai otomatis\" atau \"Aktivitas latar belakang\") untuk PluriWave. Cari di Pengaturan, di bagian Aplikasi atau Baterai, atau di aplikasi Keamanan bawaan ponsel.",
|
||||
"@alarmDiagnosticsAutostartBody": {
|
||||
"placeholders": {
|
||||
"manufacturer": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"alarmDiagnosticsFixAction": "Perbaiki",
|
||||
"alarmDiagnosticsIntentUnavailable": "Tidak bisa membuka layar pengaturan itu di ponsel ini. Coba cari secara manual di Pengaturan.",
|
||||
"alarmDiagnosticsUnavailableHint": "Kami belum bisa memeriksa pengaturan alarmmu.",
|
||||
"autoEqDisableOption": "Nonaktifkan"
|
||||
}
|
||||
|
||||
+69
-2
@@ -604,7 +604,27 @@
|
||||
"endLabel": "Fine",
|
||||
"equalizerDisable": "Disattiva equalizzatore",
|
||||
"helpTitle": "Aiuto e tutorial",
|
||||
"helpSubtitle": "Rivedi funzioni, consigli e novità di PluriWave.",
|
||||
"helpSubtitle": "9 schermate · rivedilo quando vuoi",
|
||||
"tutorialSkipAction": "Salta",
|
||||
"tutorialNextAction": "Avanti",
|
||||
"tutorialPage1Headline": "Salva le tue emittenti e raggruppale",
|
||||
"tutorialPage1Body": "Tocca il cuore per salvare un'emittente. In «Le tue emittenti» puoi creare gruppi come «Ogni mattina» o «Auto» e riordinarle trascinandole.",
|
||||
"tutorialPage2Headline": "Un equalizzatore di base e uno per emittente",
|
||||
"tutorialPage2Body": "Nelle Impostazioni definisci l'equalizzatore generale. E dalla riproduzione di un'emittente specifica puoi darle un'impostazione propria, che ha la priorità su quella generale.",
|
||||
"tutorialPage3Headline": "Registra ciò che stai ascoltando",
|
||||
"tutorialPage3Body": "Dal vassoio degli strumenti del player, «Registra» salva lo stream originale. Trova le tue registrazioni in Impostazioni › Registrazioni.",
|
||||
"tutorialPage4Headline": "Sveglie che si adattano a te",
|
||||
"tutorialPage4Body": "Svegliati con la tua emittente preferita, posticipa di 3, 5 o 10 minuti, e aggiungi intervalli di vacanza in modo che alcune sveglie si saltino da sole.",
|
||||
"tutorialPage5Headline": "I tuoi preferiti, anche in auto",
|
||||
"tutorialPage5Body": "Collega il telefono con Android Auto e troverai Preferiti, Tutte le emittenti, Le tue emittenti e la tua Musica locale, con pulsanti grandi pensati per la guida.",
|
||||
"tutorialPage6Headline": "Si riconnette da sola",
|
||||
"tutorialPage6Body": "Se il segnale cade, PluriWave riprova automaticamente e continua a mostrare i tuoi preferiti salvati anche senza connessione.",
|
||||
"tutorialPage7Headline": "Scegli quanto posticipare",
|
||||
"tutorialPage7Body": "Quando suona una sveglia, non c'è un solo «posticipa»: scegli 3, 5 o 10 minuti a seconda di cosa ti serve in quel momento.",
|
||||
"tutorialPage8Headline": "Non la trovi? Aggiungila tu",
|
||||
"tutorialPage8Body": "In «Le tue emittenti» → Aggiungi emittente personalizzata incolla l'URL dello stream di un'emittente non presente nella ricerca. Viene salvata in «Le tue emittenti», disponibile anche in auto.",
|
||||
"tutorialPage9Headline": "Fatto, ora conosci l'essenziale",
|
||||
"tutorialPage9BannerBody": "Per rivedere questo tutorial quando vuoi: Impostazioni → Informazioni → Aiuto e tutorial.",
|
||||
"indefiniteOption": "Indefinita",
|
||||
"invalidNumber": "Numero non valido",
|
||||
"nameLabel": "Nome",
|
||||
@@ -830,5 +850,52 @@
|
||||
"welcomeHeadline": "Il tuo mondo, in diretta",
|
||||
"yourStationsTitle": "Le tue emittenti",
|
||||
"nowListeningLabel": "In ascolto ora",
|
||||
"popularNowTitle": "Popolari ora"
|
||||
"popularNowTitle": "Popolari ora",
|
||||
"eqCustomActionEnableLabel": "Attiva equalizzatore",
|
||||
"eqCustomActionDisableLabel": "Disattiva equalizzatore",
|
||||
"eqCustomActionPresetLabel": "Preset attivo: {preset}",
|
||||
"@eqCustomActionPresetLabel": {
|
||||
"placeholders": {
|
||||
"preset": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"alarmCardVacationPausedBadge": "In pausa per le vacanze",
|
||||
"alarmCardSchedulingFailedMessage": "Questa sveglia non è stata registrata nel sistema, quindi potrebbe non suonare.",
|
||||
"alarmCardPreNoticeFailedMessage": "Questa sveglia è programmata, ma non è stato possibile impostare il promemoria anticipato.",
|
||||
"alarmDiagnosticsExactAlarmsTitle": "Programmazione sveglia esatta",
|
||||
"alarmDiagnosticsExactAlarmsHint": "Permette alla sveglia di suonare esattamente al minuto impostato, anche a telefono in stand-by.",
|
||||
"alarmDiagnosticsNotificationsTitle": "Notifiche",
|
||||
"alarmDiagnosticsNotificationsHint": "Necessarie per mostrare la sveglia e l'avviso anticipato.",
|
||||
"alarmDiagnosticsFullScreenTitle": "Visualizzazione a schermo intero della sveglia",
|
||||
"alarmDiagnosticsFullScreenHint": "Permette alla schermata della sveglia di apparire automaticamente, anche a telefono bloccato.",
|
||||
"alarmDiagnosticsBatteryTitle": "Ottimizzazione della batteria",
|
||||
"alarmDiagnosticsBatteryHint": "Impedisce al sistema di chiudere PluriWave in background, così la sveglia può comunque suonare.",
|
||||
"alarmDiagnosticsNativeCountTitle": "Sveglie registrate su Android",
|
||||
"alarmDiagnosticsNativeCountValue": "Attualmente registrate: {count}",
|
||||
"@alarmDiagnosticsNativeCountValue": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"alarmDiagnosticsNativeCountAttentionHint": "Hai una sveglia attiva, ma nessuna è ancora registrata nel sistema. Riapri PluriWave, oppure risolvi prima i punti sopra.",
|
||||
"alarmDiagnosticsManufacturerLabel": "Produttore",
|
||||
"alarmDiagnosticsSdkLabel": "Versione di Android (SDK)",
|
||||
"alarmDiagnosticsNeedsAttentionStatus": "Richiede attenzione",
|
||||
"alarmDiagnosticsAutostartTitle": "Un altro passaggio manuale su questo telefono",
|
||||
"alarmDiagnosticsAutostartBody": "I telefoni {manufacturer} spesso chiudono le app in background per risparmiare batteria. Non esiste un'impostazione che PluriWave possa attivare da solo: devi attivare tu stesso l'Avvio automatico (a volte chiamato \"Autostart\" o \"Attività in background\") per PluriWave. Cercalo nelle Impostazioni, sotto App o Batteria, oppure nell'app Sicurezza del telefono.",
|
||||
"@alarmDiagnosticsAutostartBody": {
|
||||
"placeholders": {
|
||||
"manufacturer": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"alarmDiagnosticsFixAction": "Risolvi",
|
||||
"alarmDiagnosticsIntentUnavailable": "Non è stato possibile aprire questa schermata delle impostazioni su questo telefono. Prova a cercarla manualmente nelle Impostazioni.",
|
||||
"alarmDiagnosticsUnavailableHint": "Non abbiamo ancora potuto controllare le impostazioni della sveglia.",
|
||||
"autoEqDisableOption": "Disattiva"
|
||||
}
|
||||
|
||||
+69
-2
@@ -604,7 +604,27 @@
|
||||
"endLabel": "終了",
|
||||
"equalizerDisable": "イコライザーを無効化",
|
||||
"helpTitle": "ヘルプとチュートリアル",
|
||||
"helpSubtitle": "PluriWaveの機能、ヒント、新着情報を確認できます。",
|
||||
"helpSubtitle": "全9画面・いつでも見返せます",
|
||||
"tutorialSkipAction": "スキップ",
|
||||
"tutorialNextAction": "次へ",
|
||||
"tutorialPage1Headline": "お気に入りの局を保存してグループ分け",
|
||||
"tutorialPage1Body": "ハートをタップして局を保存しましょう。「あなたの局」では「毎朝」や「車」などのグループを作り、ドラッグして並べ替えられます。",
|
||||
"tutorialPage2Headline": "基本のイコライザーと局ごとのイコライザー",
|
||||
"tutorialPage2Body": "設定で全体のイコライザーを決められます。特定の局を再生中は、その局専用の設定を適用でき、全体設定より優先されます。",
|
||||
"tutorialPage3Headline": "聴いている番組を録音",
|
||||
"tutorialPage3Body": "プレーヤーのツールトレイから「録音」をタップすると、元のストリームが保存されます。録音は設定›録音で確認できます。",
|
||||
"tutorialPage4Headline": "あなたに合わせてくれるアラーム",
|
||||
"tutorialPage4Body": "お気に入りの局で目覚め、3分・5分・10分でスヌーズでき、休暇期間を追加すれば一部のアラームを自動でスキップできます。",
|
||||
"tutorialPage5Headline": "お気に入りは車でも",
|
||||
"tutorialPage5Body": "スマートフォンをAndroid Autoに接続すると、お気に入り、すべての局、あなたの局、ローカルミュージックが、運転向けの大きなボタンで使えます。",
|
||||
"tutorialPage6Headline": "自動で再接続",
|
||||
"tutorialPage6Body": "電波が途切れても、PluriWaveは自動的に再試行し、接続がなくても保存済みのお気に入りを表示し続けます。",
|
||||
"tutorialPage7Headline": "スヌーズ時間を選べる",
|
||||
"tutorialPage7Body": "アラームが鳴ったとき、「スヌーズ」は一つだけではありません。3分・5分・10分から、その時必要な時間を選べます。",
|
||||
"tutorialPage8Headline": "見つからない?自分で追加",
|
||||
"tutorialPage8Body": "「あなたの局」→カスタム局を追加で、検索にない局のストリームURLを貼り付けられます。「あなたの局」に保存され、車でも利用できます。",
|
||||
"tutorialPage9Headline": "完了、基本はこれで押さえました",
|
||||
"tutorialPage9BannerBody": "このチュートリアルはいつでも再確認できます:設定 → 情報 → ヘルプとチュートリアル。",
|
||||
"indefiniteOption": "無期限",
|
||||
"invalidNumber": "無効な数値",
|
||||
"nameLabel": "名前",
|
||||
@@ -830,5 +850,52 @@
|
||||
"welcomeHeadline": "あなたの世界を、ライブで",
|
||||
"yourStationsTitle": "あなたの局",
|
||||
"nowListeningLabel": "再生中",
|
||||
"popularNowTitle": "今人気"
|
||||
"popularNowTitle": "今人気",
|
||||
"eqCustomActionEnableLabel": "イコライザーをオンにする",
|
||||
"eqCustomActionDisableLabel": "イコライザーをオフにする",
|
||||
"eqCustomActionPresetLabel": "プリセット: {preset}",
|
||||
"@eqCustomActionPresetLabel": {
|
||||
"placeholders": {
|
||||
"preset": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"alarmCardVacationPausedBadge": "休暇のため一時停止中",
|
||||
"alarmCardSchedulingFailedMessage": "このアラームはシステムに登録できなかったため、鳴らない可能性があります。",
|
||||
"alarmCardPreNoticeFailedMessage": "このアラームは設定されていますが、事前通知を設定できませんでした。",
|
||||
"alarmDiagnosticsExactAlarmsTitle": "正確なアラームのスケジュール設定",
|
||||
"alarmDiagnosticsExactAlarmsHint": "スマートフォンがスリープ中でも、設定した時刻ちょうどにアラームを鳴らせるようにします。",
|
||||
"alarmDiagnosticsNotificationsTitle": "通知",
|
||||
"alarmDiagnosticsNotificationsHint": "アラームと事前通知を表示するために必要です。",
|
||||
"alarmDiagnosticsFullScreenTitle": "アラームのフルスクリーン表示",
|
||||
"alarmDiagnosticsFullScreenHint": "画面がロックされていても、アラーム画面が自動的に表示されるようにします。",
|
||||
"alarmDiagnosticsBatteryTitle": "バッテリーの最適化",
|
||||
"alarmDiagnosticsBatteryHint": "システムがPluriWaveをバックグラウンドで終了しないようにし、アラームが確実に鳴るようにします。",
|
||||
"alarmDiagnosticsNativeCountTitle": "Androidに登録されているアラーム",
|
||||
"alarmDiagnosticsNativeCountValue": "現在登録されている数: {count}",
|
||||
"@alarmDiagnosticsNativeCountValue": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"alarmDiagnosticsNativeCountAttentionHint": "アラームは有効になっていますが、まだシステムに登録されていません。PluriWaveを開き直すか、まず上の項目を確認してください。",
|
||||
"alarmDiagnosticsManufacturerLabel": "製造元",
|
||||
"alarmDiagnosticsSdkLabel": "Androidのバージョン(SDK)",
|
||||
"alarmDiagnosticsNeedsAttentionStatus": "確認が必要です",
|
||||
"alarmDiagnosticsAutostartTitle": "この端末でのもう一つの手動設定",
|
||||
"alarmDiagnosticsAutostartBody": "{manufacturer}のスマートフォンは、バッテリーを節約するためにバックグラウンドのアプリを終了させることがよくあります。PluriWaveが自動でオンにできる設定はありません。PluriWaveの自動起動(「オートスタート」や「バックグラウンド動作」と呼ばれることもあります)を、自分で有効にする必要があります。設定内のアプリまたはバッテリーの項目、あるいは端末のセキュリティアプリを確認してください。",
|
||||
"@alarmDiagnosticsAutostartBody": {
|
||||
"placeholders": {
|
||||
"manufacturer": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"alarmDiagnosticsFixAction": "修正する",
|
||||
"alarmDiagnosticsIntentUnavailable": "この端末では設定画面を開けませんでした。設定アプリ内で手動で探してみてください。",
|
||||
"alarmDiagnosticsUnavailableHint": "アラームの設定をまだ確認できていません。",
|
||||
"autoEqDisableOption": "無効化"
|
||||
}
|
||||
|
||||
+69
-2
@@ -604,7 +604,27 @@
|
||||
"endLabel": "Fim",
|
||||
"equalizerDisable": "Desativar equalizador",
|
||||
"helpTitle": "Ajuda e tutorial",
|
||||
"helpSubtitle": "Revê funções, dicas e novidades do PluriWave.",
|
||||
"helpSubtitle": "9 ecrãs · reveja quando quiser",
|
||||
"tutorialSkipAction": "Pular",
|
||||
"tutorialNextAction": "Avançar",
|
||||
"tutorialPage1Headline": "Guarde as suas estações e agrupe-as",
|
||||
"tutorialPage1Body": "Toque no coração para guardar uma estação. Em «Suas estações» pode criar grupos como «Todas as manhãs» ou «Carro» e reordená-las arrastando.",
|
||||
"tutorialPage2Headline": "Um equalizador geral e outro por estação",
|
||||
"tutorialPage2Body": "Em Definições define o equalizador geral. E, ao reproduzir uma estação específica, pode dar-lhe o seu próprio ajuste, que prevalece sobre o geral.",
|
||||
"tutorialPage3Headline": "Grave o que está a ouvir",
|
||||
"tutorialPage3Body": "Na bandeja de ferramentas do leitor, «Gravar» guarda o stream original. Encontre as suas gravações em Definições › Gravações.",
|
||||
"tutorialPage4Headline": "Alarmes que se adaptam a si",
|
||||
"tutorialPage4Body": "Acorde com a sua estação favorita, adie 3, 5 ou 10 minutos, e adicione períodos de férias para que alguns alarmes se saltem sozinhos.",
|
||||
"tutorialPage5Headline": "Os seus favoritos, também no carro",
|
||||
"tutorialPage5Body": "Ligue o telemóvel ao Android Auto e encontre Favoritos, Todas as estações, Suas estações e a sua Música local, com botões grandes pensados para conduzir.",
|
||||
"tutorialPage6Headline": "Reconecta-se sozinho",
|
||||
"tutorialPage6Body": "Se o sinal cair, o PluriWave tenta novamente de forma automática e continua a mostrar os seus favoritos guardados mesmo sem ligação.",
|
||||
"tutorialPage7Headline": "Escolha quanto tempo adiar",
|
||||
"tutorialPage7Body": "Quando um alarme toca, não existe um único «adiar»: escolhe 3, 5 ou 10 minutos conforme o que precisar nesse momento.",
|
||||
"tutorialPage8Headline": "Não a encontra? Adicione-a você mesmo",
|
||||
"tutorialPage8Body": "Em «Suas estações» → Adicionar estação personalizada, cole o URL do stream de uma estação que não esteja na pesquisa. Fica guardada em «Suas estações», também disponível no carro.",
|
||||
"tutorialPage9Headline": "Pronto, já conhece o essencial",
|
||||
"tutorialPage9BannerBody": "Para rever este tutorial quando quiser: Definições → Informação → Ajuda e tutorial.",
|
||||
"indefiniteOption": "Indefinida",
|
||||
"invalidNumber": "Número inválido",
|
||||
"nameLabel": "Nome",
|
||||
@@ -830,5 +850,52 @@
|
||||
"welcomeHeadline": "Seu mundo, ao vivo",
|
||||
"yourStationsTitle": "Suas estações",
|
||||
"nowListeningLabel": "Ouvindo agora",
|
||||
"popularNowTitle": "Populares agora"
|
||||
"popularNowTitle": "Populares agora",
|
||||
"eqCustomActionEnableLabel": "Ativar equalizador",
|
||||
"eqCustomActionDisableLabel": "Desativar equalizador",
|
||||
"eqCustomActionPresetLabel": "Predefinição: {preset}",
|
||||
"@eqCustomActionPresetLabel": {
|
||||
"placeholders": {
|
||||
"preset": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"alarmCardVacationPausedBadge": "Pausada por férias",
|
||||
"alarmCardSchedulingFailedMessage": "Este alarme não pôde ser registrado no sistema, por isso pode não tocar.",
|
||||
"alarmCardPreNoticeFailedMessage": "Este alarme está agendado, mas seu aviso antecipado não pôde ser configurado.",
|
||||
"alarmDiagnosticsExactAlarmsTitle": "Agendamento exato do alarme",
|
||||
"alarmDiagnosticsExactAlarmsHint": "Permite que o alarme toque no minuto exato definido, mesmo com o telefone em repouso.",
|
||||
"alarmDiagnosticsNotificationsTitle": "Notificações",
|
||||
"alarmDiagnosticsNotificationsHint": "Necessárias para mostrar o alarme e o aviso prévio.",
|
||||
"alarmDiagnosticsFullScreenTitle": "Exibição em tela cheia do alarme",
|
||||
"alarmDiagnosticsFullScreenHint": "Permite que a tela do alarme apareça automaticamente, mesmo com o telefone bloqueado.",
|
||||
"alarmDiagnosticsBatteryTitle": "Otimização de bateria",
|
||||
"alarmDiagnosticsBatteryHint": "Evita que o sistema feche o PluriWave em segundo plano, para que o alarme ainda possa tocar.",
|
||||
"alarmDiagnosticsNativeCountTitle": "Alarmes registrados no Android",
|
||||
"alarmDiagnosticsNativeCountValue": "Registrados agora: {count}",
|
||||
"@alarmDiagnosticsNativeCountValue": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"alarmDiagnosticsNativeCountAttentionHint": "Você tem um alarme ativado, mas nenhum está registrado no sistema ainda. Reabra o PluriWave ou resolva primeiro os itens acima.",
|
||||
"alarmDiagnosticsManufacturerLabel": "Fabricante",
|
||||
"alarmDiagnosticsSdkLabel": "Versão do Android (SDK)",
|
||||
"alarmDiagnosticsNeedsAttentionStatus": "Precisa de atenção",
|
||||
"alarmDiagnosticsAutostartTitle": "Mais uma etapa manual neste telefone",
|
||||
"alarmDiagnosticsAutostartBody": "Telefones {manufacturer} costumam fechar apps em segundo plano para economizar bateria. Não existe uma configuração que o PluriWave possa ativar sozinho: você precisa ativar por conta própria o Início automático (às vezes chamado de \"Autostart\" ou \"Atividade em segundo plano\") para o PluriWave. Procure em Configurações, em Apps ou Bateria, ou no próprio app de Segurança do telefone.",
|
||||
"@alarmDiagnosticsAutostartBody": {
|
||||
"placeholders": {
|
||||
"manufacturer": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"alarmDiagnosticsFixAction": "Resolver",
|
||||
"alarmDiagnosticsIntentUnavailable": "Não foi possível abrir essa tela de configurações neste telefone. Tente procurá-la manualmente nas Configurações.",
|
||||
"alarmDiagnosticsUnavailableHint": "Ainda não conseguimos verificar as configurações do seu alarme.",
|
||||
"autoEqDisableOption": "Desativar"
|
||||
}
|
||||
|
||||
+69
-2
@@ -604,7 +604,27 @@
|
||||
"endLabel": "Конец",
|
||||
"equalizerDisable": "Отключить эквалайзер",
|
||||
"helpTitle": "Помощь и руководство",
|
||||
"helpSubtitle": "Посмотрите функции, советы и новости PluriWave.",
|
||||
"helpSubtitle": "9 экранов · смотрите снова в любое время",
|
||||
"tutorialSkipAction": "Пропустить",
|
||||
"tutorialNextAction": "Далее",
|
||||
"tutorialPage1Headline": "Сохраняйте станции и группируйте их",
|
||||
"tutorialPage1Body": "Нажмите на сердечко, чтобы сохранить станцию. В разделе «Ваши станции» можно создавать группы, например «Каждое утро» или «Машина», и менять порядок перетаскиванием.",
|
||||
"tutorialPage2Headline": "Общий эквалайзер и отдельный для каждой станции",
|
||||
"tutorialPage2Body": "В настройках вы задаёте общий эквалайзер. А во время воспроизведения конкретной станции можно задать для неё собственную настройку — она будет иметь приоритет над общей.",
|
||||
"tutorialPage3Headline": "Записывайте то, что слушаете",
|
||||
"tutorialPage3Body": "На панели инструментов плеера кнопка «Запись» сохраняет исходный поток. Ваши записи хранятся в разделе Настройки › Записи.",
|
||||
"tutorialPage4Headline": "Будильники, которые подстраиваются под вас",
|
||||
"tutorialPage4Body": "Просыпайтесь под любимую станцию, откладывайте на 3, 5 или 10 минут и добавляйте периоды отпуска, чтобы некоторые будильники пропускались сами.",
|
||||
"tutorialPage5Headline": "Ваши избранные станции — и в машине тоже",
|
||||
"tutorialPage5Body": "Подключите телефон через Android Auto, и вы найдёте Избранное, Все станции, Ваши станции и вашу локальную музыку с крупными кнопками, удобными за рулём.",
|
||||
"tutorialPage6Headline": "Переподключается сам",
|
||||
"tutorialPage6Body": "Если сигнал пропадает, PluriWave автоматически повторяет попытку и продолжает показывать сохранённые избранные станции даже без подключения.",
|
||||
"tutorialPage7Headline": "Выбирайте, на сколько отложить",
|
||||
"tutorialPage7Body": "Когда звонит будильник, нет единственного варианта «отложить»: выбирайте 3, 5 или 10 минут — в зависимости от того, что нужно именно сейчас.",
|
||||
"tutorialPage8Headline": "Не нашли станцию? Добавьте её сами",
|
||||
"tutorialPage8Body": "В разделе «Ваши станции» → Добавить свою станцию вставьте URL потока станции, которой нет в поиске. Она сохранится в «Ваши станции» и будет доступна и в машине.",
|
||||
"tutorialPage9Headline": "Готово, теперь вы знаете самое важное",
|
||||
"tutorialPage9BannerBody": "Чтобы посмотреть этот урок ещё раз: Настройки → Информация → Помощь и руководство.",
|
||||
"indefiniteOption": "Без ограничения",
|
||||
"invalidNumber": "Недопустимое число",
|
||||
"nameLabel": "Название",
|
||||
@@ -830,5 +850,52 @@
|
||||
"welcomeHeadline": "Ваш мир, в прямом эфире",
|
||||
"yourStationsTitle": "Ваши станции",
|
||||
"nowListeningLabel": "Сейчас слушаете",
|
||||
"popularNowTitle": "Популярно сейчас"
|
||||
"popularNowTitle": "Популярно сейчас",
|
||||
"eqCustomActionEnableLabel": "Включить эквалайзер",
|
||||
"eqCustomActionDisableLabel": "Выключить эквалайзер",
|
||||
"eqCustomActionPresetLabel": "Пресет: {preset}",
|
||||
"@eqCustomActionPresetLabel": {
|
||||
"placeholders": {
|
||||
"preset": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"alarmCardVacationPausedBadge": "Приостановлено на время отпуска",
|
||||
"alarmCardSchedulingFailedMessage": "Этот будильник не удалось зарегистрировать в системе, поэтому он может не сработать.",
|
||||
"alarmCardPreNoticeFailedMessage": "Этот будильник запланирован, но не удалось настроить предварительное напоминание.",
|
||||
"alarmDiagnosticsExactAlarmsTitle": "Точное планирование будильника",
|
||||
"alarmDiagnosticsExactAlarmsHint": "Будильник звонит ровно в заданную минуту, даже если телефон находится в режиме сна.",
|
||||
"alarmDiagnosticsNotificationsTitle": "Уведомления",
|
||||
"alarmDiagnosticsNotificationsHint": "Нужны, чтобы показать будильник и заблаговременное напоминание.",
|
||||
"alarmDiagnosticsFullScreenTitle": "Полноэкранный показ будильника",
|
||||
"alarmDiagnosticsFullScreenHint": "Экран будильника появляется автоматически, даже если телефон заблокирован.",
|
||||
"alarmDiagnosticsBatteryTitle": "Оптимизация батареи",
|
||||
"alarmDiagnosticsBatteryHint": "Не позволяет системе закрывать PluriWave в фоновом режиме, чтобы будильник мог сработать.",
|
||||
"alarmDiagnosticsNativeCountTitle": "Будильники, зарегистрированные в Android",
|
||||
"alarmDiagnosticsNativeCountValue": "Сейчас зарегистрировано: {count}",
|
||||
"@alarmDiagnosticsNativeCountValue": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"alarmDiagnosticsNativeCountAttentionHint": "У вас включён будильник, но пока ни один не зарегистрирован в системе. Откройте PluriWave заново или сначала устраните пункты выше.",
|
||||
"alarmDiagnosticsManufacturerLabel": "Производитель",
|
||||
"alarmDiagnosticsSdkLabel": "Версия Android (SDK)",
|
||||
"alarmDiagnosticsNeedsAttentionStatus": "Требует внимания",
|
||||
"alarmDiagnosticsAutostartTitle": "Ещё один ручной шаг на этом телефоне",
|
||||
"alarmDiagnosticsAutostartBody": "Телефоны {manufacturer} часто закрывают приложения в фоновом режиме для экономии батареи. PluriWave не может включить это самостоятельно — вам нужно вручную включить автозапуск (иногда называется \"Autostart\" или \"Фоновая активность\") для PluriWave. Ищите в Настройках, в разделе Приложения или Батарея, либо в приложении безопасности телефона.",
|
||||
"@alarmDiagnosticsAutostartBody": {
|
||||
"placeholders": {
|
||||
"manufacturer": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"alarmDiagnosticsFixAction": "Исправить",
|
||||
"alarmDiagnosticsIntentUnavailable": "Не удалось открыть этот экран настроек на этом телефоне. Попробуйте найти его вручную в Настройках.",
|
||||
"alarmDiagnosticsUnavailableHint": "Мы пока не смогли проверить настройки вашего будильника.",
|
||||
"autoEqDisableOption": "Отключить"
|
||||
}
|
||||
|
||||
+69
-2
@@ -604,7 +604,27 @@
|
||||
"endLabel": "结束",
|
||||
"equalizerDisable": "关闭均衡器",
|
||||
"helpTitle": "帮助和教程",
|
||||
"helpSubtitle": "查看 PluriWave 的功能、技巧和新内容。",
|
||||
"helpSubtitle": "共9屏 · 随时可重新查看",
|
||||
"tutorialSkipAction": "跳过",
|
||||
"tutorialNextAction": "下一步",
|
||||
"tutorialPage1Headline": "保存并整理你的电台",
|
||||
"tutorialPage1Body": "点击心形图标即可保存电台。在“你的电台”中,你可以创建“每天早上”或“车载”等分组,并通过拖动重新排序。",
|
||||
"tutorialPage2Headline": "一个通用均衡器,外加每个电台专属的均衡器",
|
||||
"tutorialPage2Body": "在设置中可以调整通用均衡器。播放某个电台时,你还可以为它单独设置均衡器,该设置优先于通用设置。",
|
||||
"tutorialPage3Headline": "录制正在收听的内容",
|
||||
"tutorialPage3Body": "在播放器工具栏中点击“录制”,即可保存原始音频流。录音内容可在 设置 › 录音 中查看。",
|
||||
"tutorialPage4Headline": "会迁就你的闹钟",
|
||||
"tutorialPage4Body": "用喜欢的电台唤醒自己,可以推迟3分钟、5分钟或10分钟,还能添加假期时间段,让部分闹钟自动跳过。",
|
||||
"tutorialPage5Headline": "收藏的电台,车载也能用",
|
||||
"tutorialPage5Body": "将手机连接到Android Auto,即可看到收藏、全部电台、你的电台和本地音乐,大按钮专为驾驶设计。",
|
||||
"tutorialPage6Headline": "自动重新连接",
|
||||
"tutorialPage6Body": "信号中断时,PluriWave会自动重试连接,即使暂时没有网络也会继续显示已保存的收藏电台。",
|
||||
"tutorialPage7Headline": "自选推迟时长",
|
||||
"tutorialPage7Body": "闹钟响起时,“推迟”并非只有一种选择:你可以根据当下需要选择推迟3分钟、5分钟或10分钟。",
|
||||
"tutorialPage8Headline": "找不到?自己添加",
|
||||
"tutorialPage8Body": "在“你的电台”→添加自定义电台中,粘贴搜索结果里没有的电台的流媒体URL即可。它会保存在“你的电台”中,车载模式下同样可用。",
|
||||
"tutorialPage9Headline": "完成,你已经了解核心功能",
|
||||
"tutorialPage9BannerBody": "想随时重新观看本教程:设置 → 信息 → 帮助与教程。",
|
||||
"indefiniteOption": "不限时",
|
||||
"invalidNumber": "数字无效",
|
||||
"nameLabel": "名称",
|
||||
@@ -830,5 +850,52 @@
|
||||
"welcomeHeadline": "你的世界,直播中",
|
||||
"yourStationsTitle": "你的电台",
|
||||
"nowListeningLabel": "正在收听",
|
||||
"popularNowTitle": "当前热门"
|
||||
"popularNowTitle": "当前热门",
|
||||
"eqCustomActionEnableLabel": "启用均衡器",
|
||||
"eqCustomActionDisableLabel": "关闭均衡器",
|
||||
"eqCustomActionPresetLabel": "预设:{preset}",
|
||||
"@eqCustomActionPresetLabel": {
|
||||
"placeholders": {
|
||||
"preset": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"alarmCardVacationPausedBadge": "因假期已暂停",
|
||||
"alarmCardSchedulingFailedMessage": "该闹钟未能在系统中注册,因此可能不会响铃。",
|
||||
"alarmCardPreNoticeFailedMessage": "该闹钟已设置,但其提前提醒未能设置成功。",
|
||||
"alarmDiagnosticsExactAlarmsTitle": "精确闹钟排程",
|
||||
"alarmDiagnosticsExactAlarmsHint": "即使手机处于休眠状态,也能让闹钟在设定的准确时间响起。",
|
||||
"alarmDiagnosticsNotificationsTitle": "通知",
|
||||
"alarmDiagnosticsNotificationsHint": "显示闹钟和提前提醒需要用到。",
|
||||
"alarmDiagnosticsFullScreenTitle": "闹钟全屏显示",
|
||||
"alarmDiagnosticsFullScreenHint": "即使手机已锁屏,也能让响铃界面自动出现。",
|
||||
"alarmDiagnosticsBatteryTitle": "电池优化",
|
||||
"alarmDiagnosticsBatteryHint": "防止系统在后台关闭PluriWave,让闹钟仍然可以响起。",
|
||||
"alarmDiagnosticsNativeCountTitle": "已在Android系统注册的闹钟",
|
||||
"alarmDiagnosticsNativeCountValue": "当前已注册:{count}",
|
||||
"@alarmDiagnosticsNativeCountValue": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"alarmDiagnosticsNativeCountAttentionHint": "你已开启一个闹钟,但目前还没有闹钟在系统中注册。请重新打开PluriWave,或先解决上面列出的问题。",
|
||||
"alarmDiagnosticsManufacturerLabel": "制造商",
|
||||
"alarmDiagnosticsSdkLabel": "Android版本(SDK)",
|
||||
"alarmDiagnosticsNeedsAttentionStatus": "需要注意",
|
||||
"alarmDiagnosticsAutostartTitle": "此手机还需要一步手动设置",
|
||||
"alarmDiagnosticsAutostartBody": "{manufacturer}手机经常会关闭后台运行的应用以节省电量。没有任何设置可以让PluriWave自行开启——你需要自己为PluriWave开启自启动(有时也叫\"Autostart\"或\"后台活动\")。请在设置中查找应用或电池选项,或者查看手机自带的安全应用。",
|
||||
"@alarmDiagnosticsAutostartBody": {
|
||||
"placeholders": {
|
||||
"manufacturer": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"alarmDiagnosticsFixAction": "解决",
|
||||
"alarmDiagnosticsIntentUnavailable": "无法在此手机上打开该设置界面。请尝试在设置中手动查找。",
|
||||
"alarmDiagnosticsUnavailableHint": "我们还无法检查你的闹钟设置。",
|
||||
"autoEqDisableOption": "关闭"
|
||||
}
|
||||
|
||||
@@ -2567,9 +2567,129 @@ abstract class AppLocalizations {
|
||||
/// No description provided for @helpSubtitle.
|
||||
///
|
||||
/// In es, this message translates to:
|
||||
/// **'Repasá funciones, consejos y novedades de PluriWave.'**
|
||||
/// **'9 pantallas · vuelve a verlo cuando quieras'**
|
||||
String get helpSubtitle;
|
||||
|
||||
/// No description provided for @tutorialSkipAction.
|
||||
///
|
||||
/// In es, this message translates to:
|
||||
/// **'Saltar'**
|
||||
String get tutorialSkipAction;
|
||||
|
||||
/// No description provided for @tutorialNextAction.
|
||||
///
|
||||
/// In es, this message translates to:
|
||||
/// **'Siguiente'**
|
||||
String get tutorialNextAction;
|
||||
|
||||
/// No description provided for @tutorialPage1Headline.
|
||||
///
|
||||
/// In es, this message translates to:
|
||||
/// **'Guarda tus emisoras y agrúpalas'**
|
||||
String get tutorialPage1Headline;
|
||||
|
||||
/// No description provided for @tutorialPage1Body.
|
||||
///
|
||||
/// In es, this message translates to:
|
||||
/// **'Toca el corazón para guardar una emisora. En «Tus emisoras» puedes crear grupos como «Cada mañana» o «Coche» y reordenarlas arrastrando.'**
|
||||
String get tutorialPage1Body;
|
||||
|
||||
/// No description provided for @tutorialPage2Headline.
|
||||
///
|
||||
/// In es, this message translates to:
|
||||
/// **'Un ecualizador base y otro por emisora'**
|
||||
String get tutorialPage2Headline;
|
||||
|
||||
/// No description provided for @tutorialPage2Body.
|
||||
///
|
||||
/// In es, this message translates to:
|
||||
/// **'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.'**
|
||||
String get tutorialPage2Body;
|
||||
|
||||
/// No description provided for @tutorialPage3Headline.
|
||||
///
|
||||
/// In es, this message translates to:
|
||||
/// **'Graba lo que estás escuchando'**
|
||||
String get tutorialPage3Headline;
|
||||
|
||||
/// No description provided for @tutorialPage3Body.
|
||||
///
|
||||
/// In es, this message translates to:
|
||||
/// **'Desde la bandeja de herramientas del reproductor, «Grabar» guarda el stream original. Encuentra tus grabaciones en Ajustes › Grabaciones.'**
|
||||
String get tutorialPage3Body;
|
||||
|
||||
/// No description provided for @tutorialPage4Headline.
|
||||
///
|
||||
/// In es, this message translates to:
|
||||
/// **'Alarmas que se adaptan a ti'**
|
||||
String get tutorialPage4Headline;
|
||||
|
||||
/// No description provided for @tutorialPage4Body.
|
||||
///
|
||||
/// In es, this message translates to:
|
||||
/// **'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.'**
|
||||
String get tutorialPage4Body;
|
||||
|
||||
/// No description provided for @tutorialPage5Headline.
|
||||
///
|
||||
/// In es, this message translates to:
|
||||
/// **'Tus favoritas, también en el coche'**
|
||||
String get tutorialPage5Headline;
|
||||
|
||||
/// No description provided for @tutorialPage5Body.
|
||||
///
|
||||
/// In es, this message translates to:
|
||||
/// **'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.'**
|
||||
String get tutorialPage5Body;
|
||||
|
||||
/// No description provided for @tutorialPage6Headline.
|
||||
///
|
||||
/// In es, this message translates to:
|
||||
/// **'Se reconecta sola'**
|
||||
String get tutorialPage6Headline;
|
||||
|
||||
/// No description provided for @tutorialPage6Body.
|
||||
///
|
||||
/// In es, this message translates to:
|
||||
/// **'Si se corta la señal, PluriWave reintenta automáticamente y sigue mostrando tus favoritas guardadas aunque no tengas conexión.'**
|
||||
String get tutorialPage6Body;
|
||||
|
||||
/// No description provided for @tutorialPage7Headline.
|
||||
///
|
||||
/// In es, this message translates to:
|
||||
/// **'Elige cuánto posponer'**
|
||||
String get tutorialPage7Headline;
|
||||
|
||||
/// No description provided for @tutorialPage7Body.
|
||||
///
|
||||
/// In es, this message translates to:
|
||||
/// **'Cuando suene una alarma, no hay un único «posponer»: eliges 3, 5 o 10 minutos según lo que necesites en ese momento.'**
|
||||
String get tutorialPage7Body;
|
||||
|
||||
/// No description provided for @tutorialPage8Headline.
|
||||
///
|
||||
/// In es, this message translates to:
|
||||
/// **'¿No la encuentras? Añádela tú'**
|
||||
String get tutorialPage8Headline;
|
||||
|
||||
/// No description provided for @tutorialPage8Body.
|
||||
///
|
||||
/// In es, this message translates to:
|
||||
/// **'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.'**
|
||||
String get tutorialPage8Body;
|
||||
|
||||
/// No description provided for @tutorialPage9Headline.
|
||||
///
|
||||
/// In es, this message translates to:
|
||||
/// **'Listo, ya conoces lo esencial'**
|
||||
String get tutorialPage9Headline;
|
||||
|
||||
/// No description provided for @tutorialPage9BannerBody.
|
||||
///
|
||||
/// In es, this message translates to:
|
||||
/// **'Para volver a ver este tutorial cuando quieras: Ajustes → Información → Ayuda y tutorial.'**
|
||||
String get tutorialPage9BannerBody;
|
||||
|
||||
/// No description provided for @indefiniteOption.
|
||||
///
|
||||
/// In es, this message translates to:
|
||||
@@ -3049,6 +3169,162 @@ abstract class AppLocalizations {
|
||||
/// In es, this message translates to:
|
||||
/// **'Empezar a escuchar'**
|
||||
String get welcomeCtaLabel;
|
||||
|
||||
/// No description provided for @eqCustomActionEnableLabel.
|
||||
///
|
||||
/// In es, this message translates to:
|
||||
/// **'Activar ecualizador'**
|
||||
String get eqCustomActionEnableLabel;
|
||||
|
||||
/// No description provided for @eqCustomActionDisableLabel.
|
||||
///
|
||||
/// In es, this message translates to:
|
||||
/// **'Desactivar ecualizador'**
|
||||
String get eqCustomActionDisableLabel;
|
||||
|
||||
/// No description provided for @eqCustomActionPresetLabel.
|
||||
///
|
||||
/// In es, this message translates to:
|
||||
/// **'Preset: {preset}'**
|
||||
String eqCustomActionPresetLabel(String preset);
|
||||
|
||||
/// No description provided for @alarmCardVacationPausedBadge.
|
||||
///
|
||||
/// In es, this message translates to:
|
||||
/// **'Pausada por vacaciones'**
|
||||
String get alarmCardVacationPausedBadge;
|
||||
|
||||
/// No description provided for @alarmCardSchedulingFailedMessage.
|
||||
///
|
||||
/// In es, this message translates to:
|
||||
/// **'Esta alarma no se pudo registrar en el sistema, así que podría no sonar.'**
|
||||
String get alarmCardSchedulingFailedMessage;
|
||||
|
||||
/// No description provided for @alarmCardPreNoticeFailedMessage.
|
||||
///
|
||||
/// In es, this message translates to:
|
||||
/// **'Esta alarma está programada, pero no se pudo activar su aviso previo.'**
|
||||
String get alarmCardPreNoticeFailedMessage;
|
||||
|
||||
/// No description provided for @alarmDiagnosticsExactAlarmsTitle.
|
||||
///
|
||||
/// In es, this message translates to:
|
||||
/// **'Programación de alarma exacta'**
|
||||
String get alarmDiagnosticsExactAlarmsTitle;
|
||||
|
||||
/// No description provided for @alarmDiagnosticsExactAlarmsHint.
|
||||
///
|
||||
/// In es, this message translates to:
|
||||
/// **'Permite que la alarma suene en el minuto exacto que elegiste, incluso con el teléfono en reposo.'**
|
||||
String get alarmDiagnosticsExactAlarmsHint;
|
||||
|
||||
/// No description provided for @alarmDiagnosticsNotificationsTitle.
|
||||
///
|
||||
/// In es, this message translates to:
|
||||
/// **'Notificaciones'**
|
||||
String get alarmDiagnosticsNotificationsTitle;
|
||||
|
||||
/// No description provided for @alarmDiagnosticsNotificationsHint.
|
||||
///
|
||||
/// In es, this message translates to:
|
||||
/// **'Necesarias para mostrar la alarma y el aviso previo.'**
|
||||
String get alarmDiagnosticsNotificationsHint;
|
||||
|
||||
/// No description provided for @alarmDiagnosticsFullScreenTitle.
|
||||
///
|
||||
/// In es, this message translates to:
|
||||
/// **'Pantalla completa de la alarma'**
|
||||
String get alarmDiagnosticsFullScreenTitle;
|
||||
|
||||
/// No description provided for @alarmDiagnosticsFullScreenHint.
|
||||
///
|
||||
/// In es, this message translates to:
|
||||
/// **'Permite que la pantalla de la alarma aparezca automáticamente, incluso con el teléfono bloqueado.'**
|
||||
String get alarmDiagnosticsFullScreenHint;
|
||||
|
||||
/// No description provided for @alarmDiagnosticsBatteryTitle.
|
||||
///
|
||||
/// In es, this message translates to:
|
||||
/// **'Optimización de batería'**
|
||||
String get alarmDiagnosticsBatteryTitle;
|
||||
|
||||
/// No description provided for @alarmDiagnosticsBatteryHint.
|
||||
///
|
||||
/// In es, this message translates to:
|
||||
/// **'Evita que el sistema cierre PluriWave en segundo plano para que la alarma pueda sonar.'**
|
||||
String get alarmDiagnosticsBatteryHint;
|
||||
|
||||
/// No description provided for @alarmDiagnosticsNativeCountTitle.
|
||||
///
|
||||
/// In es, this message translates to:
|
||||
/// **'Alarmas registradas en Android'**
|
||||
String get alarmDiagnosticsNativeCountTitle;
|
||||
|
||||
/// No description provided for @alarmDiagnosticsNativeCountValue.
|
||||
///
|
||||
/// In es, this message translates to:
|
||||
/// **'Registradas ahora mismo: {count}'**
|
||||
String alarmDiagnosticsNativeCountValue(int count);
|
||||
|
||||
/// No description provided for @alarmDiagnosticsNativeCountAttentionHint.
|
||||
///
|
||||
/// In es, this message translates to:
|
||||
/// **'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.'**
|
||||
String get alarmDiagnosticsNativeCountAttentionHint;
|
||||
|
||||
/// No description provided for @alarmDiagnosticsManufacturerLabel.
|
||||
///
|
||||
/// In es, this message translates to:
|
||||
/// **'Fabricante'**
|
||||
String get alarmDiagnosticsManufacturerLabel;
|
||||
|
||||
/// No description provided for @alarmDiagnosticsSdkLabel.
|
||||
///
|
||||
/// In es, this message translates to:
|
||||
/// **'Versión de Android (SDK)'**
|
||||
String get alarmDiagnosticsSdkLabel;
|
||||
|
||||
/// No description provided for @alarmDiagnosticsNeedsAttentionStatus.
|
||||
///
|
||||
/// In es, this message translates to:
|
||||
/// **'Necesita atención'**
|
||||
String get alarmDiagnosticsNeedsAttentionStatus;
|
||||
|
||||
/// No description provided for @alarmDiagnosticsAutostartTitle.
|
||||
///
|
||||
/// In es, this message translates to:
|
||||
/// **'Un paso manual más en este teléfono'**
|
||||
String get alarmDiagnosticsAutostartTitle;
|
||||
|
||||
/// No description provided for @alarmDiagnosticsAutostartBody.
|
||||
///
|
||||
/// In es, this message translates to:
|
||||
/// **'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.'**
|
||||
String alarmDiagnosticsAutostartBody(String manufacturer);
|
||||
|
||||
/// No description provided for @alarmDiagnosticsFixAction.
|
||||
///
|
||||
/// In es, this message translates to:
|
||||
/// **'Solucionar'**
|
||||
String get alarmDiagnosticsFixAction;
|
||||
|
||||
/// No description provided for @alarmDiagnosticsIntentUnavailable.
|
||||
///
|
||||
/// In es, this message translates to:
|
||||
/// **'No se pudo abrir esa pantalla de ajustes en este teléfono. Buscala manualmente en Ajustes.'**
|
||||
String get alarmDiagnosticsIntentUnavailable;
|
||||
|
||||
/// No description provided for @alarmDiagnosticsUnavailableHint.
|
||||
///
|
||||
/// In es, this message translates to:
|
||||
/// **'Todavía no pudimos revisar tus ajustes de alarma.'**
|
||||
String get alarmDiagnosticsUnavailableHint;
|
||||
|
||||
/// No description provided for @autoEqDisableOption.
|
||||
///
|
||||
/// In es, this message translates to:
|
||||
/// **'Desactivar'**
|
||||
String get autoEqDisableOption;
|
||||
}
|
||||
|
||||
class _AppLocalizationsDelegate
|
||||
|
||||
@@ -1407,7 +1407,76 @@ class AppLocalizationsAr extends AppLocalizations {
|
||||
String get helpTitle => 'المساعدة والشرح';
|
||||
|
||||
@override
|
||||
String get helpSubtitle => 'راجع ميزات PluriWave والنصائح والمستجدات.';
|
||||
String get helpSubtitle => '9 شاشات · شاهده مجددًا متى شئت';
|
||||
|
||||
@override
|
||||
String get tutorialSkipAction => 'تخطي';
|
||||
|
||||
@override
|
||||
String get tutorialNextAction => 'التالي';
|
||||
|
||||
@override
|
||||
String get tutorialPage1Headline => 'احفظ محطاتك ونظّمها في مجموعات';
|
||||
|
||||
@override
|
||||
String get tutorialPage1Body =>
|
||||
'اضغط على أيقونة القلب لحفظ محطة. في «محطاتك» يمكنك إنشاء مجموعات مثل «كل صباح» أو «السيارة» وإعادة ترتيبها بالسحب.';
|
||||
|
||||
@override
|
||||
String get tutorialPage2Headline => 'معادل صوت عام وآخر لكل محطة';
|
||||
|
||||
@override
|
||||
String get tutorialPage2Body =>
|
||||
'من الإعدادات تحدد المعادل الصوتي العام. ومن تشغيل محطة معينة يمكنك ضبط إعداد خاص بها، له الأولوية على الإعداد العام.';
|
||||
|
||||
@override
|
||||
String get tutorialPage3Headline => 'سجّل ما تستمع إليه';
|
||||
|
||||
@override
|
||||
String get tutorialPage3Body =>
|
||||
'من شريط أدوات المشغل، يحفظ زر «تسجيل» البث الأصلي. ستجد تسجيلاتك في الإعدادات › التسجيلات.';
|
||||
|
||||
@override
|
||||
String get tutorialPage4Headline => 'منبهات تتكيّف معك';
|
||||
|
||||
@override
|
||||
String get tutorialPage4Body =>
|
||||
'استيقظ على محطتك المفضلة، وأجّل المنبه 3 أو 5 أو 10 دقائق، وأضف فترات إجازة ليتخطى بعض المنبهات نفسه تلقائيًا.';
|
||||
|
||||
@override
|
||||
String get tutorialPage5Headline => 'محطاتك المفضلة، في السيارة أيضًا';
|
||||
|
||||
@override
|
||||
String get tutorialPage5Body =>
|
||||
'اربط هاتفك بـ Android Auto لتجد المفضلة وجميع المحطات ومحطاتك والموسيقى المحلية، بأزرار كبيرة مصممة للقيادة.';
|
||||
|
||||
@override
|
||||
String get tutorialPage6Headline => 'يعيد الاتصال تلقائيًا';
|
||||
|
||||
@override
|
||||
String get tutorialPage6Body =>
|
||||
'إذا انقطعت الإشارة، تعيد PluriWave المحاولة تلقائيًا وتستمر في عرض محطاتك المفضلة المحفوظة حتى بدون اتصال.';
|
||||
|
||||
@override
|
||||
String get tutorialPage7Headline => 'اختر مدة التأجيل';
|
||||
|
||||
@override
|
||||
String get tutorialPage7Body =>
|
||||
'عند رنين المنبه، لا يوجد خيار تأجيل واحد فقط: تختار 3 أو 5 أو 10 دقائق حسب ما تحتاجه في تلك اللحظة.';
|
||||
|
||||
@override
|
||||
String get tutorialPage8Headline => 'لم تجدها؟ أضفها بنفسك';
|
||||
|
||||
@override
|
||||
String get tutorialPage8Body =>
|
||||
'من «محطاتك» ← إضافة محطة مخصصة، الصق رابط بث محطة غير موجودة في نتائج البحث. تُحفظ في «محطاتك»، وتكون متاحة في السيارة أيضًا.';
|
||||
|
||||
@override
|
||||
String get tutorialPage9Headline => 'تمّ، أصبحت تعرف الأساسيات';
|
||||
|
||||
@override
|
||||
String get tutorialPage9BannerBody =>
|
||||
'لمشاهدة هذا الشرح مرة أخرى في أي وقت: الإعدادات ← المعلومات ← المساعدة والشرح.';
|
||||
|
||||
@override
|
||||
String get indefiniteOption => 'غير محدد';
|
||||
@@ -1676,4 +1745,99 @@ class AppLocalizationsAr extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get welcomeCtaLabel => 'ابدأ الاستماع';
|
||||
|
||||
@override
|
||||
String get eqCustomActionEnableLabel => 'تفعيل الموازن';
|
||||
|
||||
@override
|
||||
String get eqCustomActionDisableLabel => 'إيقاف الموازن';
|
||||
|
||||
@override
|
||||
String eqCustomActionPresetLabel(String preset) {
|
||||
return 'الإعداد المسبق: $preset';
|
||||
}
|
||||
|
||||
@override
|
||||
String get alarmCardVacationPausedBadge => 'متوقفة مؤقتًا بسبب الإجازة';
|
||||
|
||||
@override
|
||||
String get alarmCardSchedulingFailedMessage =>
|
||||
'لم يتم تسجيل هذا المنبه في النظام، لذا قد لا يرن.';
|
||||
|
||||
@override
|
||||
String get alarmCardPreNoticeFailedMessage =>
|
||||
'تمت جدولة هذا المنبه، لكن تعذّر ضبط تذكيره المسبق.';
|
||||
|
||||
@override
|
||||
String get alarmDiagnosticsExactAlarmsTitle => 'جدولة المنبه بدقة';
|
||||
|
||||
@override
|
||||
String get alarmDiagnosticsExactAlarmsHint =>
|
||||
'يتيح رنين المنبه في الدقيقة المحددة تمامًا، حتى عندما يكون الهاتف في وضع السكون.';
|
||||
|
||||
@override
|
||||
String get alarmDiagnosticsNotificationsTitle => 'الإشعارات';
|
||||
|
||||
@override
|
||||
String get alarmDiagnosticsNotificationsHint =>
|
||||
'ضرورية لعرض المنبه والتنبيه المسبق.';
|
||||
|
||||
@override
|
||||
String get alarmDiagnosticsFullScreenTitle => 'عرض المنبه بملء الشاشة';
|
||||
|
||||
@override
|
||||
String get alarmDiagnosticsFullScreenHint =>
|
||||
'يتيح ظهور شاشة الرنين تلقائيًا، حتى عندما يكون الهاتف مقفلاً.';
|
||||
|
||||
@override
|
||||
String get alarmDiagnosticsBatteryTitle => 'تحسين استهلاك البطارية';
|
||||
|
||||
@override
|
||||
String get alarmDiagnosticsBatteryHint =>
|
||||
'يمنع النظام من إغلاق PluriWave في الخلفية حتى يتمكن المنبه من الرنين.';
|
||||
|
||||
@override
|
||||
String get alarmDiagnosticsNativeCountTitle =>
|
||||
'المنبهات المسجَّلة لدى Android';
|
||||
|
||||
@override
|
||||
String alarmDiagnosticsNativeCountValue(int count) {
|
||||
return 'المسجَّل حاليًا: $count';
|
||||
}
|
||||
|
||||
@override
|
||||
String get alarmDiagnosticsNativeCountAttentionHint =>
|
||||
'لديك منبه مفعَّل، لكن لا يوجد أي منبه مسجَّل في النظام بعد. أعد فتح PluriWave، أو عالج النقاط أعلاه أولاً.';
|
||||
|
||||
@override
|
||||
String get alarmDiagnosticsManufacturerLabel => 'الشركة المصنِّعة';
|
||||
|
||||
@override
|
||||
String get alarmDiagnosticsSdkLabel => 'إصدار Android (SDK)';
|
||||
|
||||
@override
|
||||
String get alarmDiagnosticsNeedsAttentionStatus => 'يحتاج إلى انتباه';
|
||||
|
||||
@override
|
||||
String get alarmDiagnosticsAutostartTitle =>
|
||||
'خطوة يدوية إضافية على هذا الهاتف';
|
||||
|
||||
@override
|
||||
String alarmDiagnosticsAutostartBody(String manufacturer) {
|
||||
return 'غالبًا ما تُغلق هواتف $manufacturer التطبيقات العاملة في الخلفية لتوفير البطارية. لا يوجد إعداد يمكن لـ PluriWave تفعيله بنفسه — عليك أن تُفعِّل بنفسك خاصية التشغيل التلقائي (تُعرف أحيانًا باسم \"Autostart\" أو \"النشاط في الخلفية\") لتطبيق PluriWave. ابحث عنها في الإعدادات، ضمن التطبيقات أو البطارية، أو في تطبيق الأمان الخاص بالهاتف.';
|
||||
}
|
||||
|
||||
@override
|
||||
String get alarmDiagnosticsFixAction => 'إصلاح';
|
||||
|
||||
@override
|
||||
String get alarmDiagnosticsIntentUnavailable =>
|
||||
'تعذّر فتح شاشة الإعدادات هذه على هذا الهاتف. حاول البحث عنها يدويًا في الإعدادات.';
|
||||
|
||||
@override
|
||||
String get alarmDiagnosticsUnavailableHint =>
|
||||
'لم نتمكّن بعد من التحقق من إعدادات المنبه الخاصة بك.';
|
||||
|
||||
@override
|
||||
String get autoEqDisableOption => 'تعطيل';
|
||||
}
|
||||
|
||||
@@ -1417,7 +1417,79 @@ class AppLocalizationsBn extends AppLocalizations {
|
||||
String get helpTitle => 'সহায়তা ও টিউটোরিয়াল';
|
||||
|
||||
@override
|
||||
String get helpSubtitle => 'PluriWave-এর ফিচার, টিপস ও নতুন বিষয়গুলো দেখুন।';
|
||||
String get helpSubtitle => '৯টি স্ক্রিন · যখন খুশি আবার দেখুন';
|
||||
|
||||
@override
|
||||
String get tutorialSkipAction => 'এড়িয়ে যান';
|
||||
|
||||
@override
|
||||
String get tutorialNextAction => 'পরবর্তী';
|
||||
|
||||
@override
|
||||
String get tutorialPage1Headline =>
|
||||
'আপনার স্টেশন সংরক্ষণ করুন এবং গ্রুপে ভাগ করুন';
|
||||
|
||||
@override
|
||||
String get tutorialPage1Body =>
|
||||
'একটি স্টেশন সংরক্ষণ করতে হার্ট আইকনে ট্যাপ করুন। «আপনার স্টেশন»-এ আপনি «প্রতিদিন সকালে» বা «গাড়ি»-এর মতো গ্রুপ তৈরি করতে পারেন এবং টেনে সেগুলোর ক্রম বদলাতে পারেন।';
|
||||
|
||||
@override
|
||||
String get tutorialPage2Headline =>
|
||||
'একটি সাধারণ ইকুয়ালাইজার, প্রতিটি স্টেশনের জন্য আরেকটি';
|
||||
|
||||
@override
|
||||
String get tutorialPage2Body =>
|
||||
'সেটিংসে আপনি সাধারণ ইকুয়ালাইজার ঠিক করেন। আর কোনো নির্দিষ্ট স্টেশন চালানোর সময় আপনি সেটির নিজস্ব সেটিং দিতে পারেন, যা সাধারণ সেটিংয়ের চেয়ে অগ্রাধিকার পায়।';
|
||||
|
||||
@override
|
||||
String get tutorialPage3Headline => 'যা শুনছেন তা রেকর্ড করুন';
|
||||
|
||||
@override
|
||||
String get tutorialPage3Body =>
|
||||
'প্লেয়ারের টুল ট্রে থেকে «রেকর্ড করুন» মূল স্ট্রিম সংরক্ষণ করে। আপনার রেকর্ডিং পাবেন সেটিংস › রেকর্ডিং-এ।';
|
||||
|
||||
@override
|
||||
String get tutorialPage4Headline =>
|
||||
'এমন অ্যালার্ম যা আপনার সাথে মানিয়ে নেয়';
|
||||
|
||||
@override
|
||||
String get tutorialPage4Body =>
|
||||
'আপনার প্রিয় স্টেশনে জেগে উঠুন, ৩, ৫ বা ১০ মিনিট স্নুজ করুন, এবং ছুটির সময়সীমা যোগ করুন যাতে কিছু অ্যালার্ম নিজে থেকেই বাদ পড়ে।';
|
||||
|
||||
@override
|
||||
String get tutorialPage5Headline => 'আপনার প্রিয়গুলো, গাড়িতেও';
|
||||
|
||||
@override
|
||||
String get tutorialPage5Body =>
|
||||
'আপনার ফোন Android Auto-এর সাথে সংযুক্ত করুন এবং পাবেন প্রিয়, সব স্টেশন, আপনার স্টেশন এবং আপনার লোকাল মিউজিক, গাড়ি চালানোর উপযোগী বড় বোতামসহ।';
|
||||
|
||||
@override
|
||||
String get tutorialPage6Headline => 'নিজে থেকেই আবার সংযুক্ত হয়';
|
||||
|
||||
@override
|
||||
String get tutorialPage6Body =>
|
||||
'সিগন্যাল কেটে গেলে, PluriWave স্বয়ংক্রিয়ভাবে আবার চেষ্টা করে এবং সংযোগ ছাড়াই আপনার সংরক্ষিত প্রিয়গুলো দেখাতে থাকে।';
|
||||
|
||||
@override
|
||||
String get tutorialPage7Headline => 'কতক্ষণ স্নুজ করবেন তা বেছে নিন';
|
||||
|
||||
@override
|
||||
String get tutorialPage7Body =>
|
||||
'অ্যালার্ম বাজলে, «স্নুজ»-এর একটিমাত্র বিকল্প নেই: আপনি সেই মুহূর্তে প্রয়োজন অনুযায়ী ৩, ৫ বা ১০ মিনিট বেছে নেন।';
|
||||
|
||||
@override
|
||||
String get tutorialPage8Headline => 'খুঁজে পাচ্ছেন না? নিজেই যোগ করুন';
|
||||
|
||||
@override
|
||||
String get tutorialPage8Body =>
|
||||
'«আপনার স্টেশন» → কাস্টম স্টেশন যোগ করুন-এ, খোঁজে না পাওয়া স্টেশনের স্ট্রিম URL পেস্ট করুন। এটি «আপনার স্টেশন»-এ সংরক্ষিত হয় এবং গাড়িতেও পাওয়া যায়।';
|
||||
|
||||
@override
|
||||
String get tutorialPage9Headline => 'শেষ, এখন আপনি মূল বিষয়গুলো জানেন';
|
||||
|
||||
@override
|
||||
String get tutorialPage9BannerBody =>
|
||||
'এই টিউটোরিয়ালটি আবার দেখতে: সেটিংস → তথ্য → সহায়তা ও টিউটোরিয়াল।';
|
||||
|
||||
@override
|
||||
String get indefiniteOption => 'অনির্দিষ্ট';
|
||||
@@ -1685,4 +1757,98 @@ class AppLocalizationsBn extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get welcomeCtaLabel => 'শোনা শুরু করুন';
|
||||
|
||||
@override
|
||||
String get eqCustomActionEnableLabel => 'ইকুয়ালাইজার চালু করুন';
|
||||
|
||||
@override
|
||||
String get eqCustomActionDisableLabel => 'ইকুয়ালাইজার বন্ধ করুন';
|
||||
|
||||
@override
|
||||
String eqCustomActionPresetLabel(String preset) {
|
||||
return 'প্রিসেট: $preset';
|
||||
}
|
||||
|
||||
@override
|
||||
String get alarmCardVacationPausedBadge => 'ছুটির কারণে বিরত';
|
||||
|
||||
@override
|
||||
String get alarmCardSchedulingFailedMessage =>
|
||||
'এই অ্যালার্মটি সিস্টেমে নিবন্ধন করা যায়নি, তাই এটি নাও বাজতে পারে।';
|
||||
|
||||
@override
|
||||
String get alarmCardPreNoticeFailedMessage =>
|
||||
'এই অ্যালার্মটি নির্ধারিত হয়েছে, তবে এর আগাম রিমাইন্ডার সেট করা যায়নি।';
|
||||
|
||||
@override
|
||||
String get alarmDiagnosticsExactAlarmsTitle => 'নির্ভুল অ্যালার্ম শিডিউলিং';
|
||||
|
||||
@override
|
||||
String get alarmDiagnosticsExactAlarmsHint =>
|
||||
'ফোন ঘুমন্ত অবস্থায় থাকলেও অ্যালার্মকে ঠিক নির্ধারিত মিনিটে বাজতে দেয়।';
|
||||
|
||||
@override
|
||||
String get alarmDiagnosticsNotificationsTitle => 'বিজ্ঞপ্তি';
|
||||
|
||||
@override
|
||||
String get alarmDiagnosticsNotificationsHint =>
|
||||
'অ্যালার্ম এবং আগাম সতর্কবার্তা দেখানোর জন্য প্রয়োজনীয়।';
|
||||
|
||||
@override
|
||||
String get alarmDiagnosticsFullScreenTitle =>
|
||||
'অ্যালার্মের ফুল-স্ক্রিন প্রদর্শন';
|
||||
|
||||
@override
|
||||
String get alarmDiagnosticsFullScreenHint =>
|
||||
'ফোন লক থাকলেও রিং হওয়ার স্ক্রিনটি স্বয়ংক্রিয়ভাবে দেখা দিতে দেয়।';
|
||||
|
||||
@override
|
||||
String get alarmDiagnosticsBatteryTitle => 'ব্যাটারি অপ্টিমাইজেশন';
|
||||
|
||||
@override
|
||||
String get alarmDiagnosticsBatteryHint =>
|
||||
'সিস্টেমকে ব্যাকগ্রাউন্ডে PluriWave বন্ধ করা থেকে আটকায়, যাতে অ্যালার্মটি তবুও বাজতে পারে।';
|
||||
|
||||
@override
|
||||
String get alarmDiagnosticsNativeCountTitle => 'Android-এ নিবন্ধিত অ্যালার্ম';
|
||||
|
||||
@override
|
||||
String alarmDiagnosticsNativeCountValue(int count) {
|
||||
return 'বর্তমানে নিবন্ধিত: $count';
|
||||
}
|
||||
|
||||
@override
|
||||
String get alarmDiagnosticsNativeCountAttentionHint =>
|
||||
'আপনার একটি অ্যালার্ম চালু আছে, কিন্তু এখনও কোনোটিই সিস্টেমে নিবন্ধিত হয়নি। PluriWave আবার খুলুন, অথবা প্রথমে উপরের বিষয়গুলো ঠিক করুন।';
|
||||
|
||||
@override
|
||||
String get alarmDiagnosticsManufacturerLabel => 'নির্মাতা';
|
||||
|
||||
@override
|
||||
String get alarmDiagnosticsSdkLabel => 'Android সংস্করণ (SDK)';
|
||||
|
||||
@override
|
||||
String get alarmDiagnosticsNeedsAttentionStatus => 'মনোযোগ প্রয়োজন';
|
||||
|
||||
@override
|
||||
String get alarmDiagnosticsAutostartTitle => 'এই ফোনে আরেকটি ম্যানুয়াল ধাপ';
|
||||
|
||||
@override
|
||||
String alarmDiagnosticsAutostartBody(String manufacturer) {
|
||||
return '$manufacturer ফোন ব্যাটারি বাঁচাতে প্রায়ই ব্যাকগ্রাউন্ডে চলা অ্যাপ বন্ধ করে দেয়। এমন কোনো সেটিং নেই যা PluriWave নিজে থেকে চালু করতে পারে — আপনাকে নিজে থেকেই PluriWave-এর জন্য অটোস্টার্ট (কখনও কখনও \"Auto-start\" বা \"ব্যাকগ্রাউন্ড অ্যাক্টিভিটি\" নামেও পরিচিত) চালু করতে হবে। সেটিংসে, অ্যাপস বা ব্যাটারির মধ্যে, অথবা ফোনের নিজস্ব সিকিউরিটি অ্যাপে এটি খুঁজুন।';
|
||||
}
|
||||
|
||||
@override
|
||||
String get alarmDiagnosticsFixAction => 'সমাধান করুন';
|
||||
|
||||
@override
|
||||
String get alarmDiagnosticsIntentUnavailable =>
|
||||
'এই ফোনে সেই সেটিংস স্ক্রিনটি খোলা যায়নি। সেটিংসে ম্যানুয়ালি খুঁজে দেখার চেষ্টা করুন।';
|
||||
|
||||
@override
|
||||
String get alarmDiagnosticsUnavailableHint =>
|
||||
'আমরা এখনও আপনার অ্যালার্ম সেটিংস পরীক্ষা করতে পারিনি।';
|
||||
|
||||
@override
|
||||
String get autoEqDisableOption => 'বন্ধ করুন';
|
||||
}
|
||||
|
||||
@@ -1425,8 +1425,79 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
String get helpTitle => 'Hilfe und Tutorial';
|
||||
|
||||
@override
|
||||
String get helpSubtitle =>
|
||||
'Funktionen, Tipps und Neuigkeiten von PluriWave ansehen.';
|
||||
String get helpSubtitle => '9 Bildschirme · jederzeit erneut ansehen';
|
||||
|
||||
@override
|
||||
String get tutorialSkipAction => 'Überspringen';
|
||||
|
||||
@override
|
||||
String get tutorialNextAction => 'Weiter';
|
||||
|
||||
@override
|
||||
String get tutorialPage1Headline =>
|
||||
'Speichere deine Sender und gruppiere sie';
|
||||
|
||||
@override
|
||||
String get tutorialPage1Body =>
|
||||
'Tippe auf das Herz, um einen Sender zu speichern. Unter „Deine Sender“ kannst du Gruppen wie „Jeden Morgen“ oder „Auto“ anlegen und sie per Ziehen neu anordnen.';
|
||||
|
||||
@override
|
||||
String get tutorialPage2Headline =>
|
||||
'Ein Basis-Equalizer und einer pro Sender';
|
||||
|
||||
@override
|
||||
String get tutorialPage2Body =>
|
||||
'In den Einstellungen legst du den allgemeinen Equalizer fest. Und während der Wiedergabe eines bestimmten Senders kannst du ihm eine eigene Einstellung geben, die Vorrang vor der allgemeinen hat.';
|
||||
|
||||
@override
|
||||
String get tutorialPage3Headline => 'Nimm auf, was du gerade hörst';
|
||||
|
||||
@override
|
||||
String get tutorialPage3Body =>
|
||||
'Über die Werkzeugleiste des Players speichert „Aufnehmen“ den Original-Stream. Deine Aufnahmen findest du unter Einstellungen › Aufnahmen.';
|
||||
|
||||
@override
|
||||
String get tutorialPage4Headline => 'Alarme, die sich dir anpassen';
|
||||
|
||||
@override
|
||||
String get tutorialPage4Body =>
|
||||
'Wach mit deinem Lieblingssender auf, schlummere 3, 5 oder 10 Minuten weiter und füge Urlaubszeiträume hinzu, damit manche Alarme von selbst ausgesetzt werden.';
|
||||
|
||||
@override
|
||||
String get tutorialPage5Headline => 'Deine Favoriten, auch im Auto';
|
||||
|
||||
@override
|
||||
String get tutorialPage5Body =>
|
||||
'Verbinde dein Handy mit Android Auto und finde Favoriten, Alle Sender, Deine Sender und deine lokale Musik, mit großen, fürs Fahren gemachten Tasten.';
|
||||
|
||||
@override
|
||||
String get tutorialPage6Headline => 'Verbindet sich von selbst neu';
|
||||
|
||||
@override
|
||||
String get tutorialPage6Body =>
|
||||
'Bricht das Signal ab, versucht PluriWave automatisch erneut und zeigt weiterhin deine gespeicherten Favoriten, auch ohne Verbindung.';
|
||||
|
||||
@override
|
||||
String get tutorialPage7Headline => 'Wähle, wie lange du schlummerst';
|
||||
|
||||
@override
|
||||
String get tutorialPage7Body =>
|
||||
'Wenn ein Alarm klingelt, gibt es nicht nur ein „Schlummern“: Du wählst 3, 5 oder 10 Minuten, je nachdem, was du gerade brauchst.';
|
||||
|
||||
@override
|
||||
String get tutorialPage8Headline =>
|
||||
'Findest du ihn nicht? Füge ihn selbst hinzu';
|
||||
|
||||
@override
|
||||
String get tutorialPage8Body =>
|
||||
'Füge unter „Deine Sender“ → Eigenen Sender hinzufügen die Stream-URL eines Senders ein, der nicht in der Suche auftaucht. Er wird unter „Deine Sender“ gespeichert und ist auch im Auto verfügbar.';
|
||||
|
||||
@override
|
||||
String get tutorialPage9Headline => 'Fertig, du kennst jetzt das Wichtigste';
|
||||
|
||||
@override
|
||||
String get tutorialPage9BannerBody =>
|
||||
'Um dieses Tutorial jederzeit erneut anzusehen: Einstellungen → Info → Hilfe und Tutorial.';
|
||||
|
||||
@override
|
||||
String get indefiniteOption => 'Unbegrenzt';
|
||||
@@ -1698,4 +1769,99 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get welcomeCtaLabel => 'Jetzt hören';
|
||||
|
||||
@override
|
||||
String get eqCustomActionEnableLabel => 'Equalizer aktivieren';
|
||||
|
||||
@override
|
||||
String get eqCustomActionDisableLabel => 'Equalizer deaktivieren';
|
||||
|
||||
@override
|
||||
String eqCustomActionPresetLabel(String preset) {
|
||||
return 'Voreinstellung: $preset';
|
||||
}
|
||||
|
||||
@override
|
||||
String get alarmCardVacationPausedBadge => 'Wegen Urlaub pausiert';
|
||||
|
||||
@override
|
||||
String get alarmCardSchedulingFailedMessage =>
|
||||
'Dieser Alarm konnte nicht im System registriert werden, daher klingelt er möglicherweise nicht.';
|
||||
|
||||
@override
|
||||
String get alarmCardPreNoticeFailedMessage =>
|
||||
'Dieser Alarm ist geplant, aber seine Vorwarnung konnte nicht eingerichtet werden.';
|
||||
|
||||
@override
|
||||
String get alarmDiagnosticsExactAlarmsTitle => 'Genaue Alarmplanung';
|
||||
|
||||
@override
|
||||
String get alarmDiagnosticsExactAlarmsHint =>
|
||||
'Lässt den Alarm genau zur eingestellten Minute klingeln, auch wenn das Telefon im Ruhezustand ist.';
|
||||
|
||||
@override
|
||||
String get alarmDiagnosticsNotificationsTitle => 'Benachrichtigungen';
|
||||
|
||||
@override
|
||||
String get alarmDiagnosticsNotificationsHint =>
|
||||
'Nötig, um den Alarm und den Vorab-Hinweis anzuzeigen.';
|
||||
|
||||
@override
|
||||
String get alarmDiagnosticsFullScreenTitle => 'Alarmanzeige im Vollbildmodus';
|
||||
|
||||
@override
|
||||
String get alarmDiagnosticsFullScreenHint =>
|
||||
'Lässt den Klingelbildschirm automatisch erscheinen, auch wenn das Telefon gesperrt ist.';
|
||||
|
||||
@override
|
||||
String get alarmDiagnosticsBatteryTitle => 'Akku-Optimierung';
|
||||
|
||||
@override
|
||||
String get alarmDiagnosticsBatteryHint =>
|
||||
'Verhindert, dass das System PluriWave im Hintergrund beendet, damit der Alarm trotzdem klingeln kann.';
|
||||
|
||||
@override
|
||||
String get alarmDiagnosticsNativeCountTitle =>
|
||||
'Bei Android registrierte Alarme';
|
||||
|
||||
@override
|
||||
String alarmDiagnosticsNativeCountValue(int count) {
|
||||
return 'Aktuell registriert: $count';
|
||||
}
|
||||
|
||||
@override
|
||||
String get alarmDiagnosticsNativeCountAttentionHint =>
|
||||
'Du hast einen Alarm aktiviert, aber es ist noch keiner beim System registriert. Öffne PluriWave erneut oder behebe zuerst die Punkte oben.';
|
||||
|
||||
@override
|
||||
String get alarmDiagnosticsManufacturerLabel => 'Hersteller';
|
||||
|
||||
@override
|
||||
String get alarmDiagnosticsSdkLabel => 'Android-Version (SDK)';
|
||||
|
||||
@override
|
||||
String get alarmDiagnosticsNeedsAttentionStatus => 'Erfordert Aufmerksamkeit';
|
||||
|
||||
@override
|
||||
String get alarmDiagnosticsAutostartTitle =>
|
||||
'Noch ein manueller Schritt auf diesem Telefon';
|
||||
|
||||
@override
|
||||
String alarmDiagnosticsAutostartBody(String manufacturer) {
|
||||
return '$manufacturer-Telefone schließen oft Apps im Hintergrund, um Akku zu sparen. Es gibt keine Einstellung, die PluriWave selbst aktivieren kann — du musst den Autostart (manchmal auch \"Auto-Start\" oder \"Hintergrundaktivität\" genannt) für PluriWave selbst einschalten. Schau in den Einstellungen unter Apps oder Akku, oder in der Sicherheits-App des Telefons nach.';
|
||||
}
|
||||
|
||||
@override
|
||||
String get alarmDiagnosticsFixAction => 'Beheben';
|
||||
|
||||
@override
|
||||
String get alarmDiagnosticsIntentUnavailable =>
|
||||
'Diese Einstellungsseite konnte auf diesem Telefon nicht geöffnet werden. Suche sie manuell in den Einstellungen.';
|
||||
|
||||
@override
|
||||
String get alarmDiagnosticsUnavailableHint =>
|
||||
'Wir konnten deine Alarmeinstellungen noch nicht prüfen.';
|
||||
|
||||
@override
|
||||
String get autoEqDisableOption => 'Deaktivieren';
|
||||
}
|
||||
|
||||
@@ -1408,7 +1408,77 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
String get helpTitle => 'Help and tutorial';
|
||||
|
||||
@override
|
||||
String get helpSubtitle => 'Review PluriWave features, tips and what’s new.';
|
||||
String get helpSubtitle => '9 screens · watch it again anytime';
|
||||
|
||||
@override
|
||||
String get tutorialSkipAction => 'Skip';
|
||||
|
||||
@override
|
||||
String get tutorialNextAction => 'Next';
|
||||
|
||||
@override
|
||||
String get tutorialPage1Headline => 'Save your stations and group them';
|
||||
|
||||
@override
|
||||
String get 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.';
|
||||
|
||||
@override
|
||||
String get tutorialPage2Headline =>
|
||||
'One base equalizer, plus one per station';
|
||||
|
||||
@override
|
||||
String get 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.';
|
||||
|
||||
@override
|
||||
String get tutorialPage3Headline => 'Record what you\'re listening to';
|
||||
|
||||
@override
|
||||
String get tutorialPage3Body =>
|
||||
'From the player\'s tool tray, \"Record\" saves the original stream. Find your recordings under Settings › Recordings.';
|
||||
|
||||
@override
|
||||
String get tutorialPage4Headline => 'Alarms that adapt to you';
|
||||
|
||||
@override
|
||||
String get tutorialPage4Body =>
|
||||
'Wake up to your favorite station, snooze for 3, 5, or 10 minutes, and add vacation ranges so some alarms skip themselves.';
|
||||
|
||||
@override
|
||||
String get tutorialPage5Headline => 'Your favorites, in the car too';
|
||||
|
||||
@override
|
||||
String get tutorialPage5Body =>
|
||||
'Connect your phone with Android Auto to find Favorites, All stations, Your stations, and your Local Music, with big buttons made for driving.';
|
||||
|
||||
@override
|
||||
String get tutorialPage6Headline => 'It reconnects on its own';
|
||||
|
||||
@override
|
||||
String get tutorialPage6Body =>
|
||||
'If the signal drops, PluriWave retries automatically and keeps showing your saved favorites even without a connection.';
|
||||
|
||||
@override
|
||||
String get tutorialPage7Headline => 'Choose how long to snooze';
|
||||
|
||||
@override
|
||||
String get 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.';
|
||||
|
||||
@override
|
||||
String get tutorialPage8Headline => 'Can\'t find it? Add it yourself';
|
||||
|
||||
@override
|
||||
String get 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.';
|
||||
|
||||
@override
|
||||
String get tutorialPage9Headline => 'Done — now you know the essentials';
|
||||
|
||||
@override
|
||||
String get tutorialPage9BannerBody =>
|
||||
'To watch this tutorial again anytime: Settings → Information → Help and tutorial.';
|
||||
|
||||
@override
|
||||
String get indefiniteOption => 'Indefinite';
|
||||
@@ -1678,4 +1748,99 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get welcomeCtaLabel => 'Start listening';
|
||||
|
||||
@override
|
||||
String get eqCustomActionEnableLabel => 'Enable equalizer';
|
||||
|
||||
@override
|
||||
String get eqCustomActionDisableLabel => 'Disable equalizer';
|
||||
|
||||
@override
|
||||
String eqCustomActionPresetLabel(String preset) {
|
||||
return 'Preset: $preset';
|
||||
}
|
||||
|
||||
@override
|
||||
String get alarmCardVacationPausedBadge => 'Paused for vacation';
|
||||
|
||||
@override
|
||||
String get alarmCardSchedulingFailedMessage =>
|
||||
'This alarm could not be registered with the system, so it may not ring.';
|
||||
|
||||
@override
|
||||
String get alarmCardPreNoticeFailedMessage =>
|
||||
'This alarm is scheduled, but its early reminder could not be set.';
|
||||
|
||||
@override
|
||||
String get alarmDiagnosticsExactAlarmsTitle => 'Exact alarm scheduling';
|
||||
|
||||
@override
|
||||
String get alarmDiagnosticsExactAlarmsHint =>
|
||||
'Lets the alarm ring at the exact minute you set, even while the phone is asleep.';
|
||||
|
||||
@override
|
||||
String get alarmDiagnosticsNotificationsTitle => 'Notifications';
|
||||
|
||||
@override
|
||||
String get alarmDiagnosticsNotificationsHint =>
|
||||
'Needed to show the alarm and the advance-warning notice.';
|
||||
|
||||
@override
|
||||
String get alarmDiagnosticsFullScreenTitle => 'Full-screen alarm display';
|
||||
|
||||
@override
|
||||
String get alarmDiagnosticsFullScreenHint =>
|
||||
'Lets the ringing screen appear automatically, even with the phone locked.';
|
||||
|
||||
@override
|
||||
String get alarmDiagnosticsBatteryTitle => 'Battery optimization';
|
||||
|
||||
@override
|
||||
String get alarmDiagnosticsBatteryHint =>
|
||||
'Stops the system from closing PluriWave in the background so the alarm can still fire.';
|
||||
|
||||
@override
|
||||
String get alarmDiagnosticsNativeCountTitle =>
|
||||
'Alarms registered with Android';
|
||||
|
||||
@override
|
||||
String alarmDiagnosticsNativeCountValue(int count) {
|
||||
return 'Currently registered: $count';
|
||||
}
|
||||
|
||||
@override
|
||||
String get alarmDiagnosticsNativeCountAttentionHint =>
|
||||
'You have an alarm turned on, but none are registered with the system yet. Reopen PluriWave, or fix the items above first.';
|
||||
|
||||
@override
|
||||
String get alarmDiagnosticsManufacturerLabel => 'Manufacturer';
|
||||
|
||||
@override
|
||||
String get alarmDiagnosticsSdkLabel => 'Android version (SDK)';
|
||||
|
||||
@override
|
||||
String get alarmDiagnosticsNeedsAttentionStatus => 'Needs attention';
|
||||
|
||||
@override
|
||||
String get alarmDiagnosticsAutostartTitle =>
|
||||
'One more manual step on this phone';
|
||||
|
||||
@override
|
||||
String alarmDiagnosticsAutostartBody(String manufacturer) {
|
||||
return '$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.';
|
||||
}
|
||||
|
||||
@override
|
||||
String get alarmDiagnosticsFixAction => 'Fix this';
|
||||
|
||||
@override
|
||||
String get alarmDiagnosticsIntentUnavailable =>
|
||||
'Couldn\'t open that settings screen on this phone. Try looking for it manually in Settings.';
|
||||
|
||||
@override
|
||||
String get alarmDiagnosticsUnavailableHint =>
|
||||
'We couldn\'t check your alarm settings yet.';
|
||||
|
||||
@override
|
||||
String get autoEqDisableOption => 'Disable';
|
||||
}
|
||||
|
||||
@@ -1419,8 +1419,76 @@ class AppLocalizationsEs extends AppLocalizations {
|
||||
String get helpTitle => 'Ayuda y tutorial';
|
||||
|
||||
@override
|
||||
String get helpSubtitle =>
|
||||
'Repasá funciones, consejos y novedades de PluriWave.';
|
||||
String get helpSubtitle => '9 pantallas · vuelve a verlo cuando quieras';
|
||||
|
||||
@override
|
||||
String get tutorialSkipAction => 'Saltar';
|
||||
|
||||
@override
|
||||
String get tutorialNextAction => 'Siguiente';
|
||||
|
||||
@override
|
||||
String get tutorialPage1Headline => 'Guarda tus emisoras y agrúpalas';
|
||||
|
||||
@override
|
||||
String get tutorialPage1Body =>
|
||||
'Toca el corazón para guardar una emisora. En «Tus emisoras» puedes crear grupos como «Cada mañana» o «Coche» y reordenarlas arrastrando.';
|
||||
|
||||
@override
|
||||
String get tutorialPage2Headline => 'Un ecualizador base y otro por emisora';
|
||||
|
||||
@override
|
||||
String get 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.';
|
||||
|
||||
@override
|
||||
String get tutorialPage3Headline => 'Graba lo que estás escuchando';
|
||||
|
||||
@override
|
||||
String get tutorialPage3Body =>
|
||||
'Desde la bandeja de herramientas del reproductor, «Grabar» guarda el stream original. Encuentra tus grabaciones en Ajustes › Grabaciones.';
|
||||
|
||||
@override
|
||||
String get tutorialPage4Headline => 'Alarmas que se adaptan a ti';
|
||||
|
||||
@override
|
||||
String get 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.';
|
||||
|
||||
@override
|
||||
String get tutorialPage5Headline => 'Tus favoritas, también en el coche';
|
||||
|
||||
@override
|
||||
String get 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.';
|
||||
|
||||
@override
|
||||
String get tutorialPage6Headline => 'Se reconecta sola';
|
||||
|
||||
@override
|
||||
String get tutorialPage6Body =>
|
||||
'Si se corta la señal, PluriWave reintenta automáticamente y sigue mostrando tus favoritas guardadas aunque no tengas conexión.';
|
||||
|
||||
@override
|
||||
String get tutorialPage7Headline => 'Elige cuánto posponer';
|
||||
|
||||
@override
|
||||
String get tutorialPage7Body =>
|
||||
'Cuando suene una alarma, no hay un único «posponer»: eliges 3, 5 o 10 minutos según lo que necesites en ese momento.';
|
||||
|
||||
@override
|
||||
String get tutorialPage8Headline => '¿No la encuentras? Añádela tú';
|
||||
|
||||
@override
|
||||
String get 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.';
|
||||
|
||||
@override
|
||||
String get tutorialPage9Headline => 'Listo, ya conoces lo esencial';
|
||||
|
||||
@override
|
||||
String get tutorialPage9BannerBody =>
|
||||
'Para volver a ver este tutorial cuando quieras: Ajustes → Información → Ayuda y tutorial.';
|
||||
|
||||
@override
|
||||
String get indefiniteOption => 'Indefinida';
|
||||
@@ -1692,4 +1760,101 @@ class AppLocalizationsEs extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get welcomeCtaLabel => 'Empezar a escuchar';
|
||||
|
||||
@override
|
||||
String get eqCustomActionEnableLabel => 'Activar ecualizador';
|
||||
|
||||
@override
|
||||
String get eqCustomActionDisableLabel => 'Desactivar ecualizador';
|
||||
|
||||
@override
|
||||
String eqCustomActionPresetLabel(String preset) {
|
||||
return 'Preset: $preset';
|
||||
}
|
||||
|
||||
@override
|
||||
String get alarmCardVacationPausedBadge => 'Pausada por vacaciones';
|
||||
|
||||
@override
|
||||
String get alarmCardSchedulingFailedMessage =>
|
||||
'Esta alarma no se pudo registrar en el sistema, así que podría no sonar.';
|
||||
|
||||
@override
|
||||
String get alarmCardPreNoticeFailedMessage =>
|
||||
'Esta alarma está programada, pero no se pudo activar su aviso previo.';
|
||||
|
||||
@override
|
||||
String get alarmDiagnosticsExactAlarmsTitle =>
|
||||
'Programación de alarma exacta';
|
||||
|
||||
@override
|
||||
String get alarmDiagnosticsExactAlarmsHint =>
|
||||
'Permite que la alarma suene en el minuto exacto que elegiste, incluso con el teléfono en reposo.';
|
||||
|
||||
@override
|
||||
String get alarmDiagnosticsNotificationsTitle => 'Notificaciones';
|
||||
|
||||
@override
|
||||
String get alarmDiagnosticsNotificationsHint =>
|
||||
'Necesarias para mostrar la alarma y el aviso previo.';
|
||||
|
||||
@override
|
||||
String get alarmDiagnosticsFullScreenTitle =>
|
||||
'Pantalla completa de la alarma';
|
||||
|
||||
@override
|
||||
String get alarmDiagnosticsFullScreenHint =>
|
||||
'Permite que la pantalla de la alarma aparezca automáticamente, incluso con el teléfono bloqueado.';
|
||||
|
||||
@override
|
||||
String get alarmDiagnosticsBatteryTitle => 'Optimización de batería';
|
||||
|
||||
@override
|
||||
String get alarmDiagnosticsBatteryHint =>
|
||||
'Evita que el sistema cierre PluriWave en segundo plano para que la alarma pueda sonar.';
|
||||
|
||||
@override
|
||||
String get alarmDiagnosticsNativeCountTitle =>
|
||||
'Alarmas registradas en Android';
|
||||
|
||||
@override
|
||||
String alarmDiagnosticsNativeCountValue(int count) {
|
||||
return 'Registradas ahora mismo: $count';
|
||||
}
|
||||
|
||||
@override
|
||||
String get 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.';
|
||||
|
||||
@override
|
||||
String get alarmDiagnosticsManufacturerLabel => 'Fabricante';
|
||||
|
||||
@override
|
||||
String get alarmDiagnosticsSdkLabel => 'Versión de Android (SDK)';
|
||||
|
||||
@override
|
||||
String get alarmDiagnosticsNeedsAttentionStatus => 'Necesita atención';
|
||||
|
||||
@override
|
||||
String get alarmDiagnosticsAutostartTitle =>
|
||||
'Un paso manual más en este teléfono';
|
||||
|
||||
@override
|
||||
String alarmDiagnosticsAutostartBody(String manufacturer) {
|
||||
return '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.';
|
||||
}
|
||||
|
||||
@override
|
||||
String get alarmDiagnosticsFixAction => 'Solucionar';
|
||||
|
||||
@override
|
||||
String get alarmDiagnosticsIntentUnavailable =>
|
||||
'No se pudo abrir esa pantalla de ajustes en este teléfono. Buscala manualmente en Ajustes.';
|
||||
|
||||
@override
|
||||
String get alarmDiagnosticsUnavailableHint =>
|
||||
'Todavía no pudimos revisar tus ajustes de alarma.';
|
||||
|
||||
@override
|
||||
String get autoEqDisableOption => 'Desactivar';
|
||||
}
|
||||
|
||||
@@ -1428,8 +1428,79 @@ class AppLocalizationsFr extends AppLocalizations {
|
||||
String get helpTitle => 'Aide et tutoriel';
|
||||
|
||||
@override
|
||||
String get helpSubtitle =>
|
||||
'Revoyez les fonctions, conseils et nouveautés de PluriWave.';
|
||||
String get helpSubtitle => '9 écrans · à revoir quand vous voulez';
|
||||
|
||||
@override
|
||||
String get tutorialSkipAction => 'Passer';
|
||||
|
||||
@override
|
||||
String get tutorialNextAction => 'Suivant';
|
||||
|
||||
@override
|
||||
String get tutorialPage1Headline =>
|
||||
'Enregistrez vos stations et regroupez-les';
|
||||
|
||||
@override
|
||||
String get tutorialPage1Body =>
|
||||
'Touchez le cœur pour enregistrer une station. Dans « Vos stations », vous pouvez créer des groupes comme « Chaque matin » ou « Voiture » et les réorganiser en les faisant glisser.';
|
||||
|
||||
@override
|
||||
String get tutorialPage2Headline =>
|
||||
'Un égaliseur général et un autre par station';
|
||||
|
||||
@override
|
||||
String get tutorialPage2Body =>
|
||||
'Dans Réglages, vous définissez l\'égaliseur général. Et depuis la lecture d\'une station précise, vous pouvez lui donner son propre réglage, qui prévaut sur le général.';
|
||||
|
||||
@override
|
||||
String get tutorialPage3Headline => 'Enregistrez ce que vous écoutez';
|
||||
|
||||
@override
|
||||
String get tutorialPage3Body =>
|
||||
'Depuis la barre d\'outils du lecteur, « Enregistrer » sauvegarde le flux original. Retrouvez vos enregistrements dans Réglages › Enregistrements.';
|
||||
|
||||
@override
|
||||
String get tutorialPage4Headline => 'Des alarmes qui s\'adaptent à vous';
|
||||
|
||||
@override
|
||||
String get tutorialPage4Body =>
|
||||
'Réveillez-vous avec votre station préférée, reportez de 3, 5 ou 10 minutes, et ajoutez des périodes de vacances pour que certaines alarmes se sautent d\'elles-mêmes.';
|
||||
|
||||
@override
|
||||
String get tutorialPage5Headline => 'Vos favoris, aussi en voiture';
|
||||
|
||||
@override
|
||||
String get tutorialPage5Body =>
|
||||
'Connectez votre téléphone avec Android Auto et retrouvez Favoris, Toutes les stations, Vos stations et votre Musique locale, avec de grands boutons pensés pour la conduite.';
|
||||
|
||||
@override
|
||||
String get tutorialPage6Headline => 'Se reconnecte toute seule';
|
||||
|
||||
@override
|
||||
String get tutorialPage6Body =>
|
||||
'Si le signal se coupe, PluriWave retente automatiquement et continue d\'afficher vos favoris enregistrés même sans connexion.';
|
||||
|
||||
@override
|
||||
String get tutorialPage7Headline => 'Choisissez combien de temps reporter';
|
||||
|
||||
@override
|
||||
String get tutorialPage7Body =>
|
||||
'Quand une alarme sonne, il n\'y a pas un seul « reporter » : vous choisissez 3, 5 ou 10 minutes selon ce dont vous avez besoin à ce moment-là.';
|
||||
|
||||
@override
|
||||
String get tutorialPage8Headline =>
|
||||
'Vous ne la trouvez pas ? Ajoutez-la vous-même';
|
||||
|
||||
@override
|
||||
String get tutorialPage8Body =>
|
||||
'Dans « Vos stations » → Ajouter une station personnalisée, collez l\'URL du flux d\'une station absente des résultats de recherche. Elle est enregistrée dans « Vos stations », aussi disponible en voiture.';
|
||||
|
||||
@override
|
||||
String get tutorialPage9Headline => 'Voilà, vous connaissez l\'essentiel';
|
||||
|
||||
@override
|
||||
String get tutorialPage9BannerBody =>
|
||||
'Pour revoir ce tutoriel quand vous le souhaitez : Réglages → Informations → Aide et tutoriel.';
|
||||
|
||||
@override
|
||||
String get indefiniteOption => 'Indéfinie';
|
||||
@@ -1701,4 +1772,102 @@ class AppLocalizationsFr extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get welcomeCtaLabel => 'Commencer à écouter';
|
||||
|
||||
@override
|
||||
String get eqCustomActionEnableLabel => 'Activer l\'égaliseur';
|
||||
|
||||
@override
|
||||
String get eqCustomActionDisableLabel => 'Désactiver l\'égaliseur';
|
||||
|
||||
@override
|
||||
String eqCustomActionPresetLabel(String preset) {
|
||||
return 'Préréglage : $preset';
|
||||
}
|
||||
|
||||
@override
|
||||
String get alarmCardVacationPausedBadge => 'En pause pour les vacances';
|
||||
|
||||
@override
|
||||
String get alarmCardSchedulingFailedMessage =>
|
||||
'Cette alarme n\'a pas pu être enregistrée dans le système ; elle risque donc de ne pas sonner.';
|
||||
|
||||
@override
|
||||
String get alarmCardPreNoticeFailedMessage =>
|
||||
'Cette alarme est programmée, mais son rappel anticipé n\'a pas pu être activé.';
|
||||
|
||||
@override
|
||||
String get alarmDiagnosticsExactAlarmsTitle =>
|
||||
'Programmation d\'alarme précise';
|
||||
|
||||
@override
|
||||
String get alarmDiagnosticsExactAlarmsHint =>
|
||||
'Permet à l\'alarme de sonner à la minute exacte choisie, même si le téléphone est en veille.';
|
||||
|
||||
@override
|
||||
String get alarmDiagnosticsNotificationsTitle => 'Notifications';
|
||||
|
||||
@override
|
||||
String get alarmDiagnosticsNotificationsHint =>
|
||||
'Nécessaires pour afficher l\'alarme et l\'avis anticipé.';
|
||||
|
||||
@override
|
||||
String get alarmDiagnosticsFullScreenTitle =>
|
||||
'Affichage plein écran de l\'alarme';
|
||||
|
||||
@override
|
||||
String get alarmDiagnosticsFullScreenHint =>
|
||||
'Permet à l\'écran de sonnerie de s\'afficher automatiquement, même si le téléphone est verrouillé.';
|
||||
|
||||
@override
|
||||
String get alarmDiagnosticsBatteryTitle => 'Optimisation de la batterie';
|
||||
|
||||
@override
|
||||
String get alarmDiagnosticsBatteryHint =>
|
||||
'Empêche le système de fermer PluriWave en arrière-plan pour que l\'alarme puisse quand même sonner.';
|
||||
|
||||
@override
|
||||
String get alarmDiagnosticsNativeCountTitle =>
|
||||
'Alarmes enregistrées auprès d\'Android';
|
||||
|
||||
@override
|
||||
String alarmDiagnosticsNativeCountValue(int count) {
|
||||
return 'Actuellement enregistrées : $count';
|
||||
}
|
||||
|
||||
@override
|
||||
String get alarmDiagnosticsNativeCountAttentionHint =>
|
||||
'Une alarme est activée, mais aucune n\'est encore enregistrée auprès du système. Rouvrez PluriWave, ou corrigez d\'abord les points ci-dessus.';
|
||||
|
||||
@override
|
||||
String get alarmDiagnosticsManufacturerLabel => 'Fabricant';
|
||||
|
||||
@override
|
||||
String get alarmDiagnosticsSdkLabel => 'Version d\'Android (SDK)';
|
||||
|
||||
@override
|
||||
String get alarmDiagnosticsNeedsAttentionStatus =>
|
||||
'Nécessite votre attention';
|
||||
|
||||
@override
|
||||
String get alarmDiagnosticsAutostartTitle =>
|
||||
'Encore une étape manuelle sur ce téléphone';
|
||||
|
||||
@override
|
||||
String alarmDiagnosticsAutostartBody(String manufacturer) {
|
||||
return 'Les téléphones $manufacturer ferment souvent les applications en arrière-plan pour économiser la batterie. Aucun réglage ne permet à PluriWave de s\'activer lui-même : vous devez activer vous-même le démarrage automatique (parfois appelé \"Autostart\" ou \"Activité en arrière-plan\") pour PluriWave. Cherchez dans les Paramètres, sous Applications ou Batterie, ou dans l\'application Sécurité du téléphone.';
|
||||
}
|
||||
|
||||
@override
|
||||
String get alarmDiagnosticsFixAction => 'Corriger';
|
||||
|
||||
@override
|
||||
String get alarmDiagnosticsIntentUnavailable =>
|
||||
'Impossible d\'ouvrir cet écran de paramètres sur ce téléphone. Essayez de le trouver manuellement dans les Paramètres.';
|
||||
|
||||
@override
|
||||
String get alarmDiagnosticsUnavailableHint =>
|
||||
'Nous n\'avons pas encore pu vérifier vos paramètres d\'alarme.';
|
||||
|
||||
@override
|
||||
String get autoEqDisableOption => 'Désactiver';
|
||||
}
|
||||
|
||||
@@ -1411,8 +1411,78 @@ class AppLocalizationsHi extends AppLocalizations {
|
||||
String get helpTitle => 'मदद और ट्यूटोरियल';
|
||||
|
||||
@override
|
||||
String get helpSubtitle =>
|
||||
'PluriWave की सुविधाएँ, सुझाव और नया क्या है देखें।';
|
||||
String get helpSubtitle => '9 स्क्रीन · जब चाहें दोबारा देखें';
|
||||
|
||||
@override
|
||||
String get tutorialSkipAction => 'छोड़ें';
|
||||
|
||||
@override
|
||||
String get tutorialNextAction => 'अगला';
|
||||
|
||||
@override
|
||||
String get tutorialPage1Headline =>
|
||||
'अपने स्टेशन सहेजें और उन्हें समूहों में बाँटें';
|
||||
|
||||
@override
|
||||
String get tutorialPage1Body =>
|
||||
'किसी स्टेशन को सहेजने के लिए दिल के आइकॉन पर टैप करें। «आपके स्टेशन» में आप «हर सुबह» या «कार» जैसे समूह बना सकते हैं और उन्हें खींचकर पुनः क्रमबद्ध कर सकते हैं।';
|
||||
|
||||
@override
|
||||
String get tutorialPage2Headline =>
|
||||
'एक सामान्य इक्वलाइज़र, और हर स्टेशन के लिए एक अलग';
|
||||
|
||||
@override
|
||||
String get tutorialPage2Body =>
|
||||
'सेटिंग्स में आप सामान्य इक्वलाइज़र सेट करते हैं। और किसी खास स्टेशन को चलाते समय आप उसे अपनी खुद की सेटिंग दे सकते हैं, जो सामान्य सेटिंग पर प्राथमिकता रखती है।';
|
||||
|
||||
@override
|
||||
String get tutorialPage3Headline => 'जो सुन रहे हैं उसे रिकॉर्ड करें';
|
||||
|
||||
@override
|
||||
String get tutorialPage3Body =>
|
||||
'प्लेयर की टूल ट्रे से, «रिकॉर्ड करें» मूल स्ट्रीम को सहेजता है। अपनी रिकॉर्डिंग सेटिंग्स › रिकॉर्डिंग में पाएँ।';
|
||||
|
||||
@override
|
||||
String get tutorialPage4Headline => 'अलार्म जो आपके अनुसार ढलते हैं';
|
||||
|
||||
@override
|
||||
String get tutorialPage4Body =>
|
||||
'अपने पसंदीदा स्टेशन के साथ जागें, 3, 5 या 10 मिनट के लिए स्नूज़ करें, और छुट्टियों की अवधि जोड़ें ताकि कुछ अलार्म खुद-ब-खुद छूट जाएँ।';
|
||||
|
||||
@override
|
||||
String get tutorialPage5Headline => 'आपके पसंदीदा, कार में भी';
|
||||
|
||||
@override
|
||||
String get tutorialPage5Body =>
|
||||
'अपने फ़ोन को Android Auto से जोड़ें और पाएँ पसंदीदा, सभी स्टेशन, आपके स्टेशन और आपका लोकल म्यूज़िक, ड्राइविंग के लिए बने बड़े बटनों के साथ।';
|
||||
|
||||
@override
|
||||
String get tutorialPage6Headline => 'खुद ही फिर से जुड़ जाता है';
|
||||
|
||||
@override
|
||||
String get tutorialPage6Body =>
|
||||
'अगर सिग्नल कट जाए, तो PluriWave अपने आप फिर कोशिश करता है और बिना कनेक्शन के भी आपके सहेजे गए पसंदीदा दिखाता रहता है।';
|
||||
|
||||
@override
|
||||
String get tutorialPage7Headline => 'चुनें कितनी देर के लिए स्नूज़ करना है';
|
||||
|
||||
@override
|
||||
String get tutorialPage7Body =>
|
||||
'जब कोई अलार्म बजता है, तो «स्नूज़» का कोई एक ही विकल्प नहीं होता: आप उस पल की ज़रूरत के अनुसार 3, 5 या 10 मिनट चुनते हैं।';
|
||||
|
||||
@override
|
||||
String get tutorialPage8Headline => 'नहीं मिल रहा? खुद जोड़ें';
|
||||
|
||||
@override
|
||||
String get tutorialPage8Body =>
|
||||
'«आपके स्टेशन» → कस्टम स्टेशन जोड़ें में, खोज में न मिलने वाले स्टेशन का स्ट्रीम URL पेस्ट करें। यह «आपके स्टेशन» में सहेजा जाता है और कार में भी उपलब्ध रहता है।';
|
||||
|
||||
@override
|
||||
String get tutorialPage9Headline => 'हो गया, अब आप ज़रूरी बातें जानते हैं';
|
||||
|
||||
@override
|
||||
String get tutorialPage9BannerBody =>
|
||||
'इस ट्यूटोरियल को दोबारा देखने के लिए: सेटिंग्स → जानकारी → मदद और ट्यूटोरियल।';
|
||||
|
||||
@override
|
||||
String get indefiniteOption => 'अनिश्चित';
|
||||
@@ -1680,4 +1750,98 @@ class AppLocalizationsHi extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get welcomeCtaLabel => 'सुनना शुरू करें';
|
||||
|
||||
@override
|
||||
String get eqCustomActionEnableLabel => 'इक्वलाइज़र चालू करें';
|
||||
|
||||
@override
|
||||
String get eqCustomActionDisableLabel => 'इक्वलाइज़र बंद करें';
|
||||
|
||||
@override
|
||||
String eqCustomActionPresetLabel(String preset) {
|
||||
return 'प्रीसेट: $preset';
|
||||
}
|
||||
|
||||
@override
|
||||
String get alarmCardVacationPausedBadge => 'छुट्टी के कारण रोका गया';
|
||||
|
||||
@override
|
||||
String get alarmCardSchedulingFailedMessage =>
|
||||
'यह अलार्म सिस्टम में दर्ज नहीं हो सका, इसलिए यह शायद न बजे।';
|
||||
|
||||
@override
|
||||
String get alarmCardPreNoticeFailedMessage =>
|
||||
'यह अलार्म शेड्यूल किया गया है, लेकिन इसकी पूर्व-चेतावनी सेट नहीं हो सकी।';
|
||||
|
||||
@override
|
||||
String get alarmDiagnosticsExactAlarmsTitle => 'सटीक अलार्म शेड्यूलिंग';
|
||||
|
||||
@override
|
||||
String get alarmDiagnosticsExactAlarmsHint =>
|
||||
'फ़ोन के सुप्त मोड में होने पर भी अलार्म को ठीक तय किए गए मिनट पर बजने देता है।';
|
||||
|
||||
@override
|
||||
String get alarmDiagnosticsNotificationsTitle => 'सूचनाएं';
|
||||
|
||||
@override
|
||||
String get alarmDiagnosticsNotificationsHint =>
|
||||
'अलार्म और पूर्व-चेतावनी सूचना दिखाने के लिए ज़रूरी।';
|
||||
|
||||
@override
|
||||
String get alarmDiagnosticsFullScreenTitle =>
|
||||
'अलार्म की फ़ुल-स्क्रीन डिस्प्ले';
|
||||
|
||||
@override
|
||||
String get alarmDiagnosticsFullScreenHint =>
|
||||
'फ़ोन लॉक होने पर भी अलार्म स्क्रीन को अपने आप दिखने देता है।';
|
||||
|
||||
@override
|
||||
String get alarmDiagnosticsBatteryTitle => 'बैटरी ऑप्टिमाइज़ेशन';
|
||||
|
||||
@override
|
||||
String get alarmDiagnosticsBatteryHint =>
|
||||
'सिस्टम को बैकग्राउंड में PluriWave बंद करने से रोकता है, ताकि अलार्म फिर भी बज सके।';
|
||||
|
||||
@override
|
||||
String get alarmDiagnosticsNativeCountTitle => 'Android में दर्ज अलार्म';
|
||||
|
||||
@override
|
||||
String alarmDiagnosticsNativeCountValue(int count) {
|
||||
return 'अभी दर्ज: $count';
|
||||
}
|
||||
|
||||
@override
|
||||
String get alarmDiagnosticsNativeCountAttentionHint =>
|
||||
'आपने एक अलार्म चालू किया है, लेकिन अभी तक कोई भी सिस्टम में दर्ज नहीं हुआ है। PluriWave को दोबारा खोलें, या पहले ऊपर दी गई बातों को ठीक करें।';
|
||||
|
||||
@override
|
||||
String get alarmDiagnosticsManufacturerLabel => 'निर्माता';
|
||||
|
||||
@override
|
||||
String get alarmDiagnosticsSdkLabel => 'Android वर्शन (SDK)';
|
||||
|
||||
@override
|
||||
String get alarmDiagnosticsNeedsAttentionStatus => 'ध्यान देने की ज़रूरत है';
|
||||
|
||||
@override
|
||||
String get alarmDiagnosticsAutostartTitle => 'इस फ़ोन पर एक और मैन्युअल चरण';
|
||||
|
||||
@override
|
||||
String alarmDiagnosticsAutostartBody(String manufacturer) {
|
||||
return '$manufacturer फ़ोन बैटरी बचाने के लिए अक्सर बैकग्राउंड में चल रहे ऐप बंद कर देते हैं। ऐसी कोई सेटिंग नहीं है जिसे PluriWave खुद चालू कर सके — आपको PluriWave के लिए खुद ऑटोस्टार्ट (कभी-कभी \"Auto-start\" या \"बैकग्राउंड एक्टिविटी\" भी कहा जाता है) चालू करना होगा। इसे सेटिंग्स में, ऐप्स या बैटरी के अंदर, या फ़ोन के अपने सिक्योरिटी ऐप में देखें।';
|
||||
}
|
||||
|
||||
@override
|
||||
String get alarmDiagnosticsFixAction => 'ठीक करें';
|
||||
|
||||
@override
|
||||
String get alarmDiagnosticsIntentUnavailable =>
|
||||
'इस फ़ोन पर वह सेटिंग्स स्क्रीन नहीं खोली जा सकी। कृपया सेटिंग्स में इसे खुद ढूंढने की कोशिश करें।';
|
||||
|
||||
@override
|
||||
String get alarmDiagnosticsUnavailableHint =>
|
||||
'हम अभी तक आपकी अलार्म सेटिंग्स जांच नहीं पाए हैं।';
|
||||
|
||||
@override
|
||||
String get autoEqDisableOption => 'बंद करें';
|
||||
}
|
||||
|
||||
@@ -1420,7 +1420,78 @@ class AppLocalizationsId extends AppLocalizations {
|
||||
String get helpTitle => 'Bantuan dan tutorial';
|
||||
|
||||
@override
|
||||
String get helpSubtitle => 'Tinjau fitur, tips, dan hal baru di PluriWave.';
|
||||
String get helpSubtitle => '9 layar · lihat lagi kapan saja';
|
||||
|
||||
@override
|
||||
String get tutorialSkipAction => 'Lewati';
|
||||
|
||||
@override
|
||||
String get tutorialNextAction => 'Berikutnya';
|
||||
|
||||
@override
|
||||
String get tutorialPage1Headline => 'Simpan stasiun Anda dan kelompokkan';
|
||||
|
||||
@override
|
||||
String get tutorialPage1Body =>
|
||||
'Ketuk ikon hati untuk menyimpan stasiun. Di «Stasiun Anda» Anda bisa membuat grup seperti «Setiap pagi» atau «Mobil» dan mengurutkannya ulang dengan menyeret.';
|
||||
|
||||
@override
|
||||
String get tutorialPage2Headline =>
|
||||
'Satu equalizer umum, dan satu lagi per stasiun';
|
||||
|
||||
@override
|
||||
String get tutorialPage2Body =>
|
||||
'Di Pengaturan, Anda menentukan equalizer umum. Dan dari pemutaran stasiun tertentu, Anda bisa memberinya pengaturan sendiri, yang lebih diutamakan daripada pengaturan umum.';
|
||||
|
||||
@override
|
||||
String get tutorialPage3Headline => 'Rekam apa yang sedang Anda dengarkan';
|
||||
|
||||
@override
|
||||
String get tutorialPage3Body =>
|
||||
'Dari baki alat pemutar, «Rekam» menyimpan stream asli. Temukan rekaman Anda di Pengaturan › Rekaman.';
|
||||
|
||||
@override
|
||||
String get tutorialPage4Headline =>
|
||||
'Alarm yang menyesuaikan diri dengan Anda';
|
||||
|
||||
@override
|
||||
String get tutorialPage4Body =>
|
||||
'Bangun dengan stasiun favorit Anda, tunda 3, 5, atau 10 menit, dan tambahkan rentang liburan agar sebagian alarm melewati dirinya sendiri.';
|
||||
|
||||
@override
|
||||
String get tutorialPage5Headline => 'Favorit Anda, juga di mobil';
|
||||
|
||||
@override
|
||||
String get tutorialPage5Body =>
|
||||
'Sambungkan ponsel Anda dengan Android Auto dan temukan Favorit, Semua stasiun, Stasiun Anda, dan Musik Lokal Anda, dengan tombol besar yang dirancang untuk berkendara.';
|
||||
|
||||
@override
|
||||
String get tutorialPage6Headline => 'Tersambung ulang dengan sendirinya';
|
||||
|
||||
@override
|
||||
String get tutorialPage6Body =>
|
||||
'Jika sinyal terputus, PluriWave mencoba lagi secara otomatis dan tetap menampilkan favorit tersimpan Anda meski tanpa koneksi.';
|
||||
|
||||
@override
|
||||
String get tutorialPage7Headline => 'Pilih berapa lama menunda';
|
||||
|
||||
@override
|
||||
String get tutorialPage7Body =>
|
||||
'Saat alarm berbunyi, tidak ada satu «tunda» saja: Anda memilih 3, 5, atau 10 menit sesuai yang Anda butuhkan saat itu.';
|
||||
|
||||
@override
|
||||
String get tutorialPage8Headline => 'Tidak menemukannya? Tambahkan sendiri';
|
||||
|
||||
@override
|
||||
String get tutorialPage8Body =>
|
||||
'Di «Stasiun Anda» → Tambah stasiun khusus, tempel URL stream stasiun yang tidak ada di hasil pencarian. Stasiun ini disimpan di «Stasiun Anda», juga tersedia di mobil.';
|
||||
|
||||
@override
|
||||
String get tutorialPage9Headline => 'Selesai, Anda sudah tahu yang penting';
|
||||
|
||||
@override
|
||||
String get tutorialPage9BannerBody =>
|
||||
'Untuk melihat tutorial ini lagi kapan saja: Pengaturan → Informasi → Bantuan dan tutorial.';
|
||||
|
||||
@override
|
||||
String get indefiniteOption => 'Tidak terbatas';
|
||||
@@ -1688,4 +1759,99 @@ class AppLocalizationsId extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get welcomeCtaLabel => 'Mulai mendengarkan';
|
||||
|
||||
@override
|
||||
String get eqCustomActionEnableLabel => 'Aktifkan equalizer';
|
||||
|
||||
@override
|
||||
String get eqCustomActionDisableLabel => 'Nonaktifkan equalizer';
|
||||
|
||||
@override
|
||||
String eqCustomActionPresetLabel(String preset) {
|
||||
return 'Prasetel: $preset';
|
||||
}
|
||||
|
||||
@override
|
||||
String get alarmCardVacationPausedBadge => 'Dijeda karena liburan';
|
||||
|
||||
@override
|
||||
String get alarmCardSchedulingFailedMessage =>
|
||||
'Alarm ini tidak dapat didaftarkan ke sistem, sehingga mungkin tidak berbunyi.';
|
||||
|
||||
@override
|
||||
String get alarmCardPreNoticeFailedMessage =>
|
||||
'Alarm ini sudah dijadwalkan, tetapi pengingat awalnya tidak dapat diatur.';
|
||||
|
||||
@override
|
||||
String get alarmDiagnosticsExactAlarmsTitle => 'Penjadwalan alarm presisi';
|
||||
|
||||
@override
|
||||
String get alarmDiagnosticsExactAlarmsHint =>
|
||||
'Membuat alarm berbunyi tepat pada menit yang kamu atur, meski ponsel dalam mode tidur.';
|
||||
|
||||
@override
|
||||
String get alarmDiagnosticsNotificationsTitle => 'Notifikasi';
|
||||
|
||||
@override
|
||||
String get alarmDiagnosticsNotificationsHint =>
|
||||
'Diperlukan untuk menampilkan alarm dan pemberitahuan dini.';
|
||||
|
||||
@override
|
||||
String get alarmDiagnosticsFullScreenTitle => 'Tampilan alarm layar penuh';
|
||||
|
||||
@override
|
||||
String get alarmDiagnosticsFullScreenHint =>
|
||||
'Membuat layar alarm muncul otomatis, meski ponsel terkunci.';
|
||||
|
||||
@override
|
||||
String get alarmDiagnosticsBatteryTitle => 'Optimisasi baterai';
|
||||
|
||||
@override
|
||||
String get alarmDiagnosticsBatteryHint =>
|
||||
'Mencegah sistem menutup PluriWave di latar belakang, sehingga alarm tetap bisa berbunyi.';
|
||||
|
||||
@override
|
||||
String get alarmDiagnosticsNativeCountTitle =>
|
||||
'Alarm yang terdaftar di Android';
|
||||
|
||||
@override
|
||||
String alarmDiagnosticsNativeCountValue(int count) {
|
||||
return 'Terdaftar saat ini: $count';
|
||||
}
|
||||
|
||||
@override
|
||||
String get alarmDiagnosticsNativeCountAttentionHint =>
|
||||
'Kamu punya alarm yang aktif, tapi belum ada yang terdaftar di sistem. Buka lagi PluriWave, atau perbaiki dulu poin-poin di atas.';
|
||||
|
||||
@override
|
||||
String get alarmDiagnosticsManufacturerLabel => 'Produsen';
|
||||
|
||||
@override
|
||||
String get alarmDiagnosticsSdkLabel => 'Versi Android (SDK)';
|
||||
|
||||
@override
|
||||
String get alarmDiagnosticsNeedsAttentionStatus => 'Perlu perhatian';
|
||||
|
||||
@override
|
||||
String get alarmDiagnosticsAutostartTitle =>
|
||||
'Satu langkah manual lagi di ponsel ini';
|
||||
|
||||
@override
|
||||
String alarmDiagnosticsAutostartBody(String manufacturer) {
|
||||
return 'Ponsel $manufacturer sering menutup aplikasi yang berjalan di latar belakang untuk menghemat baterai. Tidak ada pengaturan yang bisa diaktifkan PluriWave sendiri — kamu perlu mengaktifkan sendiri Autostart (kadang disebut \"Mulai otomatis\" atau \"Aktivitas latar belakang\") untuk PluriWave. Cari di Pengaturan, di bagian Aplikasi atau Baterai, atau di aplikasi Keamanan bawaan ponsel.';
|
||||
}
|
||||
|
||||
@override
|
||||
String get alarmDiagnosticsFixAction => 'Perbaiki';
|
||||
|
||||
@override
|
||||
String get alarmDiagnosticsIntentUnavailable =>
|
||||
'Tidak bisa membuka layar pengaturan itu di ponsel ini. Coba cari secara manual di Pengaturan.';
|
||||
|
||||
@override
|
||||
String get alarmDiagnosticsUnavailableHint =>
|
||||
'Kami belum bisa memeriksa pengaturan alarmmu.';
|
||||
|
||||
@override
|
||||
String get autoEqDisableOption => 'Nonaktifkan';
|
||||
}
|
||||
|
||||
@@ -1428,7 +1428,77 @@ class AppLocalizationsIt extends AppLocalizations {
|
||||
String get helpTitle => 'Aiuto e tutorial';
|
||||
|
||||
@override
|
||||
String get helpSubtitle => 'Rivedi funzioni, consigli e novità di PluriWave.';
|
||||
String get helpSubtitle => '9 schermate · rivedilo quando vuoi';
|
||||
|
||||
@override
|
||||
String get tutorialSkipAction => 'Salta';
|
||||
|
||||
@override
|
||||
String get tutorialNextAction => 'Avanti';
|
||||
|
||||
@override
|
||||
String get tutorialPage1Headline => 'Salva le tue emittenti e raggruppale';
|
||||
|
||||
@override
|
||||
String get tutorialPage1Body =>
|
||||
'Tocca il cuore per salvare un\'emittente. In «Le tue emittenti» puoi creare gruppi come «Ogni mattina» o «Auto» e riordinarle trascinandole.';
|
||||
|
||||
@override
|
||||
String get tutorialPage2Headline =>
|
||||
'Un equalizzatore di base e uno per emittente';
|
||||
|
||||
@override
|
||||
String get tutorialPage2Body =>
|
||||
'Nelle Impostazioni definisci l\'equalizzatore generale. E dalla riproduzione di un\'emittente specifica puoi darle un\'impostazione propria, che ha la priorità su quella generale.';
|
||||
|
||||
@override
|
||||
String get tutorialPage3Headline => 'Registra ciò che stai ascoltando';
|
||||
|
||||
@override
|
||||
String get tutorialPage3Body =>
|
||||
'Dal vassoio degli strumenti del player, «Registra» salva lo stream originale. Trova le tue registrazioni in Impostazioni › Registrazioni.';
|
||||
|
||||
@override
|
||||
String get tutorialPage4Headline => 'Sveglie che si adattano a te';
|
||||
|
||||
@override
|
||||
String get tutorialPage4Body =>
|
||||
'Svegliati con la tua emittente preferita, posticipa di 3, 5 o 10 minuti, e aggiungi intervalli di vacanza in modo che alcune sveglie si saltino da sole.';
|
||||
|
||||
@override
|
||||
String get tutorialPage5Headline => 'I tuoi preferiti, anche in auto';
|
||||
|
||||
@override
|
||||
String get tutorialPage5Body =>
|
||||
'Collega il telefono con Android Auto e troverai Preferiti, Tutte le emittenti, Le tue emittenti e la tua Musica locale, con pulsanti grandi pensati per la guida.';
|
||||
|
||||
@override
|
||||
String get tutorialPage6Headline => 'Si riconnette da sola';
|
||||
|
||||
@override
|
||||
String get tutorialPage6Body =>
|
||||
'Se il segnale cade, PluriWave riprova automaticamente e continua a mostrare i tuoi preferiti salvati anche senza connessione.';
|
||||
|
||||
@override
|
||||
String get tutorialPage7Headline => 'Scegli quanto posticipare';
|
||||
|
||||
@override
|
||||
String get tutorialPage7Body =>
|
||||
'Quando suona una sveglia, non c\'è un solo «posticipa»: scegli 3, 5 o 10 minuti a seconda di cosa ti serve in quel momento.';
|
||||
|
||||
@override
|
||||
String get tutorialPage8Headline => 'Non la trovi? Aggiungila tu';
|
||||
|
||||
@override
|
||||
String get tutorialPage8Body =>
|
||||
'In «Le tue emittenti» → Aggiungi emittente personalizzata incolla l\'URL dello stream di un\'emittente non presente nella ricerca. Viene salvata in «Le tue emittenti», disponibile anche in auto.';
|
||||
|
||||
@override
|
||||
String get tutorialPage9Headline => 'Fatto, ora conosci l\'essenziale';
|
||||
|
||||
@override
|
||||
String get tutorialPage9BannerBody =>
|
||||
'Per rivedere questo tutorial quando vuoi: Impostazioni → Informazioni → Aiuto e tutorial.';
|
||||
|
||||
@override
|
||||
String get indefiniteOption => 'Indefinita';
|
||||
@@ -1700,4 +1770,101 @@ class AppLocalizationsIt extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get welcomeCtaLabel => 'Inizia ad ascoltare';
|
||||
|
||||
@override
|
||||
String get eqCustomActionEnableLabel => 'Attiva equalizzatore';
|
||||
|
||||
@override
|
||||
String get eqCustomActionDisableLabel => 'Disattiva equalizzatore';
|
||||
|
||||
@override
|
||||
String eqCustomActionPresetLabel(String preset) {
|
||||
return 'Preset attivo: $preset';
|
||||
}
|
||||
|
||||
@override
|
||||
String get alarmCardVacationPausedBadge => 'In pausa per le vacanze';
|
||||
|
||||
@override
|
||||
String get alarmCardSchedulingFailedMessage =>
|
||||
'Questa sveglia non è stata registrata nel sistema, quindi potrebbe non suonare.';
|
||||
|
||||
@override
|
||||
String get alarmCardPreNoticeFailedMessage =>
|
||||
'Questa sveglia è programmata, ma non è stato possibile impostare il promemoria anticipato.';
|
||||
|
||||
@override
|
||||
String get alarmDiagnosticsExactAlarmsTitle =>
|
||||
'Programmazione sveglia esatta';
|
||||
|
||||
@override
|
||||
String get alarmDiagnosticsExactAlarmsHint =>
|
||||
'Permette alla sveglia di suonare esattamente al minuto impostato, anche a telefono in stand-by.';
|
||||
|
||||
@override
|
||||
String get alarmDiagnosticsNotificationsTitle => 'Notifiche';
|
||||
|
||||
@override
|
||||
String get alarmDiagnosticsNotificationsHint =>
|
||||
'Necessarie per mostrare la sveglia e l\'avviso anticipato.';
|
||||
|
||||
@override
|
||||
String get alarmDiagnosticsFullScreenTitle =>
|
||||
'Visualizzazione a schermo intero della sveglia';
|
||||
|
||||
@override
|
||||
String get alarmDiagnosticsFullScreenHint =>
|
||||
'Permette alla schermata della sveglia di apparire automaticamente, anche a telefono bloccato.';
|
||||
|
||||
@override
|
||||
String get alarmDiagnosticsBatteryTitle => 'Ottimizzazione della batteria';
|
||||
|
||||
@override
|
||||
String get alarmDiagnosticsBatteryHint =>
|
||||
'Impedisce al sistema di chiudere PluriWave in background, così la sveglia può comunque suonare.';
|
||||
|
||||
@override
|
||||
String get alarmDiagnosticsNativeCountTitle =>
|
||||
'Sveglie registrate su Android';
|
||||
|
||||
@override
|
||||
String alarmDiagnosticsNativeCountValue(int count) {
|
||||
return 'Attualmente registrate: $count';
|
||||
}
|
||||
|
||||
@override
|
||||
String get alarmDiagnosticsNativeCountAttentionHint =>
|
||||
'Hai una sveglia attiva, ma nessuna è ancora registrata nel sistema. Riapri PluriWave, oppure risolvi prima i punti sopra.';
|
||||
|
||||
@override
|
||||
String get alarmDiagnosticsManufacturerLabel => 'Produttore';
|
||||
|
||||
@override
|
||||
String get alarmDiagnosticsSdkLabel => 'Versione di Android (SDK)';
|
||||
|
||||
@override
|
||||
String get alarmDiagnosticsNeedsAttentionStatus => 'Richiede attenzione';
|
||||
|
||||
@override
|
||||
String get alarmDiagnosticsAutostartTitle =>
|
||||
'Un altro passaggio manuale su questo telefono';
|
||||
|
||||
@override
|
||||
String alarmDiagnosticsAutostartBody(String manufacturer) {
|
||||
return 'I telefoni $manufacturer spesso chiudono le app in background per risparmiare batteria. Non esiste un\'impostazione che PluriWave possa attivare da solo: devi attivare tu stesso l\'Avvio automatico (a volte chiamato \"Autostart\" o \"Attività in background\") per PluriWave. Cercalo nelle Impostazioni, sotto App o Batteria, oppure nell\'app Sicurezza del telefono.';
|
||||
}
|
||||
|
||||
@override
|
||||
String get alarmDiagnosticsFixAction => 'Risolvi';
|
||||
|
||||
@override
|
||||
String get alarmDiagnosticsIntentUnavailable =>
|
||||
'Non è stato possibile aprire questa schermata delle impostazioni su questo telefono. Prova a cercarla manualmente nelle Impostazioni.';
|
||||
|
||||
@override
|
||||
String get alarmDiagnosticsUnavailableHint =>
|
||||
'Non abbiamo ancora potuto controllare le impostazioni della sveglia.';
|
||||
|
||||
@override
|
||||
String get autoEqDisableOption => 'Disattiva';
|
||||
}
|
||||
|
||||
@@ -1368,7 +1368,76 @@ class AppLocalizationsJa extends AppLocalizations {
|
||||
String get helpTitle => 'ヘルプとチュートリアル';
|
||||
|
||||
@override
|
||||
String get helpSubtitle => 'PluriWaveの機能、ヒント、新着情報を確認できます。';
|
||||
String get helpSubtitle => '全9画面・いつでも見返せます';
|
||||
|
||||
@override
|
||||
String get tutorialSkipAction => 'スキップ';
|
||||
|
||||
@override
|
||||
String get tutorialNextAction => '次へ';
|
||||
|
||||
@override
|
||||
String get tutorialPage1Headline => 'お気に入りの局を保存してグループ分け';
|
||||
|
||||
@override
|
||||
String get tutorialPage1Body =>
|
||||
'ハートをタップして局を保存しましょう。「あなたの局」では「毎朝」や「車」などのグループを作り、ドラッグして並べ替えられます。';
|
||||
|
||||
@override
|
||||
String get tutorialPage2Headline => '基本のイコライザーと局ごとのイコライザー';
|
||||
|
||||
@override
|
||||
String get tutorialPage2Body =>
|
||||
'設定で全体のイコライザーを決められます。特定の局を再生中は、その局専用の設定を適用でき、全体設定より優先されます。';
|
||||
|
||||
@override
|
||||
String get tutorialPage3Headline => '聴いている番組を録音';
|
||||
|
||||
@override
|
||||
String get tutorialPage3Body =>
|
||||
'プレーヤーのツールトレイから「録音」をタップすると、元のストリームが保存されます。録音は設定›録音で確認できます。';
|
||||
|
||||
@override
|
||||
String get tutorialPage4Headline => 'あなたに合わせてくれるアラーム';
|
||||
|
||||
@override
|
||||
String get tutorialPage4Body =>
|
||||
'お気に入りの局で目覚め、3分・5分・10分でスヌーズでき、休暇期間を追加すれば一部のアラームを自動でスキップできます。';
|
||||
|
||||
@override
|
||||
String get tutorialPage5Headline => 'お気に入りは車でも';
|
||||
|
||||
@override
|
||||
String get tutorialPage5Body =>
|
||||
'スマートフォンをAndroid Autoに接続すると、お気に入り、すべての局、あなたの局、ローカルミュージックが、運転向けの大きなボタンで使えます。';
|
||||
|
||||
@override
|
||||
String get tutorialPage6Headline => '自動で再接続';
|
||||
|
||||
@override
|
||||
String get tutorialPage6Body =>
|
||||
'電波が途切れても、PluriWaveは自動的に再試行し、接続がなくても保存済みのお気に入りを表示し続けます。';
|
||||
|
||||
@override
|
||||
String get tutorialPage7Headline => 'スヌーズ時間を選べる';
|
||||
|
||||
@override
|
||||
String get tutorialPage7Body =>
|
||||
'アラームが鳴ったとき、「スヌーズ」は一つだけではありません。3分・5分・10分から、その時必要な時間を選べます。';
|
||||
|
||||
@override
|
||||
String get tutorialPage8Headline => '見つからない?自分で追加';
|
||||
|
||||
@override
|
||||
String get tutorialPage8Body =>
|
||||
'「あなたの局」→カスタム局を追加で、検索にない局のストリームURLを貼り付けられます。「あなたの局」に保存され、車でも利用できます。';
|
||||
|
||||
@override
|
||||
String get tutorialPage9Headline => '完了、基本はこれで押さえました';
|
||||
|
||||
@override
|
||||
String get tutorialPage9BannerBody =>
|
||||
'このチュートリアルはいつでも再確認できます:設定 → 情報 → ヘルプとチュートリアル。';
|
||||
|
||||
@override
|
||||
String get indefiniteOption => '無期限';
|
||||
@@ -1631,4 +1700,95 @@ class AppLocalizationsJa extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get welcomeCtaLabel => '聴き始める';
|
||||
|
||||
@override
|
||||
String get eqCustomActionEnableLabel => 'イコライザーをオンにする';
|
||||
|
||||
@override
|
||||
String get eqCustomActionDisableLabel => 'イコライザーをオフにする';
|
||||
|
||||
@override
|
||||
String eqCustomActionPresetLabel(String preset) {
|
||||
return 'プリセット: $preset';
|
||||
}
|
||||
|
||||
@override
|
||||
String get alarmCardVacationPausedBadge => '休暇のため一時停止中';
|
||||
|
||||
@override
|
||||
String get alarmCardSchedulingFailedMessage =>
|
||||
'このアラームはシステムに登録できなかったため、鳴らない可能性があります。';
|
||||
|
||||
@override
|
||||
String get alarmCardPreNoticeFailedMessage =>
|
||||
'このアラームは設定されていますが、事前通知を設定できませんでした。';
|
||||
|
||||
@override
|
||||
String get alarmDiagnosticsExactAlarmsTitle => '正確なアラームのスケジュール設定';
|
||||
|
||||
@override
|
||||
String get alarmDiagnosticsExactAlarmsHint =>
|
||||
'スマートフォンがスリープ中でも、設定した時刻ちょうどにアラームを鳴らせるようにします。';
|
||||
|
||||
@override
|
||||
String get alarmDiagnosticsNotificationsTitle => '通知';
|
||||
|
||||
@override
|
||||
String get alarmDiagnosticsNotificationsHint => 'アラームと事前通知を表示するために必要です。';
|
||||
|
||||
@override
|
||||
String get alarmDiagnosticsFullScreenTitle => 'アラームのフルスクリーン表示';
|
||||
|
||||
@override
|
||||
String get alarmDiagnosticsFullScreenHint =>
|
||||
'画面がロックされていても、アラーム画面が自動的に表示されるようにします。';
|
||||
|
||||
@override
|
||||
String get alarmDiagnosticsBatteryTitle => 'バッテリーの最適化';
|
||||
|
||||
@override
|
||||
String get alarmDiagnosticsBatteryHint =>
|
||||
'システムがPluriWaveをバックグラウンドで終了しないようにし、アラームが確実に鳴るようにします。';
|
||||
|
||||
@override
|
||||
String get alarmDiagnosticsNativeCountTitle => 'Androidに登録されているアラーム';
|
||||
|
||||
@override
|
||||
String alarmDiagnosticsNativeCountValue(int count) {
|
||||
return '現在登録されている数: $count';
|
||||
}
|
||||
|
||||
@override
|
||||
String get alarmDiagnosticsNativeCountAttentionHint =>
|
||||
'アラームは有効になっていますが、まだシステムに登録されていません。PluriWaveを開き直すか、まず上の項目を確認してください。';
|
||||
|
||||
@override
|
||||
String get alarmDiagnosticsManufacturerLabel => '製造元';
|
||||
|
||||
@override
|
||||
String get alarmDiagnosticsSdkLabel => 'Androidのバージョン(SDK)';
|
||||
|
||||
@override
|
||||
String get alarmDiagnosticsNeedsAttentionStatus => '確認が必要です';
|
||||
|
||||
@override
|
||||
String get alarmDiagnosticsAutostartTitle => 'この端末でのもう一つの手動設定';
|
||||
|
||||
@override
|
||||
String alarmDiagnosticsAutostartBody(String manufacturer) {
|
||||
return '$manufacturerのスマートフォンは、バッテリーを節約するためにバックグラウンドのアプリを終了させることがよくあります。PluriWaveが自動でオンにできる設定はありません。PluriWaveの自動起動(「オートスタート」や「バックグラウンド動作」と呼ばれることもあります)を、自分で有効にする必要があります。設定内のアプリまたはバッテリーの項目、あるいは端末のセキュリティアプリを確認してください。';
|
||||
}
|
||||
|
||||
@override
|
||||
String get alarmDiagnosticsFixAction => '修正する';
|
||||
|
||||
@override
|
||||
String get alarmDiagnosticsIntentUnavailable =>
|
||||
'この端末では設定画面を開けませんでした。設定アプリ内で手動で探してみてください。';
|
||||
|
||||
@override
|
||||
String get alarmDiagnosticsUnavailableHint => 'アラームの設定をまだ確認できていません。';
|
||||
|
||||
@override
|
||||
String get autoEqDisableOption => '無効化';
|
||||
}
|
||||
|
||||
@@ -1416,7 +1416,77 @@ class AppLocalizationsPt extends AppLocalizations {
|
||||
String get helpTitle => 'Ajuda e tutorial';
|
||||
|
||||
@override
|
||||
String get helpSubtitle => 'Revê funções, dicas e novidades do PluriWave.';
|
||||
String get helpSubtitle => '9 ecrãs · reveja quando quiser';
|
||||
|
||||
@override
|
||||
String get tutorialSkipAction => 'Pular';
|
||||
|
||||
@override
|
||||
String get tutorialNextAction => 'Avançar';
|
||||
|
||||
@override
|
||||
String get tutorialPage1Headline => 'Guarde as suas estações e agrupe-as';
|
||||
|
||||
@override
|
||||
String get tutorialPage1Body =>
|
||||
'Toque no coração para guardar uma estação. Em «Suas estações» pode criar grupos como «Todas as manhãs» ou «Carro» e reordená-las arrastando.';
|
||||
|
||||
@override
|
||||
String get tutorialPage2Headline =>
|
||||
'Um equalizador geral e outro por estação';
|
||||
|
||||
@override
|
||||
String get tutorialPage2Body =>
|
||||
'Em Definições define o equalizador geral. E, ao reproduzir uma estação específica, pode dar-lhe o seu próprio ajuste, que prevalece sobre o geral.';
|
||||
|
||||
@override
|
||||
String get tutorialPage3Headline => 'Grave o que está a ouvir';
|
||||
|
||||
@override
|
||||
String get tutorialPage3Body =>
|
||||
'Na bandeja de ferramentas do leitor, «Gravar» guarda o stream original. Encontre as suas gravações em Definições › Gravações.';
|
||||
|
||||
@override
|
||||
String get tutorialPage4Headline => 'Alarmes que se adaptam a si';
|
||||
|
||||
@override
|
||||
String get tutorialPage4Body =>
|
||||
'Acorde com a sua estação favorita, adie 3, 5 ou 10 minutos, e adicione períodos de férias para que alguns alarmes se saltem sozinhos.';
|
||||
|
||||
@override
|
||||
String get tutorialPage5Headline => 'Os seus favoritos, também no carro';
|
||||
|
||||
@override
|
||||
String get tutorialPage5Body =>
|
||||
'Ligue o telemóvel ao Android Auto e encontre Favoritos, Todas as estações, Suas estações e a sua Música local, com botões grandes pensados para conduzir.';
|
||||
|
||||
@override
|
||||
String get tutorialPage6Headline => 'Reconecta-se sozinho';
|
||||
|
||||
@override
|
||||
String get tutorialPage6Body =>
|
||||
'Se o sinal cair, o PluriWave tenta novamente de forma automática e continua a mostrar os seus favoritos guardados mesmo sem ligação.';
|
||||
|
||||
@override
|
||||
String get tutorialPage7Headline => 'Escolha quanto tempo adiar';
|
||||
|
||||
@override
|
||||
String get tutorialPage7Body =>
|
||||
'Quando um alarme toca, não existe um único «adiar»: escolhe 3, 5 ou 10 minutos conforme o que precisar nesse momento.';
|
||||
|
||||
@override
|
||||
String get tutorialPage8Headline => 'Não a encontra? Adicione-a você mesmo';
|
||||
|
||||
@override
|
||||
String get tutorialPage8Body =>
|
||||
'Em «Suas estações» → Adicionar estação personalizada, cole o URL do stream de uma estação que não esteja na pesquisa. Fica guardada em «Suas estações», também disponível no carro.';
|
||||
|
||||
@override
|
||||
String get tutorialPage9Headline => 'Pronto, já conhece o essencial';
|
||||
|
||||
@override
|
||||
String get tutorialPage9BannerBody =>
|
||||
'Para rever este tutorial quando quiser: Definições → Informação → Ajuda e tutorial.';
|
||||
|
||||
@override
|
||||
String get indefiniteOption => 'Indefinida';
|
||||
@@ -1688,4 +1758,100 @@ class AppLocalizationsPt extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get welcomeCtaLabel => 'Começar a ouvir';
|
||||
|
||||
@override
|
||||
String get eqCustomActionEnableLabel => 'Ativar equalizador';
|
||||
|
||||
@override
|
||||
String get eqCustomActionDisableLabel => 'Desativar equalizador';
|
||||
|
||||
@override
|
||||
String eqCustomActionPresetLabel(String preset) {
|
||||
return 'Predefinição: $preset';
|
||||
}
|
||||
|
||||
@override
|
||||
String get alarmCardVacationPausedBadge => 'Pausada por férias';
|
||||
|
||||
@override
|
||||
String get alarmCardSchedulingFailedMessage =>
|
||||
'Este alarme não pôde ser registrado no sistema, por isso pode não tocar.';
|
||||
|
||||
@override
|
||||
String get alarmCardPreNoticeFailedMessage =>
|
||||
'Este alarme está agendado, mas seu aviso antecipado não pôde ser configurado.';
|
||||
|
||||
@override
|
||||
String get alarmDiagnosticsExactAlarmsTitle => 'Agendamento exato do alarme';
|
||||
|
||||
@override
|
||||
String get alarmDiagnosticsExactAlarmsHint =>
|
||||
'Permite que o alarme toque no minuto exato definido, mesmo com o telefone em repouso.';
|
||||
|
||||
@override
|
||||
String get alarmDiagnosticsNotificationsTitle => 'Notificações';
|
||||
|
||||
@override
|
||||
String get alarmDiagnosticsNotificationsHint =>
|
||||
'Necessárias para mostrar o alarme e o aviso prévio.';
|
||||
|
||||
@override
|
||||
String get alarmDiagnosticsFullScreenTitle =>
|
||||
'Exibição em tela cheia do alarme';
|
||||
|
||||
@override
|
||||
String get alarmDiagnosticsFullScreenHint =>
|
||||
'Permite que a tela do alarme apareça automaticamente, mesmo com o telefone bloqueado.';
|
||||
|
||||
@override
|
||||
String get alarmDiagnosticsBatteryTitle => 'Otimização de bateria';
|
||||
|
||||
@override
|
||||
String get alarmDiagnosticsBatteryHint =>
|
||||
'Evita que o sistema feche o PluriWave em segundo plano, para que o alarme ainda possa tocar.';
|
||||
|
||||
@override
|
||||
String get alarmDiagnosticsNativeCountTitle =>
|
||||
'Alarmes registrados no Android';
|
||||
|
||||
@override
|
||||
String alarmDiagnosticsNativeCountValue(int count) {
|
||||
return 'Registrados agora: $count';
|
||||
}
|
||||
|
||||
@override
|
||||
String get alarmDiagnosticsNativeCountAttentionHint =>
|
||||
'Você tem um alarme ativado, mas nenhum está registrado no sistema ainda. Reabra o PluriWave ou resolva primeiro os itens acima.';
|
||||
|
||||
@override
|
||||
String get alarmDiagnosticsManufacturerLabel => 'Fabricante';
|
||||
|
||||
@override
|
||||
String get alarmDiagnosticsSdkLabel => 'Versão do Android (SDK)';
|
||||
|
||||
@override
|
||||
String get alarmDiagnosticsNeedsAttentionStatus => 'Precisa de atenção';
|
||||
|
||||
@override
|
||||
String get alarmDiagnosticsAutostartTitle =>
|
||||
'Mais uma etapa manual neste telefone';
|
||||
|
||||
@override
|
||||
String alarmDiagnosticsAutostartBody(String manufacturer) {
|
||||
return 'Telefones $manufacturer costumam fechar apps em segundo plano para economizar bateria. Não existe uma configuração que o PluriWave possa ativar sozinho: você precisa ativar por conta própria o Início automático (às vezes chamado de \"Autostart\" ou \"Atividade em segundo plano\") para o PluriWave. Procure em Configurações, em Apps ou Bateria, ou no próprio app de Segurança do telefone.';
|
||||
}
|
||||
|
||||
@override
|
||||
String get alarmDiagnosticsFixAction => 'Resolver';
|
||||
|
||||
@override
|
||||
String get alarmDiagnosticsIntentUnavailable =>
|
||||
'Não foi possível abrir essa tela de configurações neste telefone. Tente procurá-la manualmente nas Configurações.';
|
||||
|
||||
@override
|
||||
String get alarmDiagnosticsUnavailableHint =>
|
||||
'Ainda não conseguimos verificar as configurações do seu alarme.';
|
||||
|
||||
@override
|
||||
String get autoEqDisableOption => 'Desativar';
|
||||
}
|
||||
|
||||
@@ -1420,7 +1420,79 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
String get helpTitle => 'Помощь и руководство';
|
||||
|
||||
@override
|
||||
String get helpSubtitle => 'Посмотрите функции, советы и новости PluriWave.';
|
||||
String get helpSubtitle => '9 экранов · смотрите снова в любое время';
|
||||
|
||||
@override
|
||||
String get tutorialSkipAction => 'Пропустить';
|
||||
|
||||
@override
|
||||
String get tutorialNextAction => 'Далее';
|
||||
|
||||
@override
|
||||
String get tutorialPage1Headline => 'Сохраняйте станции и группируйте их';
|
||||
|
||||
@override
|
||||
String get tutorialPage1Body =>
|
||||
'Нажмите на сердечко, чтобы сохранить станцию. В разделе «Ваши станции» можно создавать группы, например «Каждое утро» или «Машина», и менять порядок перетаскиванием.';
|
||||
|
||||
@override
|
||||
String get tutorialPage2Headline =>
|
||||
'Общий эквалайзер и отдельный для каждой станции';
|
||||
|
||||
@override
|
||||
String get tutorialPage2Body =>
|
||||
'В настройках вы задаёте общий эквалайзер. А во время воспроизведения конкретной станции можно задать для неё собственную настройку — она будет иметь приоритет над общей.';
|
||||
|
||||
@override
|
||||
String get tutorialPage3Headline => 'Записывайте то, что слушаете';
|
||||
|
||||
@override
|
||||
String get tutorialPage3Body =>
|
||||
'На панели инструментов плеера кнопка «Запись» сохраняет исходный поток. Ваши записи хранятся в разделе Настройки › Записи.';
|
||||
|
||||
@override
|
||||
String get tutorialPage4Headline =>
|
||||
'Будильники, которые подстраиваются под вас';
|
||||
|
||||
@override
|
||||
String get tutorialPage4Body =>
|
||||
'Просыпайтесь под любимую станцию, откладывайте на 3, 5 или 10 минут и добавляйте периоды отпуска, чтобы некоторые будильники пропускались сами.';
|
||||
|
||||
@override
|
||||
String get tutorialPage5Headline =>
|
||||
'Ваши избранные станции — и в машине тоже';
|
||||
|
||||
@override
|
||||
String get tutorialPage5Body =>
|
||||
'Подключите телефон через Android Auto, и вы найдёте Избранное, Все станции, Ваши станции и вашу локальную музыку с крупными кнопками, удобными за рулём.';
|
||||
|
||||
@override
|
||||
String get tutorialPage6Headline => 'Переподключается сам';
|
||||
|
||||
@override
|
||||
String get tutorialPage6Body =>
|
||||
'Если сигнал пропадает, PluriWave автоматически повторяет попытку и продолжает показывать сохранённые избранные станции даже без подключения.';
|
||||
|
||||
@override
|
||||
String get tutorialPage7Headline => 'Выбирайте, на сколько отложить';
|
||||
|
||||
@override
|
||||
String get tutorialPage7Body =>
|
||||
'Когда звонит будильник, нет единственного варианта «отложить»: выбирайте 3, 5 или 10 минут — в зависимости от того, что нужно именно сейчас.';
|
||||
|
||||
@override
|
||||
String get tutorialPage8Headline => 'Не нашли станцию? Добавьте её сами';
|
||||
|
||||
@override
|
||||
String get tutorialPage8Body =>
|
||||
'В разделе «Ваши станции» → Добавить свою станцию вставьте URL потока станции, которой нет в поиске. Она сохранится в «Ваши станции» и будет доступна и в машине.';
|
||||
|
||||
@override
|
||||
String get tutorialPage9Headline => 'Готово, теперь вы знаете самое важное';
|
||||
|
||||
@override
|
||||
String get tutorialPage9BannerBody =>
|
||||
'Чтобы посмотреть этот урок ещё раз: Настройки → Информация → Помощь и руководство.';
|
||||
|
||||
@override
|
||||
String get indefiniteOption => 'Без ограничения';
|
||||
@@ -1692,4 +1764,101 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get welcomeCtaLabel => 'Начать слушать';
|
||||
|
||||
@override
|
||||
String get eqCustomActionEnableLabel => 'Включить эквалайзер';
|
||||
|
||||
@override
|
||||
String get eqCustomActionDisableLabel => 'Выключить эквалайзер';
|
||||
|
||||
@override
|
||||
String eqCustomActionPresetLabel(String preset) {
|
||||
return 'Пресет: $preset';
|
||||
}
|
||||
|
||||
@override
|
||||
String get alarmCardVacationPausedBadge => 'Приостановлено на время отпуска';
|
||||
|
||||
@override
|
||||
String get alarmCardSchedulingFailedMessage =>
|
||||
'Этот будильник не удалось зарегистрировать в системе, поэтому он может не сработать.';
|
||||
|
||||
@override
|
||||
String get alarmCardPreNoticeFailedMessage =>
|
||||
'Этот будильник запланирован, но не удалось настроить предварительное напоминание.';
|
||||
|
||||
@override
|
||||
String get alarmDiagnosticsExactAlarmsTitle =>
|
||||
'Точное планирование будильника';
|
||||
|
||||
@override
|
||||
String get alarmDiagnosticsExactAlarmsHint =>
|
||||
'Будильник звонит ровно в заданную минуту, даже если телефон находится в режиме сна.';
|
||||
|
||||
@override
|
||||
String get alarmDiagnosticsNotificationsTitle => 'Уведомления';
|
||||
|
||||
@override
|
||||
String get alarmDiagnosticsNotificationsHint =>
|
||||
'Нужны, чтобы показать будильник и заблаговременное напоминание.';
|
||||
|
||||
@override
|
||||
String get alarmDiagnosticsFullScreenTitle =>
|
||||
'Полноэкранный показ будильника';
|
||||
|
||||
@override
|
||||
String get alarmDiagnosticsFullScreenHint =>
|
||||
'Экран будильника появляется автоматически, даже если телефон заблокирован.';
|
||||
|
||||
@override
|
||||
String get alarmDiagnosticsBatteryTitle => 'Оптимизация батареи';
|
||||
|
||||
@override
|
||||
String get alarmDiagnosticsBatteryHint =>
|
||||
'Не позволяет системе закрывать PluriWave в фоновом режиме, чтобы будильник мог сработать.';
|
||||
|
||||
@override
|
||||
String get alarmDiagnosticsNativeCountTitle =>
|
||||
'Будильники, зарегистрированные в Android';
|
||||
|
||||
@override
|
||||
String alarmDiagnosticsNativeCountValue(int count) {
|
||||
return 'Сейчас зарегистрировано: $count';
|
||||
}
|
||||
|
||||
@override
|
||||
String get alarmDiagnosticsNativeCountAttentionHint =>
|
||||
'У вас включён будильник, но пока ни один не зарегистрирован в системе. Откройте PluriWave заново или сначала устраните пункты выше.';
|
||||
|
||||
@override
|
||||
String get alarmDiagnosticsManufacturerLabel => 'Производитель';
|
||||
|
||||
@override
|
||||
String get alarmDiagnosticsSdkLabel => 'Версия Android (SDK)';
|
||||
|
||||
@override
|
||||
String get alarmDiagnosticsNeedsAttentionStatus => 'Требует внимания';
|
||||
|
||||
@override
|
||||
String get alarmDiagnosticsAutostartTitle =>
|
||||
'Ещё один ручной шаг на этом телефоне';
|
||||
|
||||
@override
|
||||
String alarmDiagnosticsAutostartBody(String manufacturer) {
|
||||
return 'Телефоны $manufacturer часто закрывают приложения в фоновом режиме для экономии батареи. PluriWave не может включить это самостоятельно — вам нужно вручную включить автозапуск (иногда называется \"Autostart\" или \"Фоновая активность\") для PluriWave. Ищите в Настройках, в разделе Приложения или Батарея, либо в приложении безопасности телефона.';
|
||||
}
|
||||
|
||||
@override
|
||||
String get alarmDiagnosticsFixAction => 'Исправить';
|
||||
|
||||
@override
|
||||
String get alarmDiagnosticsIntentUnavailable =>
|
||||
'Не удалось открыть этот экран настроек на этом телефоне. Попробуйте найти его вручную в Настройках.';
|
||||
|
||||
@override
|
||||
String get alarmDiagnosticsUnavailableHint =>
|
||||
'Мы пока не смогли проверить настройки вашего будильника.';
|
||||
|
||||
@override
|
||||
String get autoEqDisableOption => 'Отключить';
|
||||
}
|
||||
|
||||
@@ -1363,7 +1363,75 @@ class AppLocalizationsZh extends AppLocalizations {
|
||||
String get helpTitle => '帮助和教程';
|
||||
|
||||
@override
|
||||
String get helpSubtitle => '查看 PluriWave 的功能、技巧和新内容。';
|
||||
String get helpSubtitle => '共9屏 · 随时可重新查看';
|
||||
|
||||
@override
|
||||
String get tutorialSkipAction => '跳过';
|
||||
|
||||
@override
|
||||
String get tutorialNextAction => '下一步';
|
||||
|
||||
@override
|
||||
String get tutorialPage1Headline => '保存并整理你的电台';
|
||||
|
||||
@override
|
||||
String get tutorialPage1Body =>
|
||||
'点击心形图标即可保存电台。在“你的电台”中,你可以创建“每天早上”或“车载”等分组,并通过拖动重新排序。';
|
||||
|
||||
@override
|
||||
String get tutorialPage2Headline => '一个通用均衡器,外加每个电台专属的均衡器';
|
||||
|
||||
@override
|
||||
String get tutorialPage2Body =>
|
||||
'在设置中可以调整通用均衡器。播放某个电台时,你还可以为它单独设置均衡器,该设置优先于通用设置。';
|
||||
|
||||
@override
|
||||
String get tutorialPage3Headline => '录制正在收听的内容';
|
||||
|
||||
@override
|
||||
String get tutorialPage3Body =>
|
||||
'在播放器工具栏中点击“录制”,即可保存原始音频流。录音内容可在 设置 › 录音 中查看。';
|
||||
|
||||
@override
|
||||
String get tutorialPage4Headline => '会迁就你的闹钟';
|
||||
|
||||
@override
|
||||
String get tutorialPage4Body =>
|
||||
'用喜欢的电台唤醒自己,可以推迟3分钟、5分钟或10分钟,还能添加假期时间段,让部分闹钟自动跳过。';
|
||||
|
||||
@override
|
||||
String get tutorialPage5Headline => '收藏的电台,车载也能用';
|
||||
|
||||
@override
|
||||
String get tutorialPage5Body =>
|
||||
'将手机连接到Android Auto,即可看到收藏、全部电台、你的电台和本地音乐,大按钮专为驾驶设计。';
|
||||
|
||||
@override
|
||||
String get tutorialPage6Headline => '自动重新连接';
|
||||
|
||||
@override
|
||||
String get tutorialPage6Body =>
|
||||
'信号中断时,PluriWave会自动重试连接,即使暂时没有网络也会继续显示已保存的收藏电台。';
|
||||
|
||||
@override
|
||||
String get tutorialPage7Headline => '自选推迟时长';
|
||||
|
||||
@override
|
||||
String get tutorialPage7Body =>
|
||||
'闹钟响起时,“推迟”并非只有一种选择:你可以根据当下需要选择推迟3分钟、5分钟或10分钟。';
|
||||
|
||||
@override
|
||||
String get tutorialPage8Headline => '找不到?自己添加';
|
||||
|
||||
@override
|
||||
String get tutorialPage8Body =>
|
||||
'在“你的电台”→添加自定义电台中,粘贴搜索结果里没有的电台的流媒体URL即可。它会保存在“你的电台”中,车载模式下同样可用。';
|
||||
|
||||
@override
|
||||
String get tutorialPage9Headline => '完成,你已经了解核心功能';
|
||||
|
||||
@override
|
||||
String get tutorialPage9BannerBody => '想随时重新观看本教程:设置 → 信息 → 帮助与教程。';
|
||||
|
||||
@override
|
||||
String get indefiniteOption => '不限时';
|
||||
@@ -1623,4 +1691,89 @@ class AppLocalizationsZh extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get welcomeCtaLabel => '开始收听';
|
||||
|
||||
@override
|
||||
String get eqCustomActionEnableLabel => '启用均衡器';
|
||||
|
||||
@override
|
||||
String get eqCustomActionDisableLabel => '关闭均衡器';
|
||||
|
||||
@override
|
||||
String eqCustomActionPresetLabel(String preset) {
|
||||
return '预设:$preset';
|
||||
}
|
||||
|
||||
@override
|
||||
String get alarmCardVacationPausedBadge => '因假期已暂停';
|
||||
|
||||
@override
|
||||
String get alarmCardSchedulingFailedMessage => '该闹钟未能在系统中注册,因此可能不会响铃。';
|
||||
|
||||
@override
|
||||
String get alarmCardPreNoticeFailedMessage => '该闹钟已设置,但其提前提醒未能设置成功。';
|
||||
|
||||
@override
|
||||
String get alarmDiagnosticsExactAlarmsTitle => '精确闹钟排程';
|
||||
|
||||
@override
|
||||
String get alarmDiagnosticsExactAlarmsHint => '即使手机处于休眠状态,也能让闹钟在设定的准确时间响起。';
|
||||
|
||||
@override
|
||||
String get alarmDiagnosticsNotificationsTitle => '通知';
|
||||
|
||||
@override
|
||||
String get alarmDiagnosticsNotificationsHint => '显示闹钟和提前提醒需要用到。';
|
||||
|
||||
@override
|
||||
String get alarmDiagnosticsFullScreenTitle => '闹钟全屏显示';
|
||||
|
||||
@override
|
||||
String get alarmDiagnosticsFullScreenHint => '即使手机已锁屏,也能让响铃界面自动出现。';
|
||||
|
||||
@override
|
||||
String get alarmDiagnosticsBatteryTitle => '电池优化';
|
||||
|
||||
@override
|
||||
String get alarmDiagnosticsBatteryHint => '防止系统在后台关闭PluriWave,让闹钟仍然可以响起。';
|
||||
|
||||
@override
|
||||
String get alarmDiagnosticsNativeCountTitle => '已在Android系统注册的闹钟';
|
||||
|
||||
@override
|
||||
String alarmDiagnosticsNativeCountValue(int count) {
|
||||
return '当前已注册:$count';
|
||||
}
|
||||
|
||||
@override
|
||||
String get alarmDiagnosticsNativeCountAttentionHint =>
|
||||
'你已开启一个闹钟,但目前还没有闹钟在系统中注册。请重新打开PluriWave,或先解决上面列出的问题。';
|
||||
|
||||
@override
|
||||
String get alarmDiagnosticsManufacturerLabel => '制造商';
|
||||
|
||||
@override
|
||||
String get alarmDiagnosticsSdkLabel => 'Android版本(SDK)';
|
||||
|
||||
@override
|
||||
String get alarmDiagnosticsNeedsAttentionStatus => '需要注意';
|
||||
|
||||
@override
|
||||
String get alarmDiagnosticsAutostartTitle => '此手机还需要一步手动设置';
|
||||
|
||||
@override
|
||||
String alarmDiagnosticsAutostartBody(String manufacturer) {
|
||||
return '$manufacturer手机经常会关闭后台运行的应用以节省电量。没有任何设置可以让PluriWave自行开启——你需要自己为PluriWave开启自启动(有时也叫\"Autostart\"或\"后台活动\")。请在设置中查找应用或电池选项,或者查看手机自带的安全应用。';
|
||||
}
|
||||
|
||||
@override
|
||||
String get alarmDiagnosticsFixAction => '解决';
|
||||
|
||||
@override
|
||||
String get alarmDiagnosticsIntentUnavailable => '无法在此手机上打开该设置界面。请尝试在设置中手动查找。';
|
||||
|
||||
@override
|
||||
String get alarmDiagnosticsUnavailableHint => '我们还无法检查你的闹钟设置。';
|
||||
|
||||
@override
|
||||
String get autoEqDisableOption => '关闭';
|
||||
}
|
||||
|
||||
+147
-33
@@ -1,4 +1,5 @@
|
||||
import 'dart:async';
|
||||
import 'dart:developer' as developer;
|
||||
import 'dart:ui' as ui;
|
||||
|
||||
import 'package:audio_service/audio_service.dart';
|
||||
@@ -11,6 +12,7 @@ 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_presets_personalizados.dart';
|
||||
import 'tema/pluriwave_tokens.dart';
|
||||
|
||||
const _anchoMinimoLandscape = 600.0;
|
||||
@@ -24,36 +26,110 @@ const androidNotificationIconResource = 'drawable/ic_stat_pluriwave';
|
||||
const configuracionAudioService = AudioServiceConfig(
|
||||
androidNotificationChannelId: 'es.freetimelab.pluriwave.audio',
|
||||
androidNotificationChannelName: 'PluriWave Radio',
|
||||
androidNotificationOngoing: true,
|
||||
androidStopForegroundOnPause: true,
|
||||
// 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();
|
||||
|
||||
// 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.hayCarpetaConfigurada()`.
|
||||
// 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());
|
||||
|
||||
// S3-R4: single SharedPreferences instance resolved once at startup and
|
||||
// injected into every state/service below.
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
|
||||
// Android Auto browse source (Design "getChildren data source, cold-start
|
||||
// safe") — registered BEFORE the AudioService.init await below (Design
|
||||
// "Reorder handler-independent startup work before the init await"):
|
||||
// neither this nor the local-music registration depends on the
|
||||
// AudioHandler, so browse sources exist for the car even while the
|
||||
// MediaBrowser handshake (no native timeout, see arranque_audio.dart) is
|
||||
// still pending.
|
||||
final fuenteAuto = FuenteEmisorasAutoLocal();
|
||||
registrarFuenteNavegacion(fuenteAuto);
|
||||
// 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);
|
||||
|
||||
// Local-music browse source (Design "getChildren data source
|
||||
// registration"), same injectable-prefs DI convention as every other
|
||||
// startup service — required so `_fuenteMusicaLocalGlobal` is ever
|
||||
// non-null; without this registration the local-music root would stay
|
||||
// permanently hidden (`hayCarpetaConfigurada()` has no fuente to ask).
|
||||
registrarFuenteMusicaLocal(FuenteMusicaLocalAutoImpl(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 —
|
||||
@@ -69,6 +145,11 @@ Future<void> main() async {
|
||||
// degraded/late-completion paths below.
|
||||
void conectarHandler(PluriWaveAudioHandler handler) {
|
||||
registrarHandler(handler);
|
||||
// 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());
|
||||
}
|
||||
@@ -102,21 +183,54 @@ Future<void> main() async {
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
/// 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;
|
||||
|
||||
final anchoLogico = displayActivo.size.width / displayActivo.devicePixelRatio;
|
||||
if (anchoLogico < _anchoMinimoLandscape) {
|
||||
await SystemChrome.setPreferredOrientations([DeviceOrientation.portraitUp]);
|
||||
return;
|
||||
/// 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,
|
||||
);
|
||||
}
|
||||
|
||||
await SystemChrome.setPreferredOrientations(DeviceOrientation.values);
|
||||
}
|
||||
|
||||
class _OrientacionResponsiveApp extends StatefulWidget {
|
||||
@@ -143,12 +257,12 @@ class _OrientacionResponsiveAppState extends State<_OrientacionResponsiveApp>
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
_display = View.maybeOf(context)?.display;
|
||||
unawaited(_aplicarPoliticaOrientacion(_display));
|
||||
unawaited(aplicarPoliticaOrientacion(display: _display));
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeMetrics() {
|
||||
unawaited(_aplicarPoliticaOrientacion(_display));
|
||||
unawaited(aplicarPoliticaOrientacion(display: _display));
|
||||
}
|
||||
|
||||
@override
|
||||
|
||||
@@ -302,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(),
|
||||
|
||||
@@ -7,8 +7,8 @@ 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_onboarding_dialog.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
|
||||
@@ -86,7 +86,15 @@ class _CuerpoInfo extends StatelessWidget {
|
||||
title: Text(AppLocalizations.of(ctx).helpTitle),
|
||||
subtitle: Text(AppLocalizations.of(ctx).helpSubtitle),
|
||||
trailing: const Icon(Icons.chevron_right_rounded),
|
||||
onTap: () => PluriOnboardingDialog.mostrar(ctx),
|
||||
onTap:
|
||||
() => Navigator.of(ctx).push(
|
||||
MaterialPageRoute<void>(
|
||||
builder:
|
||||
(_) => const PantallaTutorialAyuda(
|
||||
primerArranque: false,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
ListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
|
||||
@@ -147,7 +147,9 @@ class _AjustesContent extends StatelessWidget {
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
// Issue 3 (feedback-pruebas): t4:523/534/541 all draw a 16px gap
|
||||
// between these opaque, stacked settings groups -- not 12.
|
||||
const SizedBox(height: 16, key: ValueKey('ajustes-group-gap-1')),
|
||||
GrupoAjustes(
|
||||
titulo: l10n.settingsGroupStationsTitle,
|
||||
filas: [
|
||||
@@ -202,7 +204,7 @@ class _AjustesContent extends StatelessWidget {
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
const SizedBox(height: 16, key: ValueKey('ajustes-group-gap-2')),
|
||||
GrupoAjustes(
|
||||
titulo: l10n.settingsGroupRecordingsTitle,
|
||||
filas: [
|
||||
@@ -250,7 +252,7 @@ class _AjustesContent extends StatelessWidget {
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
const SizedBox(height: 16, key: ValueKey('ajustes-group-gap-3')),
|
||||
GrupoAjustes(
|
||||
titulo: l10n.settingsGroupApplicationTitle,
|
||||
filas: [
|
||||
|
||||
@@ -225,25 +225,6 @@ class _PantallaAlarmaSonandoState extends State<PantallaAlarmaSonando> {
|
||||
tokens: tokens,
|
||||
),
|
||||
const SizedBox(height: 22),
|
||||
// Audit 9.4 (t4 line 419): "Lunes, 3 de agosto" between
|
||||
// the pill and the hero time -- never rendered before.
|
||||
// Purely additive: a new sibling Text, touching neither
|
||||
// the pill above nor the hero time below.
|
||||
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),
|
||||
FittedBox(
|
||||
fit: BoxFit.scaleDown,
|
||||
child: Text(
|
||||
@@ -261,6 +242,28 @@ class _PantallaAlarmaSonandoState extends State<PantallaAlarmaSonando> {
|
||||
),
|
||||
),
|
||||
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,
|
||||
@@ -339,14 +342,18 @@ class _PantallaAlarmaSonandoState extends State<PantallaAlarmaSonando> {
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
// 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: 14),
|
||||
const SizedBox(height: 12),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: FilledButton.icon(
|
||||
|
||||
@@ -18,6 +18,7 @@ import '../widgets/pluri_layout.dart';
|
||||
import '../widgets/pluri_push_scaffold.dart';
|
||||
import '../widgets/pluri_root_header.dart';
|
||||
import '../widgets/pluri_sleep_timer_sheet.dart';
|
||||
import 'pantalla_diagnostico_alarmas.dart';
|
||||
import 'pantalla_vacaciones.dart';
|
||||
|
||||
class PantallaAlarmas extends StatelessWidget {
|
||||
@@ -265,6 +266,40 @@ class _TarjetaAlarma extends StatelessWidget {
|
||||
? l10n.noStationUseInternalSound
|
||||
: localizedStationName(l10n, alarma.emisora!.nombre);
|
||||
|
||||
// Item 5: surfaces the genuinely useful fields that already exist on
|
||||
// the model, WITHOUT turning the row into clutter -- each is shown
|
||||
// only when it is a meaningful deviation from the common case.
|
||||
// Mirrors EXACTLY the pause predicate `impactoDeRango`/
|
||||
// `ServicioProgramacionAlarmas` already use
|
||||
// (`!sonarEnVacaciones` while `activa`), gated by whether a vacation
|
||||
// range is CURRENTLY active -- an alarm configured to pause but with
|
||||
// no active range right now is not actually paused by anything yet.
|
||||
final pausadaPorVacaciones =
|
||||
alarma.activa &&
|
||||
!alarma.sonarEnVacaciones &&
|
||||
estado.rangoVacacionesActivo() != null;
|
||||
final detalles = <String>[
|
||||
if (alarma.fadeInSegundos > 0)
|
||||
l10n.alarmFadeInLabel(alarma.fadeInSegundos),
|
||||
if ((alarma.volumen * 100).round() != 85)
|
||||
'${(alarma.volumen * 100).round()}%',
|
||||
if (pausadaPorVacaciones) l10n.alarmCardVacationPausedBadge,
|
||||
];
|
||||
|
||||
// fix/alarmas-fallos-silenciosos: `ultimaExcepcionPara` existed but was
|
||||
// never read from any screen, so a failed native scheduling attempt (main
|
||||
// alarm, pre-notice, foreground service, or a post-boot reschedule)
|
||||
// rendered exactly like a healthy alarm -- switched on, no visible sign
|
||||
// anything was wrong. `_esValida` only ever treats `tipoSaltoSiguiente`
|
||||
// as a real skip, so any OTHER tipo found here is a reliability failure,
|
||||
// never a deliberate user action.
|
||||
final ultimaExcepcion = estado.ultimaExcepcionPara(alarma.id);
|
||||
final fallo =
|
||||
ultimaExcepcion != null &&
|
||||
ExcepcionAlarma.tiposFallo.contains(ultimaExcepcion.tipo)
|
||||
? ultimaExcepcion
|
||||
: null;
|
||||
|
||||
return Dismissible(
|
||||
key: ValueKey('tarjeta-alarma-${alarma.id}'),
|
||||
direction: DismissDirection.horizontal,
|
||||
@@ -309,14 +344,20 @@ class _TarjetaAlarma extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
_recurrenciaCorta(l10n, alarma),
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.onSurface.withValues(alpha: 0.5),
|
||||
// Item 5: real day list can run longer than the
|
||||
// old generic "Días" label -- Flexible+ellipsis
|
||||
// keeps a long selection from overflowing the
|
||||
// Row instead of clipping visibly.
|
||||
Flexible(
|
||||
child: Text(
|
||||
_recurrenciaCorta(l10n, alarma),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: Theme.of(context).colorScheme.onSurface
|
||||
.withValues(alpha: 0.5),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -369,6 +410,35 @@ class _TarjetaAlarma extends StatelessWidget {
|
||||
),
|
||||
],
|
||||
),
|
||||
// Item 5: fade/volume/vacation-pause state, only
|
||||
// when each is a genuinely useful deviation from
|
||||
// the common case (see `detalles` above) -- a
|
||||
// single compact line, not a badge per field.
|
||||
if (detalles.isNotEmpty) ...[
|
||||
const SizedBox(height: 3),
|
||||
Text(
|
||||
detalles.join(' · '),
|
||||
key: ValueKey(
|
||||
'tarjeta-alarma-detalles-${alarma.id}',
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.onSurface.withValues(alpha: 0.55),
|
||||
),
|
||||
),
|
||||
],
|
||||
if (fallo != null) ...[
|
||||
const SizedBox(height: 6),
|
||||
_AvisoFalloProgramacion(
|
||||
alarmaId: alarma.id,
|
||||
esSoloPreaviso:
|
||||
fallo.tipo == ExcepcionAlarma.tipoFalloPreaviso,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -422,6 +492,82 @@ class _TarjetaAlarma extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
/// Per-alarm scheduling-failure notice (fix/alarmas-fallos-silenciosos):
|
||||
/// renders INSIDE the card's own content, in its own small tap target --
|
||||
/// the surrounding card `InkWell` (tap = edit) and `Dismissible` (swipe =
|
||||
/// delete) are untouched; this inner `InkWell` only claims its own region
|
||||
/// and pushes the diagnostics screen instead of opening the editor.
|
||||
class _AvisoFalloProgramacion extends StatelessWidget {
|
||||
const _AvisoFalloProgramacion({
|
||||
required this.alarmaId,
|
||||
required this.esSoloPreaviso,
|
||||
});
|
||||
|
||||
final String alarmaId;
|
||||
|
||||
/// True when only the pre-notice reminder failed (the alarm itself is
|
||||
/// still scheduled) -- the user's reported symptom explicitly called out
|
||||
/// a missing pre-notice as distinct from the alarm never ringing at all,
|
||||
/// so the message must not conflate the two.
|
||||
final bool esSoloPreaviso;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final color = Theme.of(context).colorScheme.error;
|
||||
return Material(
|
||||
type: MaterialType.transparency,
|
||||
child: InkWell(
|
||||
key: ValueKey('tarjeta-alarma-fallo-$alarmaId'),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
onTap: () => _abrirDiagnostico(context),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 2),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Icon(Icons.warning_amber_rounded, size: 15, color: color),
|
||||
const SizedBox(width: 6),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
esSoloPreaviso
|
||||
? l10n.alarmCardPreNoticeFailedMessage
|
||||
: l10n.alarmCardSchedulingFailedMessage,
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: color,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
l10n.androidReliabilityReview,
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w800,
|
||||
color: color,
|
||||
decoration: TextDecoration.underline,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _abrirDiagnostico(BuildContext context) {
|
||||
PluriPushScaffold.push(context, (_) => const PantallaDiagnosticoAlarmas());
|
||||
}
|
||||
}
|
||||
|
||||
/// Swipe-to-delete reveal, shown on both sides so either swipe direction
|
||||
/// works regardless of locale text direction.
|
||||
class _FondoSwipeEliminarAlarma extends StatelessWidget {
|
||||
@@ -1291,6 +1437,16 @@ class _SelectorEmisoraSheetState extends State<_SelectorEmisoraSheet> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Entry point into the full Android alarm-reliability diagnostics screen
|
||||
/// (fix/alarmas-fiabilidad). Was a one-line `TextButton.icon` that only ever
|
||||
/// surfaced 3 of the 6 fields `DiagnosticoAlarmasAndroid` collects (exact
|
||||
/// alarms, notifications, full-screen intent) and cycled all three
|
||||
/// permission requests on a single tap; the two most diagnostic fields --
|
||||
/// battery-optimization exemption and the native pending-alarm count, which
|
||||
/// tells the user whether the alarm ever reached the OS at all -- were
|
||||
/// gathered and never shown. Now a tap target row (mirrors
|
||||
/// `_PanelVacaciones`'s shape) pushing `PantallaDiagnosticoAlarmas`, which
|
||||
/// shows every signal individually with its own fix action.
|
||||
class _AccesoDiagnostico extends StatelessWidget {
|
||||
const _AccesoDiagnostico({required this.estado});
|
||||
|
||||
@@ -1299,47 +1455,37 @@ class _AccesoDiagnostico extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final diag = estado.diagnostico;
|
||||
final exactStatus =
|
||||
diag?.puedeProgramarExactas == true
|
||||
? l10n.statusOk
|
||||
: l10n.statusPending;
|
||||
final notificationStatus =
|
||||
diag?.notificacionesPermitidas == true
|
||||
? l10n.statusOk
|
||||
: l10n.statusPending;
|
||||
final screenStatus =
|
||||
diag?.puedeUsarPantallaCompleta == true
|
||||
? l10n.statusOk
|
||||
: l10n.statusPending;
|
||||
return TextButton.icon(
|
||||
icon: const _AssetIcon(
|
||||
'assets/icons/alarmas/android_reliability.png',
|
||||
size: 28,
|
||||
),
|
||||
label: Text(
|
||||
diag == null
|
||||
? l10n.androidReliabilityTitle
|
||||
: l10n.androidReliabilityStatus(
|
||||
exactStatus,
|
||||
notificationStatus,
|
||||
screenStatus,
|
||||
final tokens = context.pluriTokens;
|
||||
return PluriGlassSurface(
|
||||
padding: EdgeInsets.zero,
|
||||
child: Material(
|
||||
type: MaterialType.transparency,
|
||||
child: InkWell(
|
||||
key: const ValueKey('diagnostico-alarmas-resumen'),
|
||||
borderRadius: BorderRadius.circular(tokens.radiusMd),
|
||||
onTap: () => _abrirDiagnostico(context),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Row(
|
||||
children: [
|
||||
const _AssetIcon(
|
||||
'assets/icons/alarmas/android_reliability.png',
|
||||
size: 28,
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(child: Text(l10n.androidReliabilityReview)),
|
||||
const Icon(Icons.chevron_right_rounded),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
onPressed: () async {
|
||||
if (diag != null && !diag.puedeProgramarExactas) {
|
||||
await estado.android.solicitarPermisoAlarmasExactas();
|
||||
}
|
||||
if (diag != null && !diag.notificacionesPermitidas) {
|
||||
await estado.android.solicitarPermisoNotificaciones();
|
||||
}
|
||||
if (diag != null && !diag.puedeUsarPantallaCompleta) {
|
||||
await estado.android.solicitarPermisoPantallaCompleta();
|
||||
}
|
||||
await estado.cargarDiagnostico();
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
void _abrirDiagnostico(BuildContext context) {
|
||||
PluriPushScaffold.push(context, (_) => const PantallaDiagnosticoAlarmas());
|
||||
}
|
||||
}
|
||||
|
||||
/// Vacation summary row (alarm-vacation-ranges delta, WU8): replaces the old
|
||||
@@ -1668,16 +1814,39 @@ String _weekdayShort(AppLocalizations l10n, int day) => switch (day) {
|
||||
String _fechaCorta(AppLocalizations l10n, DateTime fecha) =>
|
||||
fechaCortaLocalizada(l10n.localeName, fecha);
|
||||
|
||||
/// Audit 7.4 (t4:339): a compact recurrence label next to the alarm card's
|
||||
/// giant time. Reuses the SAME generic labels the editor's own
|
||||
/// `TipoProgramacionAlarma` `SegmentedButton` already shows (`oneTimeOption`
|
||||
/// / `dailyOption` / `weekdaysOption`) rather than inventing a new, more
|
||||
/// specific ARB string -- honest given the space (12px, next to a 34px
|
||||
/// time) genuinely only fits a short word, not a full weekday list.
|
||||
/// Audit 7.4 (t4:339) / item 5: a compact recurrence label next to the
|
||||
/// alarm card's giant time. `diaria`/`unica` still show the SAME generic
|
||||
/// labels the editor's own `TipoProgramacionAlarma` `SegmentedButton`
|
||||
/// already uses (`dailyOption`/`oneTimeOption`) -- both are already fully
|
||||
/// specific (there is nothing more concrete to say than "every day"/"just
|
||||
/// once"). `diasSemana` now renders the alarm's ACTUAL configured days
|
||||
/// (e.g. "Lun, Mié, Vie") instead of the generic `weekdaysOption` ("Días"),
|
||||
/// reusing [_weekdayShort] (the SAME per-day abbreviation the editor's own
|
||||
/// day-picker circles already use) -- no new ARB keys, no second
|
||||
/// formatting scheme, and the resulting Text is wrapped in a
|
||||
/// `Flexible`+ellipsis at the call site so a long selection never
|
||||
/// overflows the row.
|
||||
String _recurrenciaCorta(AppLocalizations l10n, AlarmaMusical alarma) {
|
||||
return switch (alarma.tipoProgramacion) {
|
||||
TipoProgramacionAlarma.diaria => l10n.dailyOption,
|
||||
TipoProgramacionAlarma.diasSemana => l10n.weekdaysOption,
|
||||
TipoProgramacionAlarma.diasSemana => _diasSemanaCorto(
|
||||
l10n,
|
||||
alarma.diasSemana,
|
||||
),
|
||||
TipoProgramacionAlarma.unica => l10n.oneTimeOption,
|
||||
};
|
||||
}
|
||||
|
||||
/// The real, ordered day abbreviations for a `diasSemana` alarm (item 5),
|
||||
/// e.g. "Lun, Mié, Vie". [diasSemana] is re-sorted defensively (the editor
|
||||
/// always persists it sorted, but this does not rely on that). Falls back
|
||||
/// to the generic [AppLocalizations.weekdaysOption] label when
|
||||
/// [diasSemana] is empty -- the editor already blocks saving an empty
|
||||
/// selection in this mode, but a corrupt/legacy persisted record could
|
||||
/// still reach here, and showing nothing would be worse than the old
|
||||
/// generic label.
|
||||
String _diasSemanaCorto(AppLocalizations l10n, List<int> diasSemana) {
|
||||
if (diasSemana.isEmpty) return l10n.weekdaysOption;
|
||||
final ordenados = [...diasSemana]..sort();
|
||||
return ordenados.map((dia) => _weekdayShort(l10n, dia)).join(', ');
|
||||
}
|
||||
|
||||
@@ -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)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -174,12 +174,18 @@ class _PantallaFavoritosState extends State<PantallaFavoritos> {
|
||||
|
||||
return ReorderableListView(
|
||||
buildDefaultDragHandles: false,
|
||||
padding: const EdgeInsets.fromLTRB(
|
||||
PluriLayout.horizontal,
|
||||
4,
|
||||
PluriLayout.horizontal,
|
||||
PluriLayout.bottomChromeInset,
|
||||
),
|
||||
// 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(
|
||||
@@ -224,17 +230,39 @@ class _PantallaFavoritosState extends State<PantallaFavoritos> {
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_FilaChipsGrupos(
|
||||
grupos: gruposVisibles,
|
||||
favoritos: favoritos,
|
||||
seleccionado: seleccionEfectiva,
|
||||
onSeleccionar: (id) => setState(() => _grupoSeleccionadoId = id),
|
||||
// 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(
|
||||
padding: const EdgeInsets.only(top: 4),
|
||||
// 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,
|
||||
),
|
||||
@@ -244,14 +272,24 @@ class _PantallaFavoritosState extends State<PantallaFavoritos> {
|
||||
_onReorder(filtrados, favoritos, oldIndex, newIndex),
|
||||
children: [
|
||||
for (var i = 0; i < filtrados.length; i++)
|
||||
_FilaFavorito(
|
||||
// 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),
|
||||
index: i,
|
||||
emisora: filtrados[i],
|
||||
grupos: gruposVisibles,
|
||||
grupoActual: gruposVisibles.firstWhere(
|
||||
(g) => g.id == filtrados[i].grupoFavoritosId,
|
||||
orElse: () => gruposVisibles.first,
|
||||
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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -343,7 +381,6 @@ class _FilaChipsGrupos extends StatelessWidget {
|
||||
|
||||
class _FilaFavorito extends StatelessWidget {
|
||||
const _FilaFavorito({
|
||||
super.key,
|
||||
required this.index,
|
||||
required this.emisora,
|
||||
required this.grupos,
|
||||
|
||||
@@ -251,7 +251,12 @@ class _PantallaGrabacionesState extends State<PantallaGrabaciones> {
|
||||
padding: PluriLayout.pageContentPadding,
|
||||
children: [
|
||||
_BarraDeAlmacenamiento(archivos: archivos),
|
||||
const SizedBox(height: 12),
|
||||
// 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(
|
||||
|
||||
@@ -134,7 +134,12 @@ class _PantallaPaisesState extends State<PantallaPaises> {
|
||||
),
|
||||
if (query.isEmpty) ...[
|
||||
_seccionTusIdiomas(context, estado.paises, l10n),
|
||||
const SizedBox(height: 16),
|
||||
// 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),
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -842,10 +842,38 @@ class _EditorVacacionesSheetState extends State<_EditorVacacionesSheet> {
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
FilledButton.icon(
|
||||
onPressed: _guardar,
|
||||
icon: const Icon(Icons.check_rounded),
|
||||
label: Text(l10n.saveRangeAction),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: FilledButton.icon(
|
||||
onPressed: _guardar,
|
||||
icon: const Icon(Icons.check_rounded),
|
||||
label: Text(l10n.saveRangeAction),
|
||||
),
|
||||
),
|
||||
// Fix `vacaciones-delete`: only when EDITING an existing
|
||||
// range (never when creating one -- there is nothing to
|
||||
// delete yet). Reuses the exact same confirmation dialog
|
||||
// (`_confirmarEliminarRango`) and deletion method
|
||||
// (`eliminarRangoVacaciones`) the swipe-to-delete gesture
|
||||
// already uses on both `_HeroRangoActivo` and
|
||||
// `_TarjetaRangoVacaciones` -- no new deletion path.
|
||||
if (widget.rango != null) ...[
|
||||
const SizedBox(width: 10),
|
||||
OutlinedButton.icon(
|
||||
key: const ValueKey('vacation-delete-button'),
|
||||
style: OutlinedButton.styleFrom(
|
||||
foregroundColor: Theme.of(context).colorScheme.error,
|
||||
side: BorderSide(
|
||||
color: Theme.of(context).colorScheme.error,
|
||||
),
|
||||
),
|
||||
onPressed: _eliminar,
|
||||
icon: const Icon(Icons.delete_outline_rounded),
|
||||
label: Text(l10n.deleteAction),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -903,6 +931,21 @@ class _EditorVacacionesSheetState extends State<_EditorVacacionesSheet> {
|
||||
}
|
||||
if (mounted) Navigator.pop(context);
|
||||
}
|
||||
|
||||
/// Fix `vacaciones-delete`: mirrors `_guardar`'s pop-on-success shape,
|
||||
/// but confirms first (via the same `_confirmarEliminarRango` dialog the
|
||||
/// swipe gesture uses) and calls `eliminarRangoVacaciones` instead of
|
||||
/// saving. Only reachable when [widget.rango] is non-null (the delete
|
||||
/// button itself is hidden otherwise).
|
||||
Future<void> _eliminar() async {
|
||||
final rango = widget.rango;
|
||||
if (rango == null) return;
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final confirmado = await _confirmarEliminarRango(context, l10n);
|
||||
if (!confirmado || !mounted) return;
|
||||
await context.read<EstadoAlarmas>().eliminarRangoVacaciones(rango.id);
|
||||
if (mounted) Navigator.pop(context);
|
||||
}
|
||||
}
|
||||
|
||||
class _PickerButton extends StatelessWidget {
|
||||
|
||||
@@ -58,6 +58,55 @@ Future<ResultadoArranqueAudio<T>> esperarArranqueAudio<T>(
|
||||
}
|
||||
}
|
||||
|
||||
/// Subscribes to [errores] — in production `AudioService.asyncError` — and
|
||||
/// hands every event to [registrar]. Returns the [StreamSubscription] so the
|
||||
/// caller can cancel it when the handler is torn down.
|
||||
///
|
||||
/// Why this exists: `audio_service` funnels EVERY asynchronous failure of its
|
||||
/// own observers into that stream and nothing else
|
||||
/// (`_observePlaybackState`/`_observeMediaItem`/`_observeQueue` each wrap
|
||||
/// their whole body in `catch (e) { _asyncError.add(e); }`, and the artwork
|
||||
/// path uses `.catchError(_asyncError.add)`), yet this app had ZERO
|
||||
/// subscribers on it. A `PublishSubject` with no listeners simply drops
|
||||
/// events, so the platform-side exception behind "the media notification
|
||||
/// disappeared" — a rejected `setState`, a failed `setMediaItem`, an
|
||||
/// Android 12+ `ForegroundServiceStartNotAllowedException` surfacing through
|
||||
/// the plugin — was being discarded without a single log line. This makes
|
||||
/// that channel audible.
|
||||
///
|
||||
/// [errores] and [registrar] are both injected — this function never touches
|
||||
/// the real `audio_service` plugin, so it is testable with a plain
|
||||
/// [StreamController] (same seam convention as [esperarArranqueAudio] above,
|
||||
/// and as `decidirAvanceCola`/`debeReaplicarEcualizador` elsewhere).
|
||||
StreamSubscription<Object> observarErroresAudio(
|
||||
Stream<Object> errores, {
|
||||
required void Function(Object error) registrar,
|
||||
}) {
|
||||
return errores.listen(
|
||||
registrar,
|
||||
// The plugin only ever feeds this subject through `add`, never
|
||||
// `addError`, so this branch is purely defensive: a stream-level error
|
||||
// would otherwise escape as an unhandled zone error, which is strictly
|
||||
// worse than one more log line.
|
||||
onError: (Object error, StackTrace _) => registrar(error),
|
||||
cancelOnError: false,
|
||||
);
|
||||
}
|
||||
|
||||
/// Default [observarErroresAudio] logger: one line per swallowed plugin
|
||||
/// exception.
|
||||
///
|
||||
/// Uses [debugPrint], NOT `dart:developer`'s `log`. That distinction is the
|
||||
/// whole reason this channel existed for weeks without ever producing a
|
||||
/// single line of evidence: `log()` writes to the VM service, which a
|
||||
/// RELEASE build does not have, so every exception this was built to catch
|
||||
/// was still being thrown away — just one layer further down than before.
|
||||
/// `debugPrint` reaches logcat in release, which is the only build that ever
|
||||
/// runs in the car.
|
||||
void registrarErrorAudioService(Object error) {
|
||||
debugPrint('[PluriWave][ArranqueAudio] AudioService.asyncError: $error');
|
||||
}
|
||||
|
||||
/// Minimal branded bootstrap widget for the degraded path (Design "still
|
||||
/// call runApp, but with a minimal bootstrap widget that keeps waiting on
|
||||
/// the SAME original future"). Shows [_CargandoArranqueAudio] while
|
||||
|
||||
@@ -97,7 +97,5 @@ DecisionAvanceCola decidirAvanceCola({
|
||||
/// distinct `ColaLocal` during the async URI-resolve gap is correctly
|
||||
/// detected as stale and aborts the advance, instead of silently racing an
|
||||
/// external play/stop.
|
||||
bool avanceEsValido(
|
||||
ColaLocal? colaLocalActual,
|
||||
ColaLocal? siguienteEsperado,
|
||||
) => identical(colaLocalActual, siguienteEsperado);
|
||||
bool avanceEsValido(ColaLocal? colaLocalActual, ColaLocal? siguienteEsperado) =>
|
||||
identical(colaLocalActual, siguienteEsperado);
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
import 'servicio_alarmas_android.dart';
|
||||
|
||||
/// One diagnosable Android alarm-reliability signal (fix/alarmas-fiabilidad).
|
||||
///
|
||||
/// [DiagnosticoAlarmasAndroid] already collects six raw fields, but only
|
||||
/// three were ever surfaced in the UI -- the two most diagnostic ones
|
||||
/// (battery-optimization exemption and the native pending-alarm count) were
|
||||
/// gathered and thrown away. This module maps the raw snapshot into a
|
||||
/// stable, ordered list of user-facing signals with a clear ok/needs-
|
||||
/// attention state, decoupled from Flutter/localization so it stays a
|
||||
/// trivial pure-Dart unit to test.
|
||||
enum SenalDiagnosticoAlarma {
|
||||
alarmasExactas,
|
||||
notificaciones,
|
||||
pantallaCompleta,
|
||||
optimizacionBateria,
|
||||
alarmasNativasPendientes,
|
||||
}
|
||||
|
||||
enum EstadoSenalDiagnostico { ok, atencion }
|
||||
|
||||
/// The system screen a "fix this" action should open for a given signal.
|
||||
/// `ninguna` marks signals with no actionable system screen of their own
|
||||
/// (`alarmasNativasPendientes` is informational -- fixing the OTHER signals
|
||||
/// above is what makes it recover).
|
||||
enum AccionDiagnosticoAlarma {
|
||||
abrirAlarmasExactas,
|
||||
abrirNotificaciones,
|
||||
abrirOptimizacionBateria,
|
||||
abrirPantallaCompleta,
|
||||
ninguna,
|
||||
}
|
||||
|
||||
class ItemDiagnosticoAlarma {
|
||||
const ItemDiagnosticoAlarma({
|
||||
required this.senal,
|
||||
required this.estado,
|
||||
required this.accion,
|
||||
});
|
||||
|
||||
final SenalDiagnosticoAlarma senal;
|
||||
final EstadoSenalDiagnostico estado;
|
||||
final AccionDiagnosticoAlarma accion;
|
||||
|
||||
bool get requiereAtencion => estado == EstadoSenalDiagnostico.atencion;
|
||||
}
|
||||
|
||||
/// Builds the five diagnosable signals in a FIXED, stable order so the
|
||||
/// screen renders them consistently every time.
|
||||
///
|
||||
/// [hayAlarmasActivas] contextualizes `alarmasNativasPendientes`: a fresh
|
||||
/// install with zero alarms turned on has nothing to register with the OS,
|
||||
/// so a `0` count there is only meaningful once the user actually has an
|
||||
/// active alarm -- THAT combination is direct evidence the alarm never
|
||||
/// reached the operating system at all, which is the single most useful
|
||||
/// signal for the reported "alarm never rings" failure mode.
|
||||
List<ItemDiagnosticoAlarma> construirItemsDiagnosticoAlarmas({
|
||||
required DiagnosticoAlarmasAndroid diagnostico,
|
||||
required bool hayAlarmasActivas,
|
||||
}) {
|
||||
EstadoSenalDiagnostico desde(bool ok) =>
|
||||
ok ? EstadoSenalDiagnostico.ok : EstadoSenalDiagnostico.atencion;
|
||||
|
||||
final alarmasNativasOk =
|
||||
!hayAlarmasActivas || diagnostico.alarmasNativasPendientes > 0;
|
||||
|
||||
return [
|
||||
ItemDiagnosticoAlarma(
|
||||
senal: SenalDiagnosticoAlarma.alarmasExactas,
|
||||
estado: desde(diagnostico.puedeProgramarExactas),
|
||||
accion: AccionDiagnosticoAlarma.abrirAlarmasExactas,
|
||||
),
|
||||
ItemDiagnosticoAlarma(
|
||||
senal: SenalDiagnosticoAlarma.notificaciones,
|
||||
estado: desde(diagnostico.notificacionesPermitidas),
|
||||
accion: AccionDiagnosticoAlarma.abrirNotificaciones,
|
||||
),
|
||||
ItemDiagnosticoAlarma(
|
||||
senal: SenalDiagnosticoAlarma.pantallaCompleta,
|
||||
estado: desde(diagnostico.puedeUsarPantallaCompleta),
|
||||
accion: AccionDiagnosticoAlarma.abrirPantallaCompleta,
|
||||
),
|
||||
ItemDiagnosticoAlarma(
|
||||
senal: SenalDiagnosticoAlarma.optimizacionBateria,
|
||||
estado: desde(diagnostico.ignoraOptimizacionBateria),
|
||||
accion: AccionDiagnosticoAlarma.abrirOptimizacionBateria,
|
||||
),
|
||||
ItemDiagnosticoAlarma(
|
||||
senal: SenalDiagnosticoAlarma.alarmasNativasPendientes,
|
||||
estado: desde(alarmasNativasOk),
|
||||
accion: AccionDiagnosticoAlarma.ninguna,
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
/// Manufacturers/sub-brands known for aggressive background-process killing
|
||||
/// that requires the user to manually enable "Autostart" (or the vendor's
|
||||
/// own equivalent toggle) -- there is NO public Android API to detect or
|
||||
/// grant this setting programmatically, so the app can only explain it.
|
||||
const _fabricantesConGuiaAutostart = [
|
||||
'xiaomi',
|
||||
'redmi',
|
||||
'poco',
|
||||
'huawei',
|
||||
'oppo',
|
||||
'vivo',
|
||||
'oneplus',
|
||||
'samsung',
|
||||
];
|
||||
|
||||
/// Whether [fabricante] (`Build.MANUFACTURER`, e.g. "Xiaomi", "POCO") is a
|
||||
/// known aggressive-background-killer vendor that needs the manual autostart
|
||||
/// explanation. Case-insensitive substring match, since `Build.MANUFACTURER`
|
||||
/// values are not fully standardized across sub-brands/regions/builds.
|
||||
bool fabricanteRequiereGuiaAutostart(String fabricante) {
|
||||
final normalizado = fabricante.trim().toLowerCase();
|
||||
if (normalizado.isEmpty) return false;
|
||||
return _fabricantesConGuiaAutostart.any(normalizado.contains);
|
||||
}
|
||||
@@ -61,9 +61,10 @@ String nombreCarpetaDesdeUri(String treeUri, {required String nombreGenerico}) {
|
||||
segmento = documentId.substring(ultimaBarra + 1);
|
||||
} else {
|
||||
final ultimosDosPuntos = documentId.lastIndexOf(':');
|
||||
segmento = ultimosDosPuntos >= 0
|
||||
? documentId.substring(ultimosDosPuntos + 1)
|
||||
: documentId;
|
||||
segmento =
|
||||
ultimosDosPuntos >= 0
|
||||
? documentId.substring(ultimosDosPuntos + 1)
|
||||
: documentId;
|
||||
}
|
||||
|
||||
final recortado = segmento.trim();
|
||||
@@ -200,10 +201,9 @@ class FuenteMusicaLocalAutoImpl implements FuenteMusicaLocalAuto {
|
||||
try {
|
||||
final uri = await _uriPersistida();
|
||||
if (uri == null || uri.isEmpty) return false;
|
||||
final valido = await _canal.invokeMethod<bool>(
|
||||
'hasPersistedPermission',
|
||||
{'treeUri': uri},
|
||||
);
|
||||
final valido = await _canal.invokeMethod<bool>('hasPersistedPermission', {
|
||||
'treeUri': uri,
|
||||
});
|
||||
return valido ?? false;
|
||||
} catch (_) {
|
||||
// Cold-start / revoked-permission safety (Spec "Permission revoked or
|
||||
|
||||
@@ -6,10 +6,10 @@ import 'package:audio_service/audio_service.dart';
|
||||
import 'package:flutter/foundation.dart' show visibleForTesting;
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
|
||||
import '../estado/orden_emisoras.dart';
|
||||
import '../modelos/emisora.dart';
|
||||
import '../modelos/grupo_favoritos.dart';
|
||||
import '../modelos/pista_local.dart';
|
||||
import '../modelos/preset_ecualizador.dart';
|
||||
import 'musica_local_auto.dart';
|
||||
import 'persistencia_tolerante.dart';
|
||||
import 'servicio_favoritos.dart';
|
||||
@@ -19,16 +19,37 @@ import 'servicio_favoritos.dart';
|
||||
/// no [NodoLocal] coupling — so a future paged folder type can reuse the
|
||||
/// slice arithmetic directly. An empty [items] or a [pagina] beyond the
|
||||
/// list's range returns `[]`, never throws.
|
||||
List<T> paginaDe<T>(List<T> items, {required int pagina, required int tamano}) =>
|
||||
items.skip(pagina * tamano).take(tamano).toList();
|
||||
List<T> paginaDe<T>(
|
||||
List<T> items, {
|
||||
required int pagina,
|
||||
required int tamano,
|
||||
}) => items.skip(pagina * tamano).take(tamano).toList();
|
||||
|
||||
/// Whether a page after [pagina] exists for a list of [total] elements
|
||||
/// (Design ADR-6): `true` iff at least one element remains beyond the
|
||||
/// current page's slice. The exact-boundary case
|
||||
/// (`total == (pagina + 1) * tamano`) is `false` — nothing remains to
|
||||
/// reveal.
|
||||
bool hayPaginaSiguiente(int total, {required int pagina, required int tamano}) =>
|
||||
total > (pagina + 1) * tamano;
|
||||
bool hayPaginaSiguiente(
|
||||
int total, {
|
||||
required int pagina,
|
||||
required int tamano,
|
||||
}) => total > (pagina + 1) * tamano;
|
||||
|
||||
/// Browse-tree ordering comparator for a local-music folder's children
|
||||
/// (Design "Directories before files", item 1): directories sort before
|
||||
/// files regardless of name, and within each group, alphabetically by
|
||||
/// [NodoLocal.nombre] -- the standard file-browser convention. Fixes a
|
||||
/// driver-facing bug where a folder's subfolders could land on a later
|
||||
/// "Más…" page whenever enough tracks sorted alphabetically ahead of them
|
||||
/// (e.g. a "Live" subfolder behind 80 numbered tracks), making the
|
||||
/// subfolder unreachable without paging through every track first.
|
||||
int compararNodoLocalParaNavegacion(NodoLocal a, NodoLocal b) {
|
||||
if (a.esDirectorio != b.esDirectorio) {
|
||||
return a.esDirectorio ? -1 : 1;
|
||||
}
|
||||
return a.nombre.compareTo(b.nombre);
|
||||
}
|
||||
|
||||
const _prefijoEmisora = 'emisora:';
|
||||
|
||||
@@ -68,8 +89,7 @@ bool faviconUsable(String? favicon) {
|
||||
// even with an empty host (e.g. `Uri.parse('http://').hasAuthority` is
|
||||
// `true`) — check `host.isNotEmpty` explicitly to actually require a
|
||||
// non-empty authority host.
|
||||
return (uri.scheme == 'http' || uri.scheme == 'https') &&
|
||||
uri.host.isNotEmpty;
|
||||
return (uri.scheme == 'http' || uri.scheme == 'https') && uri.host.isNotEmpty;
|
||||
}
|
||||
|
||||
/// Deterministic rotation index over the 4 on-brand fallback arts, same
|
||||
@@ -83,10 +103,11 @@ int indiceArtePara(String seed) =>
|
||||
/// drawable URI selected via [indiceArtePara] over `e.uuid` — the same
|
||||
/// on-brand art the phone UI would pick for this station (per-station
|
||||
/// parity), never a launcher-icon lookalike.
|
||||
String artUriPara(Emisora e) => faviconUsable(e.favicon)
|
||||
? e.favicon!
|
||||
: 'android.resource://es.freetimelab.pluriwave/drawable/'
|
||||
'station_art_${_nombresArte[indiceArtePara(e.uuid)]}';
|
||||
String artUriPara(Emisora e) =>
|
||||
faviconUsable(e.favicon)
|
||||
? e.favicon!
|
||||
: 'android.resource://es.freetimelab.pluriwave/drawable/'
|
||||
'station_art_${_nombresArte[indiceArtePara(e.uuid)]}';
|
||||
|
||||
/// Formats a human-readable audio-quality hint for the browse row's
|
||||
/// `displaySubtitle` (Design Decision "`displaySubtitle` quality format"):
|
||||
@@ -197,6 +218,17 @@ class ConstructorArbolAuto {
|
||||
/// (Design "Local root hidden until a folder is configured").
|
||||
static const idMusicaLocal = 'musica_local';
|
||||
|
||||
/// Root folder id for the "Ecualizador" browsable folder (decision
|
||||
/// `auto/ecualizador-diseno`): lists "Desactivar" plus the six factory
|
||||
/// presets, the currently-active one marked. Deliberately NOT added to
|
||||
/// [_idsCarpetas] -- like [idMusicaLocal], it has its own dedicated
|
||||
/// children, built by `itemsEcualizadorAuto` in `servicio_audio.dart`
|
||||
/// (which needs `AppLocalizations` -- this pure builder class does not
|
||||
/// depend on it), not the generic station-list [hijos] path. Unlike
|
||||
/// [idMusicaLocal], it is ALWAYS present in [raiz], never conditionally
|
||||
/// hidden.
|
||||
static const idEcualizador = 'ecualizador';
|
||||
|
||||
static const _idsCarpetas = {idFavoritos, idTodas, idMisEmisoras};
|
||||
static const _maxItemsPorCarpeta = 50;
|
||||
|
||||
@@ -280,12 +312,22 @@ class ConstructorArbolAuto {
|
||||
};
|
||||
|
||||
/// The root folders (Favoritos, Todas las emisoras, Mis emisoras,
|
||||
/// optionally Música Local), all non-playable.
|
||||
/// optionally Música Local, Ecualizador), all non-playable.
|
||||
///
|
||||
/// There is deliberately no equalizer folder: EQ is configured on the phone
|
||||
/// only. The car still gets the right sound, because the per-device preset
|
||||
/// is applied automatically when the output device changes — that lives in
|
||||
/// `EstadoEcualizador`, not in this tree.
|
||||
/// Decision `auto/ecualizador-diseno` SUPERSEDES the "no equalizer
|
||||
/// There is NO `Ecualizador` folder. The car's only equalizer control is
|
||||
/// the on/off custom action on the playback screen
|
||||
/// (`controlesEcualizadorPersonalizados` in `servicio_audio.dart`), which
|
||||
/// the driver reaches from all three player views without leaving them.
|
||||
///
|
||||
/// The folder existed briefly (`8423ccd`) because custom actions were
|
||||
/// thought unable to convey enough state for a six-preset choice. Owner
|
||||
/// decision after driving with it: a browsable preset list is more
|
||||
/// interaction than a driver wants, and on/off is the only equalizer
|
||||
/// control that belongs in a car. Preset selection stays on the phone.
|
||||
/// This lands back on the redesign mockup's original rule ("sin carpeta de
|
||||
/// ecualizador", turn t4 line 40), now for a road-tested reason rather than
|
||||
/// an assumed one.
|
||||
///
|
||||
/// `Música Local` is OMITTED entirely (not just empty) unless
|
||||
/// [incluirMusicaLocal] is `true` (Design "Local root hidden until a folder
|
||||
@@ -305,15 +347,23 @@ class ConstructorArbolAuto {
|
||||
extras: _contentStyleLista,
|
||||
);
|
||||
|
||||
/// Leaf items for [parentId], sorted via [ordenarEmisoras] and capped at
|
||||
/// [_maxItemsPorCarpeta] (Design "which stations surface & ordering" —
|
||||
/// avoids driver distraction and Auto list limits). Unknown [parentId]
|
||||
/// (or an empty [emisoras]) returns an empty list instead of throwing.
|
||||
/// Leaf items for [parentId], PRESERVING the incoming [emisoras] order and
|
||||
/// capped at [_maxItemsPorCarpeta] (Design "which stations surface &
|
||||
/// ordering" — avoids driver distraction and Auto list limits).
|
||||
///
|
||||
/// Fix `android-auto-orden`: this used to force
|
||||
/// `ordenarEmisoras(emisoras, OrdenEmisoras.calidad)` unconditionally,
|
||||
/// silently discarding whatever order the caller actually wanted —
|
||||
/// Favoritos' manual drag-reorder order, or the global `ordenListas`
|
||||
/// setting for Todas/Mis emisoras. Every caller (`EstadoRadio.
|
||||
/// cargarFavoritos`/`cargarPopulares`/`_cargarEmisorasCustom`/
|
||||
/// `cambiarOrdenListas`) now pushes an already-ordered snapshot, so this
|
||||
/// only slices and maps — it must never re-sort. Unknown [parentId] (or
|
||||
/// an empty [emisoras]) returns an empty list instead of throwing.
|
||||
List<MediaItem> hijos(String parentId, {required List<Emisora> emisoras}) {
|
||||
if (!_idsCarpetas.contains(parentId)) return const [];
|
||||
if (emisoras.isEmpty) return const [];
|
||||
final ordenadas = ordenarEmisoras(emisoras, OrdenEmisoras.calidad);
|
||||
return ordenadas.take(_maxItemsPorCarpeta).map(itemEmisora).toList();
|
||||
return emisoras.take(_maxItemsPorCarpeta).map(itemEmisora).toList();
|
||||
}
|
||||
|
||||
/// Maps a single [Emisora] to a playable `MediaItem`: id `emisora:<uuid>`
|
||||
@@ -432,14 +482,21 @@ class ConstructorArbolAuto {
|
||||
int tamano = _maxItemsCarpetaLocal,
|
||||
@visibleForTesting
|
||||
MediaItem Function(NodoLocal, Map<String, MetadatosPista>)? construirItem,
|
||||
// Item 2 (recursive folder play): optional so every pre-existing call
|
||||
// site/test that has no need for the recursive gate keeps working
|
||||
// unchanged. Only used on page 0, and only when [nodos] has zero
|
||||
// DIRECT tracks (a direct track already makes the gate cheaply true
|
||||
// without it) — see the `hayContenidoReproducible` computation below.
|
||||
FuenteMusicaLocalAuto? fuente,
|
||||
}) async {
|
||||
final construir = construirItem ?? _itemLocal;
|
||||
final ordenados = [...nodos]..sort((a, b) => a.nombre.compareTo(b.nombre));
|
||||
final ordenados = [...nodos]..sort(compararNodoLocalParaNavegacion);
|
||||
final paginaActual = paginaDe(ordenados, pagina: pagina, tamano: tamano);
|
||||
final docIds = paginaActual
|
||||
.where((n) => !n.esDirectorio)
|
||||
.map((n) => n.documentId)
|
||||
.toList();
|
||||
final docIds =
|
||||
paginaActual
|
||||
.where((n) => !n.esDirectorio)
|
||||
.map((n) => n.documentId)
|
||||
.toList();
|
||||
final metadatos = await metadatosDe(docIds);
|
||||
final items = paginaActual.map((n) => construir(n, metadatos)).toList();
|
||||
if (hayPaginaSiguiente(ordenados.length, pagina: pagina, tamano: tamano)) {
|
||||
@@ -447,14 +504,25 @@ class ConstructorArbolAuto {
|
||||
}
|
||||
if (pagina == 0) {
|
||||
final totalPistas = nodos.where((n) => !n.esDirectorio).length;
|
||||
// Item 2: a folder plays everything beneath it, recursively -- so
|
||||
// the play actions must be offered whenever the RECURSIVE count is
|
||||
// > 0, not just the direct count. `totalPistas > 0` short-circuits
|
||||
// the bounded recursive walk entirely for the common case (a direct
|
||||
// track already answers the question); only a folder with ZERO
|
||||
// direct tracks but at least one subfolder pays the recursive-check
|
||||
// cost, and only up to [profundidadMaximaRecursivaLocal] levels.
|
||||
final hayContenidoReproducible =
|
||||
totalPistas > 0 ||
|
||||
(fuente != null &&
|
||||
await _haySubcarpetaConPistas(nodos, fuente: fuente));
|
||||
final prepend = <MediaItem>[
|
||||
// Folder-play actions (Design ADR-5, Phase 3): prepended BEFORE
|
||||
// the sort/bucket nav entries, guarded the same shape as
|
||||
// ofreceOrdenCalidad(totalPistas > 0) — present iff the folder has
|
||||
// at least one direct audio child, absent for a folder with only
|
||||
// subfolders (Spec "Folder has no tracks").
|
||||
if (totalPistas > 0) _itemReproducirCarpeta(documentIdPadre),
|
||||
if (totalPistas > 0) _itemReproducirAleatorio(documentIdPadre),
|
||||
// Folder-play actions (Design ADR-5, Phase 3; recursive gate item
|
||||
// 2): prepended BEFORE the sort/bucket nav entries, present iff
|
||||
// the folder has at least one playable track anywhere beneath it
|
||||
// (direct or nested), absent for a folder that is genuinely empty
|
||||
// even recursively (Spec "Folder has no tracks").
|
||||
if (hayContenidoReproducible) _itemReproducirCarpeta(documentIdPadre),
|
||||
if (hayContenidoReproducible) _itemReproducirAleatorio(documentIdPadre),
|
||||
if (ofreceOrdenCalidad(totalPistas))
|
||||
_itemModoOrdenCalidad(documentIdPadre),
|
||||
if (ofreceBuckets(totalPistas))
|
||||
@@ -466,6 +534,32 @@ class ConstructorArbolAuto {
|
||||
return items;
|
||||
}
|
||||
|
||||
/// Whether at least one subfolder within [nodos] recursively contains a
|
||||
/// playable track (Design "recursive folder play, gate", item 2): called
|
||||
/// ONLY when the folder has zero DIRECT tracks (the caller already
|
||||
/// checked that cheaply) — descends into each direct subfolder via
|
||||
/// [pistasRecursivas] with `limite: 1`, stopping at the very first
|
||||
/// match so a folder with an early hit costs as little as possible.
|
||||
/// [nodos] is assumed already resolved by the caller (its own
|
||||
/// `fuente.hijos(...)` result), so this folder's own children are never
|
||||
/// re-fetched.
|
||||
Future<bool> _haySubcarpetaConPistas(
|
||||
List<NodoLocal> nodos, {
|
||||
required FuenteMusicaLocalAuto fuente,
|
||||
}) async {
|
||||
for (final nodo in nodos) {
|
||||
if (!nodo.esDirectorio) continue;
|
||||
final encontradas = await pistasRecursivas(
|
||||
nodo.documentId,
|
||||
fuente: fuente,
|
||||
profundidadMaxima: profundidadMaximaRecursivaLocal - 1,
|
||||
limite: 1,
|
||||
);
|
||||
if (encontradas.isNotEmpty) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// Whether the "Ordenar por calidad" mode entry should be offered for a
|
||||
/// folder with [totalPistas] audio files (Design ADR-3): present for
|
||||
/// `0 < totalPistas <= 150`, omitted otherwise (empty folder or above the
|
||||
@@ -666,10 +760,11 @@ class ConstructorArbolAuto {
|
||||
final ordenados = [...buckets[idxBucket].nodos]
|
||||
..sort((a, b) => a.nombre.compareTo(b.nombre));
|
||||
final paginaActual = paginaDe(ordenados, pagina: pagina, tamano: tamano);
|
||||
final docIds = paginaActual
|
||||
.where((n) => !n.esDirectorio)
|
||||
.map((n) => n.documentId)
|
||||
.toList();
|
||||
final docIds =
|
||||
paginaActual
|
||||
.where((n) => !n.esDirectorio)
|
||||
.map((n) => n.documentId)
|
||||
.toList();
|
||||
final metadatos = await metadatosDe(docIds);
|
||||
final items = paginaActual.map((n) => construir(n, metadatos)).toList();
|
||||
if (hayPaginaSiguiente(ordenados.length, pagina: pagina, tamano: tamano)) {
|
||||
@@ -684,20 +779,21 @@ class ConstructorArbolAuto {
|
||||
}
|
||||
final meta = metadatos[nodo.documentId];
|
||||
final tituloMeta = meta?.titulo?.trim();
|
||||
final titulo = (tituloMeta != null && tituloMeta.isNotEmpty)
|
||||
? tituloMeta
|
||||
: _tituloDesdeNombre(nodo.nombre);
|
||||
final titulo =
|
||||
(tituloMeta != null && tituloMeta.isNotEmpty)
|
||||
? tituloMeta
|
||||
: _tituloDesdeNombre(nodo.nombre);
|
||||
final artUriMeta = meta?.artUri?.trim();
|
||||
final artUri = (artUriMeta != null && artUriMeta.isNotEmpty)
|
||||
? artUriMeta
|
||||
: artUriLocal(nodo.documentId);
|
||||
final artUri =
|
||||
(artUriMeta != null && artUriMeta.isNotEmpty)
|
||||
? artUriMeta
|
||||
: artUriLocal(nodo.documentId);
|
||||
final artistaMeta = meta?.artista?.trim();
|
||||
return MediaItem(
|
||||
id: '$_prefijoPista${nodo.documentId}',
|
||||
title: titulo,
|
||||
artist: (artistaMeta != null && artistaMeta.isNotEmpty)
|
||||
? artistaMeta
|
||||
: null,
|
||||
artist:
|
||||
(artistaMeta != null && artistaMeta.isNotEmpty) ? artistaMeta : null,
|
||||
playable: true,
|
||||
artUri: Uri.parse(artUri),
|
||||
displaySubtitle: subtituloCalidadLocal(meta),
|
||||
@@ -718,23 +814,27 @@ class ConstructorArbolAuto {
|
||||
required List<GrupoFavoritos> grupos,
|
||||
required List<Emisora> favoritos,
|
||||
}) {
|
||||
final carpetas = grupos
|
||||
.where((g) => !g.esSinAsignar)
|
||||
.where((g) => favoritos.any((e) => e.grupoFavoritosId == g.id))
|
||||
.take(_maxGruposPorFavoritos)
|
||||
.map(itemGrupo)
|
||||
.toList();
|
||||
final sinAsignar = favoritos
|
||||
.where((e) => e.grupoFavoritosId == GrupoFavoritos.sinAsignarId)
|
||||
.toList();
|
||||
final carpetas =
|
||||
grupos
|
||||
.where((g) => !g.esSinAsignar)
|
||||
.where((g) => favoritos.any((e) => e.grupoFavoritosId == g.id))
|
||||
.take(_maxGruposPorFavoritos)
|
||||
.map(itemGrupo)
|
||||
.toList();
|
||||
final sinAsignar =
|
||||
favoritos
|
||||
.where((e) => e.grupoFavoritosId == GrupoFavoritos.sinAsignarId)
|
||||
.toList();
|
||||
return [...carpetas, ...hijos(idFavoritos, emisoras: sinAsignar)];
|
||||
}
|
||||
|
||||
/// Members of the favorite group identified by [grupoMediaId] (a
|
||||
/// `grupo:<id>` id), sorted and capped like every other folder (Spec "Car
|
||||
/// requests a group folder's stations"). An unknown/stale/malformed id
|
||||
/// returns an empty list instead of throwing (Spec "Car requests an
|
||||
/// unknown or stale group id").
|
||||
/// `grupo:<id>` id), PRESERVING the incoming [favoritos] order (Favoritos'
|
||||
/// manual order — see [hijos]' doc, fix `android-auto-orden`) and capped
|
||||
/// like every other folder (Spec "Car requests a group folder's
|
||||
/// stations"). An unknown/stale/malformed id returns an empty list
|
||||
/// instead of throwing (Spec "Car requests an unknown or stale group
|
||||
/// id").
|
||||
List<MediaItem> hijosGrupo(
|
||||
String grupoMediaId, {
|
||||
required List<Emisora> favoritos,
|
||||
@@ -742,12 +842,60 @@ class ConstructorArbolAuto {
|
||||
if (!esCarpetaGrupo(grupoMediaId)) return const [];
|
||||
final id = grupoMediaId.substring(_prefijoGrupo.length);
|
||||
if (id.isEmpty) return const [];
|
||||
final miembros = favoritos
|
||||
.where((e) => e.grupoFavoritosId == id)
|
||||
.toList();
|
||||
final miembros = favoritos.where((e) => e.grupoFavoritosId == id).toList();
|
||||
if (miembros.isEmpty) return const [];
|
||||
final ordenados = ordenarEmisoras(miembros, OrdenEmisoras.calidad);
|
||||
return ordenados.take(_maxItemsPorCarpeta).map(itemEmisora).toList();
|
||||
return miembros.take(_maxItemsPorCarpeta).map(itemEmisora).toList();
|
||||
}
|
||||
|
||||
/// Equalizer preset-selection media-id prefix (decision
|
||||
/// `auto/ecualizador-diseno`): `eq_preset:<rawPresetName>` for the six
|
||||
/// factory presets, plus the reserved [_valorDesactivarEq] sentinel for
|
||||
/// the "Desactivar" item ([idDesactivarEq]). Collision-free against every
|
||||
/// other prefix/bare id in this class -- diverges from every sibling
|
||||
/// prefix at the very first character ('e' vs 'g'/'c'/'p') and from every
|
||||
/// bare folder id (none of which starts with "eq_preset:").
|
||||
static const _prefijoPresetEq = 'eq_preset:';
|
||||
|
||||
/// Reserved sentinel raw value for the "Desactivar" item under
|
||||
/// [_prefijoPresetEq] (decision `auto/ecualizador-diseno`) -- never
|
||||
/// collides with a real [PresetEcualizador.nombre]; none of the six
|
||||
/// factory presets is named this.
|
||||
static const _valorDesactivarEq = '_off_';
|
||||
|
||||
/// The "Desactivar" item's media id: the reserved [_valorDesactivarEq]
|
||||
/// sentinel under [_prefijoPresetEq].
|
||||
static const idDesactivarEq = '$_prefijoPresetEq$_valorDesactivarEq';
|
||||
|
||||
/// Whether [id] identifies an item under the Ecualizador folder (a
|
||||
/// factory preset OR "Desactivar").
|
||||
bool esPresetEqMediaId(String id) => id.startsWith(_prefijoPresetEq);
|
||||
|
||||
/// Whether [id] is specifically the "Desactivar" item (not a factory
|
||||
/// preset). Only meaningful alongside [esPresetEqMediaId].
|
||||
bool esDesactivarEqMediaId(String id) => id == idDesactivarEq;
|
||||
|
||||
/// Builds a factory preset's selection media id, matched by raw
|
||||
/// (untranslated) [PresetEcualizador.nombre] -- the SAME identity
|
||||
/// [PresetEcualizador.presets] already uses for equality, so a locale
|
||||
/// change never breaks resolution.
|
||||
String idPresetEq(String nombrePreset) => '$_prefijoPresetEq$nombrePreset';
|
||||
|
||||
/// Resolves an `eq_preset:<nombre>` [id] to the matching factory
|
||||
/// [PresetEcualizador] from [presets] (defaults to
|
||||
/// [PresetEcualizador.presets]), comparing by raw `nombre`. Returns
|
||||
/// `null` for the [_valorDesactivarEq] sentinel, an unresolvable name, or
|
||||
/// any id that doesn't match [esPresetEqMediaId] -- never throws.
|
||||
PresetEcualizador? resolverPresetEq(
|
||||
String id, {
|
||||
List<PresetEcualizador>? presets,
|
||||
}) {
|
||||
if (!esPresetEqMediaId(id) || esDesactivarEqMediaId(id)) return null;
|
||||
final nombre = id.substring(_prefijoPresetEq.length);
|
||||
final lista = presets ?? PresetEcualizador.presets;
|
||||
for (final preset in lista) {
|
||||
if (preset.nombre == nombre) return preset;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -777,15 +925,195 @@ Future<void> reproducirPorMediaId(
|
||||
title: emisora.nombre,
|
||||
artist: emisora.pais ?? '',
|
||||
album: 'PluriWave',
|
||||
artUri:
|
||||
emisora.favicon != null && emisora.favicon!.isNotEmpty
|
||||
? Uri.tryParse(emisora.favicon!)
|
||||
: null,
|
||||
// Item 3: reuses [artUriPara] (the SAME fallback the browse tree's
|
||||
// itemEmisora already applies) so the "now playing" media item never
|
||||
// falls back to a blank tile — a real usable favicon still wins, a
|
||||
// missing/unusable one gets the on-brand rotating drawable instead of
|
||||
// `null`.
|
||||
artUri: Uri.parse(artUriPara(emisora)),
|
||||
extras: {'uuid': emisora.uuid},
|
||||
);
|
||||
await reproducir(item);
|
||||
}
|
||||
|
||||
/// Which list previous/next should walk for [actual]: the NARROWEST context
|
||||
/// the station belongs to.
|
||||
///
|
||||
/// Tightest first:
|
||||
/// 1. its FAVOURITES GROUP, when it is a favourite filed under a real group,
|
||||
/// 2. all favourites,
|
||||
/// 3. my stations,
|
||||
/// 4. the full catalogue.
|
||||
///
|
||||
/// The group tier is what the owner asked for: driving with a themed group,
|
||||
/// "next" should stay inside that group rather than wander across every
|
||||
/// favourite. And "next" from a favourite must never land on entry 4,318 of a
|
||||
/// 50,000-station catalogue that happens to sit beside it alphabetically.
|
||||
/// Falling through to [todas] only when the station is in neither curated
|
||||
/// list keeps the button alive for a station reached by search.
|
||||
///
|
||||
/// [GrupoFavoritos.sinAsignarId] is deliberately NOT treated as a group: it
|
||||
/// is the ABSENCE of one, so those stations walk all favourites instead of a
|
||||
/// bucket that only means "unfiled". A group with a single member also falls
|
||||
/// through to all favourites — otherwise both buttons would be dead ends.
|
||||
///
|
||||
/// Returns an empty list when [actual] is in none of them, which
|
||||
/// [emisoraVecina] turns into "do nothing".
|
||||
List<Emisora> listaParaSaltoEmisora({
|
||||
required Emisora actual,
|
||||
required List<Emisora> favoritos,
|
||||
required List<Emisora> misEmisoras,
|
||||
required List<Emisora> todas,
|
||||
}) {
|
||||
Emisora? enLista(List<Emisora> lista) {
|
||||
for (final e in lista) {
|
||||
if (e.uuid == actual.uuid) return e;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// The FAVOURITE record is the authority on the group, never `actual`: the
|
||||
// playing station is rebuilt from a MediaItem by `emisoraDesdeMediaItem`,
|
||||
// which carries no group id and would always report "sin asignar".
|
||||
final favorita = enLista(favoritos);
|
||||
if (favorita != null) {
|
||||
final grupo = favorita.grupoFavoritosId;
|
||||
if (grupo != GrupoFavoritos.sinAsignarId) {
|
||||
final delGrupo =
|
||||
favoritos.where((e) => e.grupoFavoritosId == grupo).toList();
|
||||
if (delGrupo.length > 1) return delGrupo;
|
||||
}
|
||||
return favoritos;
|
||||
}
|
||||
if (enLista(misEmisoras) != null) return misEmisoras;
|
||||
if (enLista(todas) != null) return todas;
|
||||
return const [];
|
||||
}
|
||||
|
||||
/// The station before or after [actual] in [lista], wrapping around at both
|
||||
/// ends.
|
||||
///
|
||||
/// Wrapping is deliberate: on a car's transport row a button that goes dead
|
||||
/// at the end of a list reads as a broken app, and there is no visible list
|
||||
/// position to explain it. Matching is by `uuid`, the same identity the
|
||||
/// browse tree uses, so a refreshed snapshot with different object instances
|
||||
/// still resolves.
|
||||
///
|
||||
/// Returns `null` when [lista] has fewer than two entries, or when [actual]
|
||||
/// is not in it — the caller must then leave playback alone rather than jump
|
||||
/// somewhere arbitrary.
|
||||
Emisora? emisoraVecina(
|
||||
Emisora? actual,
|
||||
List<Emisora> lista, {
|
||||
required bool haciaAtras,
|
||||
}) {
|
||||
if (actual == null || lista.length < 2) return null;
|
||||
final indice = lista.indexWhere((e) => e.uuid == actual.uuid);
|
||||
if (indice < 0) return null;
|
||||
final destino =
|
||||
haciaAtras
|
||||
? (indice - 1 + lista.length) % lista.length
|
||||
: (indice + 1) % lista.length;
|
||||
return lista[destino];
|
||||
}
|
||||
|
||||
/// Picks the station a spoken query refers to ("pon Radio Clásica"), over the
|
||||
/// stations the car can already browse.
|
||||
///
|
||||
/// Pure and source-agnostic so it is testable without a handler. Ranking, best
|
||||
/// first:
|
||||
/// 1. exact name match (case/accent-insensitive),
|
||||
/// 2. name starts with the query,
|
||||
/// 3. name contains the query,
|
||||
/// 4. country contains the query.
|
||||
/// Ties are broken by the order [candidatas] arrives in, which the caller
|
||||
/// composes as favourites → my stations → all, so a favourite always wins over
|
||||
/// a stranger with the same name.
|
||||
///
|
||||
/// Returns `null` for an empty query or no match — the caller must then do
|
||||
/// nothing rather than play something arbitrary, since a driver who asked for
|
||||
/// a specific station is worse served by a random one than by silence.
|
||||
Emisora? emisoraParaBusqueda(String consulta, List<Emisora> candidatas) {
|
||||
final q = _normalizarBusqueda(consulta);
|
||||
if (q.isEmpty) return null;
|
||||
|
||||
Emisora? contiene;
|
||||
Emisora? empieza;
|
||||
Emisora? porPais;
|
||||
for (final emisora in candidatas) {
|
||||
final nombre = _normalizarBusqueda(emisora.nombre);
|
||||
if (nombre == q) return emisora;
|
||||
if (empieza == null && nombre.startsWith(q)) {
|
||||
empieza = emisora;
|
||||
} else if (contiene == null && nombre.contains(q)) {
|
||||
contiene = emisora;
|
||||
} else if (porPais == null &&
|
||||
_normalizarBusqueda(emisora.pais ?? '').contains(q)) {
|
||||
porPais = emisora;
|
||||
}
|
||||
}
|
||||
return empieza ?? contiene ?? porPais;
|
||||
}
|
||||
|
||||
/// Lowercase, accent-stripped, collapsed whitespace — a driver saying "radio
|
||||
/// clasica" must match "Radio Clásica", and voice transcription rarely gets
|
||||
/// diacritics right.
|
||||
String _normalizarBusqueda(String texto) {
|
||||
const conAcento = 'áàäâãéèëêíìïîóòöôõúùüûñç';
|
||||
const sinAcento = 'aaaaaeeeeiiiiooooouuuunc';
|
||||
final buffer = StringBuffer();
|
||||
for (final rune in texto.toLowerCase().runes) {
|
||||
final char = String.fromCharCode(rune);
|
||||
final i = conAcento.indexOf(char);
|
||||
buffer.write(i >= 0 ? sinAcento[i] : char);
|
||||
}
|
||||
return buffer.toString().replaceAll(RegExp(r'\s+'), ' ').trim();
|
||||
}
|
||||
|
||||
/// Routing seam for a car-tapped `eq_preset:<...>` media id (decision
|
||||
/// `auto/ecualizador-diseno`, mirrors [reproducirPorMediaId]'s seam
|
||||
/// shape): dispatches "Desactivar" to [activarEcualizador]`(false)`, and a
|
||||
/// resolved factory preset to [aplicarPreset] -- turning the equalizer
|
||||
/// back ON via [activarEcualizador]`(true)` AFTERWARDS whenever [activo]
|
||||
/// is currently `false`, so tapping a preset while the equalizer is off
|
||||
/// both re-enables it AND applies the tapped preset's gains (Spec
|
||||
/// "selecting a preset while disabled enables it and applies it"), never
|
||||
/// silently just remembering the preset for later. [aplicarPreset] runs
|
||||
/// BEFORE the enable check so the native engine only ever pushes gains
|
||||
/// once, for the NEW preset -- never once for whatever was active before,
|
||||
/// then again for the new one.
|
||||
///
|
||||
/// A stale/unresolvable id, or any id that doesn't match
|
||||
/// [ConstructorArbolAuto.esPresetEqMediaId], is a no-op: neither callback
|
||||
/// runs and no exception propagates.
|
||||
///
|
||||
/// [presets] is the universe the id is resolved against, and it MUST be the
|
||||
/// same list the folder was rendered from (`presetsEcualizadorAuto` in
|
||||
/// `servicio_audio.dart` — factory presets plus the user's saved ones).
|
||||
/// Defaulting to the factory six alone is what made a tapped custom preset a
|
||||
/// silent no-op: the item was listed, but nothing here could resolve it.
|
||||
Future<void> seleccionarPresetEqPorMediaId(
|
||||
String id, {
|
||||
required bool activo,
|
||||
required Future<void> Function(PresetEcualizador) aplicarPreset,
|
||||
required Future<void> Function(bool) activarEcualizador,
|
||||
List<PresetEcualizador>? presets,
|
||||
}) async {
|
||||
final constructor = ConstructorArbolAuto();
|
||||
if (!constructor.esPresetEqMediaId(id)) return;
|
||||
|
||||
if (constructor.esDesactivarEqMediaId(id)) {
|
||||
await activarEcualizador(false);
|
||||
return;
|
||||
}
|
||||
|
||||
final preset = constructor.resolverPresetEq(id, presets: presets);
|
||||
if (preset == null) return;
|
||||
|
||||
await aplicarPreset(preset);
|
||||
if (!activo) await activarEcualizador(true);
|
||||
}
|
||||
|
||||
/// Fallback title (Design "Title = filename minus extension") for a blank
|
||||
/// or otherwise empty-after-stripping local filename — hardcoded Spanish,
|
||||
/// matching every other car-tree label in this file (`'Favoritos'`,
|
||||
@@ -843,10 +1171,8 @@ List<NodoLocal> ordenarPorCalidadLocal(
|
||||
) {
|
||||
final ordenados = List<NodoLocal>.from(nodos);
|
||||
ordenados.sort(
|
||||
(a, b) => compararCalidadLocal(
|
||||
metadatos[a.documentId],
|
||||
metadatos[b.documentId],
|
||||
),
|
||||
(a, b) =>
|
||||
compararCalidadLocal(metadatos[a.documentId], metadatos[b.documentId]),
|
||||
);
|
||||
return ordenados;
|
||||
}
|
||||
@@ -887,12 +1213,13 @@ List<BucketLocal> bucketsDe(List<NodoLocal> nodos) {
|
||||
final pistas = nodos.where((n) => !n.esDirectorio).toList();
|
||||
return _rangosBucket.map((rango) {
|
||||
final (etiqueta, desde, hasta) = rango;
|
||||
final coincidencias = pistas.where((n) {
|
||||
final recortado = n.nombre.trim();
|
||||
if (recortado.isEmpty) return false;
|
||||
final letra = recortado[0].toLowerCase();
|
||||
return letra.compareTo(desde) >= 0 && letra.compareTo(hasta) <= 0;
|
||||
}).toList();
|
||||
final coincidencias =
|
||||
pistas.where((n) {
|
||||
final recortado = n.nombre.trim();
|
||||
if (recortado.isEmpty) return false;
|
||||
final letra = recortado[0].toLowerCase();
|
||||
return letra.compareTo(desde) >= 0 && letra.compareTo(hasta) <= 0;
|
||||
}).toList();
|
||||
return BucketLocal(etiqueta: etiqueta, nodos: coincidencias);
|
||||
}).toList();
|
||||
}
|
||||
@@ -931,17 +1258,115 @@ List<NodoLocal> mezclarFisherYates(List<NodoLocal> nodos, Random rng) {
|
||||
List<NodoLocal> pistasEnOrdenAleatorio(List<NodoLocal> nodos, Random rng) =>
|
||||
mezclarFisherYates(pistasEnOrdenNombre(nodos), rng);
|
||||
|
||||
/// Maximum recursion depth for "play folder recursively" (Design "recursive
|
||||
/// folder play, cost bound", item 2): SAF directory listing is a native
|
||||
/// round-trip PER folder, so unbounded recursion could turn a single tap
|
||||
/// into dozens of channel calls for a pathologically deep tree. 4 levels
|
||||
/// below the tapped folder covers virtually every real music-library
|
||||
/// layout (even `Artist/Album/Disc/track.mp3` is only 3 levels deep) while
|
||||
/// keeping a worst-case tree's native-call count bounded. A subfolder
|
||||
/// beyond this depth is simply never explored — its tracks are not
|
||||
/// collected, exactly like content beyond the browse tree's own page cap
|
||||
/// is never listed.
|
||||
const profundidadMaximaRecursivaLocal = 4;
|
||||
|
||||
/// Maximum number of tracks collected by a recursive folder walk (Design
|
||||
/// "recursive folder play, cost bound", item 2): a folder-play/shuffle
|
||||
/// queue beyond a few hundred tracks has no practical benefit, and an
|
||||
/// unbounded collection risks an extremely long queue AND an extremely
|
||||
/// long recursive walk over a huge library. 500 is an order of magnitude
|
||||
/// above the existing quality-sort cap
|
||||
/// ([ConstructorArbolAuto._maxPistasParaOrdenCalidad], 150) — generous for
|
||||
/// a "play everything" action, while still bounded.
|
||||
const limitePistasRecursivasLocal = 500;
|
||||
|
||||
/// Recursively collects every audio-file [NodoLocal] reachable from
|
||||
/// [documentId] (Design "recursive folder play", item 2): [documentId]'s
|
||||
/// own direct audio children, plus — for every direct subfolder — that
|
||||
/// subfolder's own recursive result. Walked depth-first, sorted by
|
||||
/// [NodoLocal.nombre] at each level (the SAME comparator the sequential/
|
||||
/// shuffle play actions already used pre-recursion), so the collected
|
||||
/// order is deterministic and reproducible under a fixed shuffle seed.
|
||||
///
|
||||
/// Bounded on two independent axes so a pathological tree (very deep, or
|
||||
/// very wide-and-deep) can never turn a single tap into an unbounded
|
||||
/// number of native SAF round-trips or an unbounded in-memory list:
|
||||
/// - [profundidadMaxima] caps how many folder levels BELOW [documentId]
|
||||
/// are ever descended into (`0` = only [documentId]'s own direct
|
||||
/// children, no descent at all).
|
||||
/// - [limite] caps the TOTAL number of tracks collected across the whole
|
||||
/// walk; collection stops (mid-folder if needed) the instant this many
|
||||
/// have been gathered.
|
||||
///
|
||||
/// Never throws: a [fuente.hijos] failure on any one subfolder (revoked
|
||||
/// permission, a race with the OS SAF layer) is swallowed for that
|
||||
/// subfolder only — sibling folders already queued for traversal are
|
||||
/// still visited — mirroring this file's existing no-throw contract
|
||||
/// (Design "no-op on empty/unresolvable folder").
|
||||
Future<List<NodoLocal>> pistasRecursivas(
|
||||
String documentId, {
|
||||
required FuenteMusicaLocalAuto fuente,
|
||||
int profundidadMaxima = profundidadMaximaRecursivaLocal,
|
||||
int limite = limitePistasRecursivasLocal,
|
||||
}) async {
|
||||
final resultado = <NodoLocal>[];
|
||||
await _recolectarPistasRecursivas(
|
||||
documentId,
|
||||
fuente: fuente,
|
||||
profundidadRestante: profundidadMaxima,
|
||||
limite: limite,
|
||||
resultado: resultado,
|
||||
);
|
||||
return resultado;
|
||||
}
|
||||
|
||||
Future<void> _recolectarPistasRecursivas(
|
||||
String documentId, {
|
||||
required FuenteMusicaLocalAuto fuente,
|
||||
required int profundidadRestante,
|
||||
required int limite,
|
||||
required List<NodoLocal> resultado,
|
||||
}) async {
|
||||
if (resultado.length >= limite) return;
|
||||
final List<NodoLocal> hijos;
|
||||
try {
|
||||
hijos = await fuente.hijos(documentId);
|
||||
} catch (_) {
|
||||
return;
|
||||
}
|
||||
final ordenados = [...hijos]..sort((a, b) => a.nombre.compareTo(b.nombre));
|
||||
for (final nodo in ordenados) {
|
||||
if (resultado.length >= limite) return;
|
||||
if (nodo.esDirectorio) {
|
||||
if (profundidadRestante <= 0) continue;
|
||||
await _recolectarPistasRecursivas(
|
||||
nodo.documentId,
|
||||
fuente: fuente,
|
||||
profundidadRestante: profundidadRestante - 1,
|
||||
limite: limite,
|
||||
resultado: resultado,
|
||||
);
|
||||
} else {
|
||||
resultado.add(nodo);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Orchestrates a "Reproducir carpeta"/"Reproducir aleatorio" tap (Design
|
||||
/// "Data Flow", ADR-5/ADR-6, Phase 3 task 4.2): resolves whichever of the
|
||||
/// two action prefixes matches [id] (ignoring [aleatorio] for the STRIP —
|
||||
/// the prefix itself is authoritative), fetches [fuente]'s direct children
|
||||
/// for that folder, filters to audio files, orders them ([aleatorio] picks
|
||||
/// shuffled vs name order), and hands the resulting list to [iniciarCola].
|
||||
/// "Data Flow", ADR-5/ADR-6, Phase 3 task 4.2; recursive collection item
|
||||
/// 2): resolves whichever of the two action prefixes matches [id]
|
||||
/// (ignoring [aleatorio] for the STRIP — the prefix itself is
|
||||
/// authoritative), RECURSIVELY collects every track beneath that folder
|
||||
/// via [pistasRecursivas] (direct children AND every nested subfolder, up
|
||||
/// to its depth/count bounds), orders them ([aleatorio] picks shuffled vs
|
||||
/// the recursive walk's own name-sorted order), and hands the resulting
|
||||
/// list to [iniciarCola].
|
||||
///
|
||||
/// A no-op (never calls [iniciarCola]) when: [id] matches neither action
|
||||
/// prefix; [fuente.hijos] throws or returns only directories (an
|
||||
/// unresolvable/empty folder — Design "no-op on empty/unresolvable
|
||||
/// folder").
|
||||
/// prefix; the folder (or everything beneath it, within the recursion
|
||||
/// bounds) is unresolvable/empty (Design "no-op on empty/unresolvable
|
||||
/// folder") — [pistasRecursivas] never throws, so this never propagates an
|
||||
/// exception either.
|
||||
Future<void> reproducirCarpetaLocal(
|
||||
String id, {
|
||||
required bool aleatorio,
|
||||
@@ -959,16 +1384,11 @@ Future<void> reproducirCarpetaLocal(
|
||||
return;
|
||||
}
|
||||
|
||||
final List<NodoLocal> nodos;
|
||||
try {
|
||||
nodos = await fuente.hijos(documentId);
|
||||
} catch (_) {
|
||||
return;
|
||||
}
|
||||
|
||||
final pistas = aleatorio
|
||||
? pistasEnOrdenAleatorio(nodos, rng ?? Random())
|
||||
: pistasEnOrdenNombre(nodos);
|
||||
final recolectadas = await pistasRecursivas(documentId, fuente: fuente);
|
||||
final pistas =
|
||||
aleatorio
|
||||
? mezclarFisherYates(recolectadas, rng ?? Random())
|
||||
: recolectadas;
|
||||
if (pistas.isEmpty) return;
|
||||
|
||||
await iniciarCola(pistas);
|
||||
@@ -992,6 +1412,11 @@ Future<MediaItem?> construirMediaItemColaLocal(
|
||||
id: contentUri,
|
||||
title: _tituloDesdeDocumentId(nodo.documentId),
|
||||
album: 'PluriWave',
|
||||
// Item 3: a queued local track had NO artUri at all before — reuses
|
||||
// [artUriLocal] (the SAME on-brand rotation the browse tree's
|
||||
// `_itemLocal` already falls back to) so the car's now-playing screen
|
||||
// never shows a blank tile for a track with no embedded art.
|
||||
artUri: Uri.parse(artUriLocal(nodo.documentId)),
|
||||
extras: {'documentId': nodo.documentId},
|
||||
);
|
||||
}
|
||||
@@ -1114,6 +1539,7 @@ Future<List<MediaItem>?> hijosMusicaLocal(
|
||||
documentIdPadre: documentId,
|
||||
pagina: pagina,
|
||||
metadatosDe: (ids) => _metadatosDeConCache(ids, fuente: fuente),
|
||||
fuente: fuente,
|
||||
);
|
||||
} catch (_) {
|
||||
return const [];
|
||||
@@ -1170,6 +1596,9 @@ Future<void> reproducirPistaLocal(
|
||||
id: pista.contentUri,
|
||||
title: pista.titulo,
|
||||
album: 'PluriWave',
|
||||
// Item 3: same fallback as construirMediaItemColaLocal, for a track
|
||||
// tapped directly (not via a folder-play queue).
|
||||
artUri: Uri.parse(artUriLocal(pista.documentId)),
|
||||
extras: {'documentId': pista.documentId},
|
||||
);
|
||||
await reproducir(item);
|
||||
|
||||
@@ -350,6 +350,73 @@ class ServicioAlarmas {
|
||||
return nuevo;
|
||||
});
|
||||
|
||||
/// Records a scheduling-reliability failure for [alarmaId] (fix/alarmas-
|
||||
/// fallos-silenciosos): reuses the SAME `ExcepcionAlarma` model
|
||||
/// `saltarProxima` already persists, so `EstadoAlarmas.ultimaExcepcionPara`
|
||||
/// surfaces it on the exact alarm card affected instead of only a
|
||||
/// transient, alarm-agnostic message. A previous FAILURE record for the
|
||||
/// same alarm is replaced (only the latest attempt's outcome matters) --
|
||||
/// any `skipNext` exception for this or other alarms is left untouched.
|
||||
/// Never affects scheduling: `ServicioProgramacionAlarmas._esValida` only
|
||||
/// treats `tipoSaltoSiguiente` as an actual skip.
|
||||
Future<ConfiguracionAlarmas> registrarFalloProgramacion(
|
||||
String alarmaId,
|
||||
DateTime ejecucion,
|
||||
String tipo,
|
||||
) => _enCola(() async {
|
||||
final config = await _configActual();
|
||||
final excepciones = [
|
||||
..._sinFalloPrevio(config.excepciones, alarmaId),
|
||||
ExcepcionAlarma(alarmaId: alarmaId, ejecucion: ejecucion, tipo: tipo),
|
||||
];
|
||||
final nuevo = ConfiguracionAlarmas(
|
||||
alarmas: config.alarmas,
|
||||
vacaciones: config.vacaciones,
|
||||
excepciones: excepciones,
|
||||
);
|
||||
await _guardar(nuevo);
|
||||
return nuevo;
|
||||
});
|
||||
|
||||
/// Clears the outstanding failure record for [alarmaId] ONLY when its
|
||||
/// current tipo is [tipo] (a subsequent attempt of THAT SPECIFIC kind
|
||||
/// succeeded). Type-scoped on purpose: a successful main-alarm schedule
|
||||
/// call proves nothing about the pre-notice or foreground-service
|
||||
/// subsystems, so it must never clear a failure recorded for those. No-op
|
||||
/// when there is nothing to clear or the recorded tipo does not match.
|
||||
Future<ConfiguracionAlarmas> limpiarFalloProgramacion(
|
||||
String alarmaId,
|
||||
String tipo,
|
||||
) => _enCola(() async {
|
||||
final config = await _configActual();
|
||||
final actual = config.excepciones.where((e) => e.alarmaId == alarmaId);
|
||||
final tieneEseTipo = actual.any((e) => e.tipo == tipo);
|
||||
if (!tieneEseTipo) return config;
|
||||
final excepciones =
|
||||
config.excepciones
|
||||
.where((e) => !(e.alarmaId == alarmaId && e.tipo == tipo))
|
||||
.toList();
|
||||
final nuevo = ConfiguracionAlarmas(
|
||||
alarmas: config.alarmas,
|
||||
vacaciones: config.vacaciones,
|
||||
excepciones: excepciones,
|
||||
);
|
||||
await _guardar(nuevo);
|
||||
return nuevo;
|
||||
});
|
||||
|
||||
List<ExcepcionAlarma> _sinFalloPrevio(
|
||||
List<ExcepcionAlarma> excepciones,
|
||||
String alarmaId,
|
||||
) =>
|
||||
excepciones
|
||||
.where(
|
||||
(e) =>
|
||||
!(e.alarmaId == alarmaId &&
|
||||
ExcepcionAlarma.tiposFallo.contains(e.tipo)),
|
||||
)
|
||||
.toList();
|
||||
|
||||
Future<ConfiguracionAlarmas> posponerEjecucion(
|
||||
String alarmaId,
|
||||
DateTime ejecucion,
|
||||
@@ -497,17 +564,52 @@ class ServicioAlarmas {
|
||||
final ahora = _reloj();
|
||||
// S2-R5: a disabled alarm must not keep a pending snooze; clearing it
|
||||
// here guarantees the snoozed occurrence dies with the alarm.
|
||||
// Self-heal for a snooze target parked absurdly far out — the reported
|
||||
// "posponer left it 1400+ minutes away". A legitimate snooze can never
|
||||
// reach here: posponerEjecucion clamps to `minutos.clamp(1, 120)` and the
|
||||
// anchor is now guarded on both the native and Dart sides, so anything
|
||||
// past that ceiling is a leftover from a build that had neither guard.
|
||||
// Without this, an alarm poisoned before the fix keeps showing tomorrow
|
||||
// on every tick — the user reinstalls, sees no change, and reasonably
|
||||
// concludes nothing was fixed. Generous margin over the 120-minute cap so
|
||||
// a real long snooze is never mistaken for corruption.
|
||||
const techoSnooze = Duration(hours: 3);
|
||||
final snoozeCorrupto =
|
||||
alarma.snoozeHasta != null &&
|
||||
alarma.snoozeHasta!.isAfter(ahora.add(techoSnooze));
|
||||
final snoozeActivo =
|
||||
alarma.activa &&
|
||||
!snoozeCorrupto &&
|
||||
alarma.snoozeHasta != null &&
|
||||
alarma.snoozeHasta!.isAfter(ahora);
|
||||
// Self-heal for state poisoned before the Detener anchor fix: a stop
|
||||
// that closed a FUTURE occurrence wrote it into
|
||||
// ultimaEjecucionGestionada, and _esValida rejects any candidate
|
||||
// matching it -- so the alarm silently skips that day forever after,
|
||||
// with nothing in the UI to explain it. An occurrence cannot have been
|
||||
// handled before it happens, so a value meaningfully in the future is
|
||||
// corrupt by definition and safe to drop: it can only ever suppress a
|
||||
// real future ring, never prevent a double-fire (which needs a PAST
|
||||
// occurrence to guard). Placed here, in the recalculation every load and
|
||||
// every mutation already funnels through, so an affected alarm heals on
|
||||
// the next app open with no user action.
|
||||
final gestionada = alarma.ultimaEjecucionGestionada;
|
||||
final gestionadaCorrupta =
|
||||
gestionada != null &&
|
||||
gestionada.isAfter(
|
||||
ahora.add(ServicioProgramacionAlarmas.toleranciaDisparoInminente),
|
||||
);
|
||||
final saneada =
|
||||
gestionadaCorrupta
|
||||
? alarma.copyWith(limpiarUltimaEjecucionGestionada: true)
|
||||
: alarma;
|
||||
final proxima = _programacion.calcularProxima(
|
||||
alarma: alarma,
|
||||
alarma: saneada,
|
||||
desde: ahora,
|
||||
vacaciones: vacaciones,
|
||||
excepciones: excepciones,
|
||||
);
|
||||
return alarma.copyWith(
|
||||
return saneada.copyWith(
|
||||
proximaEjecucion: proxima,
|
||||
limpiarProximaEjecucion: true,
|
||||
limpiarSnooze: !snoozeActivo,
|
||||
|
||||
@@ -161,6 +161,35 @@ class EjecucionAlarmaNativa {
|
||||
}
|
||||
}
|
||||
|
||||
/// A scheduling-reliability failure the NATIVE side recorded on its own
|
||||
/// (fix/alarmas-fallos-silenciosos, item 2): the pre-notice reminder, the
|
||||
/// ringing foreground service, and a post-boot/unlock reschedule can each
|
||||
/// fail without ever going through a Dart method-channel call that could
|
||||
/// throw -- the native scheduler persists these instead (mirroring how
|
||||
/// handled occurrences and snooze state already survive a killed engine),
|
||||
/// and this is the cold-start sync so the Dart side finds out at all.
|
||||
class FalloProgramacionNativo {
|
||||
const FalloProgramacionNativo({
|
||||
required this.alarmaId,
|
||||
required this.tipo,
|
||||
required this.ocurridoEn,
|
||||
});
|
||||
|
||||
final String alarmaId;
|
||||
final String tipo;
|
||||
final DateTime ocurridoEn;
|
||||
|
||||
factory FalloProgramacionNativo.fromMap(Map<Object?, Object?> map) {
|
||||
return FalloProgramacionNativo(
|
||||
alarmaId: map['alarmId'] as String? ?? '',
|
||||
tipo: map['type'] as String? ?? '',
|
||||
ocurridoEn: DateTime.fromMillisecondsSinceEpoch(
|
||||
(map['atMillis'] as num?)?.toInt() ?? 0,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
abstract class PuertoAlarmasAndroid {
|
||||
Stream<EventoAlarmaAndroid> get eventosAlarma;
|
||||
|
||||
@@ -170,6 +199,24 @@ abstract class PuertoAlarmasAndroid {
|
||||
|
||||
Future<void> programar(AlarmaMusical alarma);
|
||||
Future<void> cancelar(String alarmaId);
|
||||
|
||||
/// Failures the NATIVE side recorded on its own, outside any Dart call:
|
||||
/// 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. Each entry carries the alarm id and one of
|
||||
/// [ExcepcionAlarma]'s `tipoFallo*` constants.
|
||||
///
|
||||
/// Before this existed every one of those paths logged to logcat and
|
||||
/// stopped there, so an alarm could sit switched on in the list having
|
||||
/// never reached the OS at all — the user's "as if there were no alarm".
|
||||
///
|
||||
/// Returns the typed model rather than raw maps ON PURPOSE:
|
||||
/// [FalloProgramacionNativo.fromMap] is the single place the native key
|
||||
/// names (`alarmId`/`type`/`atMillis`) appear. Consuming raw maps here
|
||||
/// once silently dropped every entry, because the caller guessed Spanish
|
||||
/// key names and the fake was seeded with the same guess — the test
|
||||
/// confirmed the mistake instead of catching it.
|
||||
Future<List<FalloProgramacionNativo>> fallosNativosProgramacion();
|
||||
Future<void> ocultarNotificacionAlarma(String alarmaId);
|
||||
|
||||
/// Notification-only dismissal (RES-1): hides the fire notification for
|
||||
@@ -190,10 +237,24 @@ abstract class PuertoAlarmasAndroid {
|
||||
Future<bool> solicitarPermisoPantallaCompleta();
|
||||
Future<bool> solicitarExencionBateria();
|
||||
|
||||
/// Opens the system's per-app notification settings screen directly
|
||||
/// (`ACTION_APP_NOTIFICATION_SETTINGS`), as opposed to
|
||||
/// [solicitarPermisoNotificaciones]'s runtime permission popup. Used from
|
||||
/// the reliability diagnostics screen: once a user is troubleshooting an
|
||||
/// alarm that already failed, sending them straight to Settings is more
|
||||
/// robust than a runtime dialog the OS may refuse to show again after a
|
||||
/// prior denial.
|
||||
Future<bool> abrirConfiguracionNotificaciones();
|
||||
|
||||
Future<DiagnosticoAlarmasAndroid> diagnostico();
|
||||
Future<EventoAlarmaAndroid?> obtenerEventoInicial();
|
||||
Future<List<EjecucionAlarmaNativa>> obtenerEjecucionesNativasGestionadas();
|
||||
Future<List<EstadoSnoozeNativo>> obtenerEstadoSnoozeNativo();
|
||||
|
||||
/// Scheduling-reliability failures the native side recorded on its own
|
||||
/// (pre-notice, foreground-service start, or post-boot reschedule) since
|
||||
/// the last sync.
|
||||
Future<List<FalloProgramacionNativo>> obtenerFallosProgramacionNativos();
|
||||
}
|
||||
|
||||
class ServicioAlarmasAndroid implements PuertoAlarmasAndroid {
|
||||
@@ -384,6 +445,26 @@ class ServicioAlarmasAndroid implements PuertoAlarmasAndroid {
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<FalloProgramacionNativo>> fallosNativosProgramacion() async {
|
||||
try {
|
||||
final raw = await _channel.invokeMethod<List<Object?>>(
|
||||
'getNativeSchedulingFailures',
|
||||
);
|
||||
if (raw == null) return const [];
|
||||
return raw
|
||||
.whereType<Map<Object?, Object?>>()
|
||||
.map(FalloProgramacionNativo.fromMap)
|
||||
.where((f) => f.alarmaId.isNotEmpty && f.tipo.isNotEmpty)
|
||||
.toList();
|
||||
} catch (e) {
|
||||
// Never let a diagnostics read break alarm handling: an older build
|
||||
// of the native side simply has no such channel method.
|
||||
debugPrint('[PluriWave][alarmas] fallosNativosProgramacion ERROR $e');
|
||||
return const [];
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<bool> solicitarPermisoAlarmasExactas() async {
|
||||
final abierto = await _channel.invokeMethod<bool>(
|
||||
@@ -416,6 +497,14 @@ class ServicioAlarmasAndroid implements PuertoAlarmasAndroid {
|
||||
return abierto ?? false;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<bool> abrirConfiguracionNotificaciones() async {
|
||||
final abierto = await _channel.invokeMethod<bool>(
|
||||
'openNotificationSettings',
|
||||
);
|
||||
return abierto ?? false;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<DiagnosticoAlarmasAndroid> diagnostico() async {
|
||||
debugPrint('[PluriWave][alarmas] diagnostico android');
|
||||
@@ -477,6 +566,20 @@ class ServicioAlarmasAndroid implements PuertoAlarmasAndroid {
|
||||
.toList();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<FalloProgramacionNativo>>
|
||||
obtenerFallosProgramacionNativos() async {
|
||||
final raw = await _channel.invokeMethod<List<Object?>>(
|
||||
'getNativeSchedulingFailures',
|
||||
);
|
||||
if (raw == null || raw.isEmpty) return const [];
|
||||
return raw
|
||||
.whereType<Map<Object?, Object?>>()
|
||||
.map(FalloProgramacionNativo.fromMap)
|
||||
.where((fallo) => fallo.alarmaId.isNotEmpty && fallo.tipo.isNotEmpty)
|
||||
.toList();
|
||||
}
|
||||
|
||||
Future<void> _logAndInvokeVoid(String method, Map<String, Object?> args) {
|
||||
debugPrint('[PluriWave][alarmas] $method $args');
|
||||
return _channel.invokeMethod<void>(method, args);
|
||||
|
||||
+910
-107
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,4 @@
|
||||
import 'dart:async';
|
||||
import 'dart:developer' as developer;
|
||||
|
||||
import 'package:audio_session/audio_session.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
@@ -20,6 +19,20 @@ abstract class ObjetivoAudioInterrumpible {
|
||||
|
||||
/// Temporarily lowers ("ducks") the output volume without pausing.
|
||||
Future<void> setAtenuado(bool atenuado);
|
||||
|
||||
/// Re-attaches the equalizer effect and re-pushes the current preset's
|
||||
/// gains (fix "EQ Re-Apply After Audio-Focus Interruption"). Called after
|
||||
/// resuming from a transient interruption pause and after un-ducking,
|
||||
/// because Android's AudioEffect framework can let a higher-priority
|
||||
/// client silently disable this app's effect instance while the
|
||||
/// underlying player session id never changes — the existing session-id
|
||||
/// rotation trigger (`ServicioAudio.debeReaplicarEcualizador`) therefore
|
||||
/// never fires for a SHORT interruption (e.g. a nav-app voice prompt).
|
||||
/// Idempotent and cheap (a `setEnabled` plus band `setGain` calls); takes
|
||||
/// no argument by design — it re-asserts whatever enabled/disabled state
|
||||
/// the handler ALREADY holds, so a caller here can never force the
|
||||
/// equalizer on. Never restarts or repositions playback.
|
||||
Future<void> reaplicarEcualizador();
|
||||
}
|
||||
|
||||
/// Wrapper around `package:audio_session` (S3-R1): configures the session
|
||||
@@ -44,9 +57,26 @@ class ServicioAudioSession {
|
||||
Future<void> configurar() async {
|
||||
try {
|
||||
final sesion = await _obtenerSesion();
|
||||
// DUCK, never pause, when another app asks for transient focus.
|
||||
//
|
||||
// `androidWillPauseWhenDucked: true` makes `audio_session` translate
|
||||
// Android's AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK into a full PAUSE. In a
|
||||
// car that fires constantly — every navigation instruction, every
|
||||
// speed-camera warning, every "OK Google" — and each one used to stop
|
||||
// the radio outright instead of dipping the volume for two seconds.
|
||||
//
|
||||
// Worse than the audio gap: a pause publishes `playing: false`, which
|
||||
// `AudioService.setState` turns into `exitPlayingState()` and, with
|
||||
// `androidStopForegroundOnPause`, into `stopForeground(...)`. A service
|
||||
// that is no longer in the foreground is killable, and when Android
|
||||
// took it the app vanished from the Android Auto pane mid-drive and
|
||||
// another media app took its slot. Ducking keeps `playing: true`
|
||||
// throughout, so the session, the notification and the car pane all
|
||||
// survive an interruption — which is also what keeps the equalizer
|
||||
// alive across it.
|
||||
await sesion.configure(
|
||||
const AudioSessionConfiguration.music().copyWith(
|
||||
androidWillPauseWhenDucked: true,
|
||||
androidWillPauseWhenDucked: false,
|
||||
),
|
||||
);
|
||||
await _interrupcionesSub?.cancel();
|
||||
@@ -58,10 +88,8 @@ class ServicioAudioSession {
|
||||
(_) => unawaited(manejarDesconexionSalida()),
|
||||
);
|
||||
} catch (e) {
|
||||
developer.log(
|
||||
'[PluriWave] No se pudo configurar la sesion de audio: $e',
|
||||
name: 'ServicioAudioSession',
|
||||
level: 900,
|
||||
debugPrint(
|
||||
'[PluriWave][ServicioAudioSession] No se pudo configurar la sesion de audio: $e',
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -84,11 +112,18 @@ class ServicioAudioSession {
|
||||
switch (evento.type) {
|
||||
case AudioInterruptionType.duck:
|
||||
await _objetivo.setAtenuado(false);
|
||||
// Un-ducking never rotates the native player session id, so the
|
||||
// session-id-change trigger never fires for this case — re-assert
|
||||
// here too (belt-and-braces, additive to that trigger).
|
||||
await _objetivo.reaplicarEcualizador();
|
||||
case AudioInterruptionType.pause:
|
||||
// Transient loss ended and the OS says we may resume.
|
||||
if (_pausadoPorInterrupcion) {
|
||||
_pausadoPorInterrupcion = false;
|
||||
await _objetivo.reanudar();
|
||||
// Same rationale as the duck branch above: a short transient
|
||||
// interruption keeps the SAME player session id.
|
||||
await _objetivo.reaplicarEcualizador();
|
||||
}
|
||||
case AudioInterruptionType.unknown:
|
||||
// Permanent focus loss: never auto-resume.
|
||||
|
||||
@@ -157,7 +157,9 @@ class ServicioDispositivoAudioReal extends ServicioDispositivoAudio {
|
||||
@override
|
||||
Future<Map<String, String>> obtenerNombresEmparejados() async {
|
||||
try {
|
||||
final raw = await _methodChannel.invokeMethod<Map>('getBondedDeviceNames');
|
||||
final raw = await _methodChannel.invokeMethod<Map>(
|
||||
'getBondedDeviceNames',
|
||||
);
|
||||
if (raw == null) return const {};
|
||||
return {
|
||||
for (final entry in raw.entries)
|
||||
|
||||
@@ -70,7 +70,10 @@ class ServicioEcualizador {
|
||||
final porEmisora = _leerPresetsPorEmisora(prefs);
|
||||
final presetsDispositivo = _leerMapa(prefs, _keyPresetsPorDispositivo);
|
||||
final presetsMatriz = _leerMapa(prefs, _keyPresetsMatriz);
|
||||
final nombresDispositivos = _leerMapaStrings(prefs, _keyNombresDispositivos);
|
||||
final nombresDispositivos = _leerMapaStrings(
|
||||
prefs,
|
||||
_keyNombresDispositivos,
|
||||
);
|
||||
return ConfiguracionEcualizador(
|
||||
principal: principal,
|
||||
porEmisora: porEmisora,
|
||||
@@ -124,21 +127,22 @@ class ServicioEcualizador {
|
||||
String prefijo,
|
||||
) async {
|
||||
final presetsPorDispositivo = _leerMapa(prefs, _keyPresetsPorDispositivo);
|
||||
final dispositivos = presetsPorDispositivo.keys
|
||||
.where((clave) => clave.startsWith(prefijo))
|
||||
.toList();
|
||||
final dispositivos =
|
||||
presetsPorDispositivo.keys
|
||||
.where((clave) => clave.startsWith(prefijo))
|
||||
.toList();
|
||||
|
||||
final nombres = _leerMapaStrings(prefs, _keyNombresDispositivos);
|
||||
final nombresAPurgar = nombres.keys
|
||||
.where((clave) => clave.startsWith(prefijo))
|
||||
.toList();
|
||||
final nombresAPurgar =
|
||||
nombres.keys.where((clave) => clave.startsWith(prefijo)).toList();
|
||||
|
||||
final matriz = _leerMapa(prefs, _keyPresetsMatriz);
|
||||
final matrizAPurgar = matriz.keys.where((clave) {
|
||||
final separador = clave.indexOf(':');
|
||||
if (separador == -1) return false;
|
||||
return clave.substring(separador + 1).startsWith(prefijo);
|
||||
}).toList();
|
||||
final matrizAPurgar =
|
||||
matriz.keys.where((clave) {
|
||||
final separador = clave.indexOf(':');
|
||||
if (separador == -1) return false;
|
||||
return clave.substring(separador + 1).startsWith(prefijo);
|
||||
}).toList();
|
||||
|
||||
for (final clave in dispositivos) {
|
||||
presetsPorDispositivo.remove(clave);
|
||||
@@ -183,11 +187,12 @@ class ServicioEcualizador {
|
||||
// (station UUIDs are RFC4122 and contain no colons — multi-device-eq
|
||||
// ADR-3), since deviceId itself may contain colons (e.g. a MAC-based id).
|
||||
final presetsMatriz = _leerMapa(prefs, _keyPresetsMatriz);
|
||||
final clavesMatrizAPurgar = presetsMatriz.keys.where((clave) {
|
||||
final separador = clave.indexOf(':');
|
||||
if (separador == -1) return false;
|
||||
return clave.substring(separador + 1) == deviceId;
|
||||
}).toList();
|
||||
final clavesMatrizAPurgar =
|
||||
presetsMatriz.keys.where((clave) {
|
||||
final separador = clave.indexOf(':');
|
||||
if (separador == -1) return false;
|
||||
return clave.substring(separador + 1) == deviceId;
|
||||
}).toList();
|
||||
if (clavesMatrizAPurgar.isNotEmpty) {
|
||||
for (final clave in clavesMatrizAPurgar) {
|
||||
presetsMatriz.remove(clave);
|
||||
|
||||
@@ -150,9 +150,15 @@ class ServicioProgramacionAlarmas {
|
||||
if (!alarma.sonarEnVacaciones && estaEnVacaciones(candidato, vacaciones)) {
|
||||
return false;
|
||||
}
|
||||
// Only a deliberate user skip ever removes a candidate occurrence.
|
||||
// Reliability-failure records share this same list/model (so the alarms
|
||||
// list can surface them per-alarm via `ultimaExcepcionPara`), but they
|
||||
// must never be mistaken for a skip — that would silently jump the
|
||||
// alarm to its NEXT occurrence instead of just flagging the failed one.
|
||||
return !excepciones.any(
|
||||
(excepcion) =>
|
||||
excepcion.alarmaId == alarma.id &&
|
||||
excepcion.tipo == ExcepcionAlarma.tipoSaltoSiguiente &&
|
||||
_mismaEjecucion(excepcion.ejecucion, candidato),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
/// Persists whether the 9-screen help/tutorial carousel (`PantallaTutorialAyuda`)
|
||||
/// has already been shown, so it renders once on the genuine first-run
|
||||
/// sequence rather than on every launch.
|
||||
///
|
||||
/// Same injectable-`SharedPreferences`, versioned-key convention as
|
||||
/// `ServicioBienvenida` -- a plain one-time boolean flag, not a per-app-version
|
||||
/// "due again" scheme like `ServicioContenidoApp` uses: no existing user has
|
||||
/// this flag set yet, regardless of whether they are a fresh install or an
|
||||
/// upgrade, so a single flag already satisfies "show once, ever".
|
||||
class ServicioTutorialAyuda {
|
||||
ServicioTutorialAyuda({SharedPreferences? prefs}) : _prefs = prefs;
|
||||
|
||||
static const _keyTutorialVisto = 'pluri_tutorial_visto_v1';
|
||||
|
||||
final SharedPreferences? _prefs;
|
||||
|
||||
/// Injected startup instance (S3-R4); getInstance() is only a fallback.
|
||||
Future<SharedPreferences> _resolverPrefs() async =>
|
||||
_prefs ?? SharedPreferences.getInstance();
|
||||
|
||||
Future<bool> debeMostrarTutorial() async {
|
||||
final prefs = await _resolverPrefs();
|
||||
return !(prefs.getBool(_keyTutorialVisto) ?? false);
|
||||
}
|
||||
|
||||
Future<void> marcarTutorialVisto() async {
|
||||
final prefs = await _resolverPrefs();
|
||||
await prefs.setBool(_keyTutorialVisto, true);
|
||||
}
|
||||
}
|
||||
@@ -29,41 +29,59 @@ class PluriRootHeader extends StatelessWidget {
|
||||
/// needs nothing extra here).
|
||||
final List<Widget> actions;
|
||||
|
||||
/// Fix `safearea-top-inset`: this is the CONTENT row's height only —
|
||||
/// NOT this widget's total rendered height. `app.dart`'s root
|
||||
/// `SafeArea(top: false, ...)` deliberately excludes the top inset (so
|
||||
/// each root's own full-bleed background paints genuinely edge-to-edge
|
||||
/// behind the status bar), which left this header's title/actions row
|
||||
/// with zero top-inset awareness — flush at y=0 under the status bar /
|
||||
/// camera cutout on every device. This widget now adds
|
||||
/// `MediaQuery.paddingOf(context).top` ABOVE this content height itself
|
||||
/// (see [build]), so the total rendered height is
|
||||
/// `height + MediaQuery.paddingOf(context).top`. Callers doing
|
||||
/// total-height math (none currently do — checked every `PluriRootHeader`
|
||||
/// call site) must add that inset separately; this constant's MEANING
|
||||
/// (content height) is unchanged.
|
||||
static const double height = 56;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final type = context.pluriType;
|
||||
final l10n = AppLocalizations.of(context);
|
||||
return SizedBox(
|
||||
height: height,
|
||||
child: Padding(
|
||||
// S5: the prototype's own header padding is title-tier on the
|
||||
// left, row-tier on the right (t4 e.g. Alarmas
|
||||
// `padding:0 12px 0 20px`).
|
||||
padding: const EdgeInsets.fromLTRB(
|
||||
PluriLayout.titleHorizontal,
|
||||
0,
|
||||
PluriLayout.rowHorizontal,
|
||||
0,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
title,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: type.sectionTitle,
|
||||
final topInset = MediaQuery.paddingOf(context).top;
|
||||
return Padding(
|
||||
padding: EdgeInsets.only(top: topInset),
|
||||
child: SizedBox(
|
||||
height: height,
|
||||
child: Padding(
|
||||
key: const ValueKey('pluri-root-header-content'),
|
||||
// S5: the prototype's own header padding is title-tier on the
|
||||
// left, row-tier on the right (t4 e.g. Alarmas
|
||||
// `padding:0 12px 0 20px`).
|
||||
padding: const EdgeInsets.fromLTRB(
|
||||
PluriLayout.titleHorizontal,
|
||||
0,
|
||||
PluriLayout.rowHorizontal,
|
||||
0,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
title,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: type.sectionTitle,
|
||||
),
|
||||
),
|
||||
),
|
||||
...actions,
|
||||
IconButton(
|
||||
icon: const Icon(Icons.bedtime_outlined),
|
||||
tooltip: l10n.sleepTimer,
|
||||
onPressed: onSleepTimer,
|
||||
),
|
||||
],
|
||||
...actions,
|
||||
IconButton(
|
||||
icon: const Icon(Icons.bedtime_outlined),
|
||||
tooltip: l10n.sleepTimer,
|
||||
onPressed: onSleepTimer,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
+23
-1
@@ -1,7 +1,7 @@
|
||||
name: pluriwave
|
||||
description: "Radio mundial con ecualizador, reconocimiento de canciones y UI premium"
|
||||
publish_to: 'none'
|
||||
version: 1.2.1+123
|
||||
version: 1.2.28+150
|
||||
|
||||
environment:
|
||||
sdk: ^3.7.0
|
||||
@@ -75,4 +75,26 @@ flutter:
|
||||
- assets/audio/
|
||||
- assets/mockups/
|
||||
- assets/generated/
|
||||
# Flutter NO recurse: declarar 'assets/content/' incluye solo los
|
||||
# ficheros sueltos de esa carpeta, nunca los de sus subcarpetas. Todo
|
||||
# el contenido vive en subcarpetas, asi que NADA de esto viajaba en el
|
||||
# APK -- verificado abriendo el binario instalado: cero entradas de
|
||||
# assets/content. El onboarding reventaba en cada arranque con
|
||||
# 'Unable to load asset: assets/content/onboarding/en.md' aunque el
|
||||
# fichero existe en disco. Mismo fallo de familia que los drawables
|
||||
# resueltos por nombre: referencia sin validacion en compilacion.
|
||||
- assets/content/
|
||||
- assets/content/onboarding/
|
||||
- assets/content/updates/ar/
|
||||
- assets/content/updates/bn/
|
||||
- assets/content/updates/de/
|
||||
- assets/content/updates/en/
|
||||
- assets/content/updates/es/
|
||||
- assets/content/updates/fr/
|
||||
- assets/content/updates/hi/
|
||||
- assets/content/updates/id/
|
||||
- assets/content/updates/it/
|
||||
- assets/content/updates/ja/
|
||||
- assets/content/updates/pt/
|
||||
- assets/content/updates/ru/
|
||||
- assets/content/updates/zh/
|
||||
|
||||
@@ -20,4 +20,53 @@ void main() {
|
||||
'PluriRootHeader inside its content instead',
|
||||
);
|
||||
});
|
||||
|
||||
test('the first-launch sequence runs the welcome screen, then the tutorial '
|
||||
'carousel, then the recurring what-is-new dialog, in that order', () {
|
||||
// `_PaginaPrincipal` is library-private and its
|
||||
// `_mostrarFlujoPrimerLanzamiento` constructs real platform-backed
|
||||
// services, so it cannot be safely widget-tested here (same
|
||||
// constraint as the AppBar guard above). This is a fast source-level
|
||||
// ordering guard instead: PantallaBienvenida.mostrarSiProcede must
|
||||
// run before PantallaTutorialAyuda.mostrarSiProcede, which must run
|
||||
// before the unrelated, pre-existing _mostrarOnboardingInicial() call
|
||||
// -- so the tutorial shows once on every launch sequence (fresh
|
||||
// installs AND existing installs upgrading to this version) without
|
||||
// ever racing the welcome screen or the what's-new dialog.
|
||||
final source = File('lib/app.dart').readAsStringSync();
|
||||
|
||||
final indiceBienvenida = source.indexOf(
|
||||
'PantallaBienvenida.mostrarSiProcede',
|
||||
);
|
||||
final indiceTutorial = source.indexOf(
|
||||
'PantallaTutorialAyuda.mostrarSiProcede',
|
||||
);
|
||||
final indiceOnboarding = source.indexOf('_mostrarOnboardingInicial()');
|
||||
|
||||
expect(
|
||||
indiceBienvenida,
|
||||
greaterThanOrEqualTo(0),
|
||||
reason: 'PantallaBienvenida.mostrarSiProcede must still be called',
|
||||
);
|
||||
expect(
|
||||
indiceTutorial,
|
||||
greaterThanOrEqualTo(0),
|
||||
reason: 'PantallaTutorialAyuda.mostrarSiProcede must be wired in',
|
||||
);
|
||||
expect(
|
||||
indiceOnboarding,
|
||||
greaterThanOrEqualTo(0),
|
||||
reason: '_mostrarOnboardingInicial() must still be called',
|
||||
);
|
||||
expect(
|
||||
indiceBienvenida,
|
||||
lessThan(indiceTutorial),
|
||||
reason: 'the welcome screen must run before the tutorial carousel',
|
||||
);
|
||||
expect(
|
||||
indiceTutorial,
|
||||
lessThan(indiceOnboarding),
|
||||
reason: 'the tutorial carousel must run before the what-is-new dialog',
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:pluriwave/main.dart';
|
||||
|
||||
/// Reported: with Android Auto connected, the car screen sometimes came up
|
||||
/// completely BLACK, and opening the app on the phone then showed a
|
||||
/// completely WHITE screen until it was force-killed and reopened. Never
|
||||
/// without Android Auto.
|
||||
///
|
||||
/// Cause, verified in the plugin source:
|
||||
/// `AudioServiceActivity.provideFlutterEngine` returns
|
||||
/// `AudioServicePlugin.getFlutterEngine(context)`, which CREATES the engine
|
||||
/// and runs `main()` the first time it is asked — and the car asks first,
|
||||
/// when it binds the MediaBrowserService, so `main()` runs HEADLESS with no
|
||||
/// Activity. `SystemChrome.setPreferredOrientations` travels the
|
||||
/// `flutter/platform` channel, whose handler (`PlatformPlugin`) is installed
|
||||
/// by the Activity. Headless, nobody answers it.
|
||||
///
|
||||
/// It was the FIRST `await` in `main()`, so that one call took the whole
|
||||
/// startup with it: the Android Auto browse source below it 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. Only a force-kill, which disposes the cached
|
||||
/// engine, recovered it — exactly the workaround that was reported.
|
||||
///
|
||||
/// The user's own guess was that portrait-only + a landscape phone made the
|
||||
/// app "go a bit crazy". Right file, right trigger, different mechanism: a
|
||||
/// broken layout renders overflow stripes or a red error box, never white.
|
||||
/// White means nothing was ever built.
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
group('política de orientación', () {
|
||||
test('un móvil se queda en vertical', () {
|
||||
expect(orientacionesPara(411), const [DeviceOrientation.portraitUp]);
|
||||
expect(orientacionesPara(599.9), const [DeviceOrientation.portraitUp]);
|
||||
});
|
||||
|
||||
test('una tablet puede girar', () {
|
||||
expect(orientacionesPara(600), DeviceOrientation.values);
|
||||
expect(orientacionesPara(1280), DeviceOrientation.values);
|
||||
});
|
||||
});
|
||||
|
||||
group('nunca puede tumbar el arranque', () {
|
||||
test('un fallo del canal de plataforma se traga, no se propaga', () async {
|
||||
// This is the headless case: no PlatformPlugin, so the call fails.
|
||||
// Before the fix this exception escaped out of main() and killed
|
||||
// startup before runApp and before the Android Auto registration.
|
||||
await expectLater(
|
||||
aplicarPoliticaOrientacion(
|
||||
aplicar:
|
||||
(_) async =>
|
||||
throw MissingPluginException(
|
||||
'No implementation found for method '
|
||||
'SystemChrome.setPreferredOrientations on channel '
|
||||
'flutter/platform',
|
||||
),
|
||||
),
|
||||
completes,
|
||||
);
|
||||
});
|
||||
|
||||
test('un canal que nunca responde tampoco puede colgar a quien llama, '
|
||||
'porque main() ya no lo espera', () async {
|
||||
// The structural half of the fix: main() calls this through
|
||||
// `unawaited(...)`. Proven here by starting a call that never settles
|
||||
// and showing the test still finishes -- if startup awaited it, this
|
||||
// future is exactly what would hang forever on the headless engine.
|
||||
var termino = false;
|
||||
unawaited(
|
||||
aplicarPoliticaOrientacion(
|
||||
aplicar: (_) => Completer<void>().future,
|
||||
).then((_) => termino = true),
|
||||
);
|
||||
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
expect(termino, isFalse, reason: 'sigue pendiente, como debe');
|
||||
// The point is that nothing above depends on it.
|
||||
});
|
||||
|
||||
test('el camino feliz sigue aplicando la política de la pantalla', () {
|
||||
// Guard against "fixed" by neutering: the swallow-everything wrapper
|
||||
// must still actually apply something on a healthy engine.
|
||||
late List<DeviceOrientation> aplicadas;
|
||||
return aplicarPoliticaOrientacion(
|
||||
aplicar: (o) async => aplicadas = o,
|
||||
).then((_) {
|
||||
expect(aplicadas, isNotEmpty);
|
||||
expect(aplicadas, orientacionesPara(800 / 1));
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
/// Every file under `assets/content/` must be loadable through `rootBundle`,
|
||||
/// which is the only thing that proves it is DECLARED in pubspec.yaml and
|
||||
/// therefore actually ships.
|
||||
///
|
||||
/// Found by reading the installed APK: it contained ZERO entries under
|
||||
/// `assets/content/`, while `assets/icons/alarmas/*` was present. pubspec
|
||||
/// declared `assets/content/` — and Flutter does NOT recurse: naming a
|
||||
/// directory includes the files sitting directly in it, never its
|
||||
/// subdirectories. All of this content lives in subdirectories
|
||||
/// (`onboarding/`, `updates/<locale>/`), so the entire onboarding and
|
||||
/// release-notes feature had never shipped in any build. On the device it
|
||||
/// surfaced on every launch as:
|
||||
///
|
||||
/// Unable to load asset: "assets/content/onboarding/en.md"
|
||||
///
|
||||
/// with the file plainly present on disk.
|
||||
///
|
||||
/// Same family as the drawables the resource shrinker deleted: a reference by
|
||||
/// NAME that nothing validates at compile time, so it fails only on a device.
|
||||
/// A test that merely checked `File(...).existsSync()` would have stayed green
|
||||
/// throughout — the files were never missing. Loading through `rootBundle` is
|
||||
/// what makes it a real guard, because that is the path the app itself takes.
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
final directorio = Directory('assets/content');
|
||||
final ficheros =
|
||||
directorio
|
||||
.listSync(recursive: true)
|
||||
.whereType<File>()
|
||||
.map((f) => f.path.replaceAll(r'\', '/'))
|
||||
.toList()
|
||||
..sort();
|
||||
|
||||
test('hay contenido que comprobar (si no, este test sería vacuo)', () {
|
||||
expect(ficheros, isNotEmpty);
|
||||
});
|
||||
|
||||
for (final ruta in ficheros) {
|
||||
test('$ruta está declarado y se puede cargar', () async {
|
||||
await expectLater(
|
||||
rootBundle.loadString(ruta),
|
||||
completes,
|
||||
reason:
|
||||
'existe en disco pero rootBundle no lo encuentra: falta declarar '
|
||||
'su directorio en pubspec.yaml, y no viajará en el APK',
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:pluriwave/estado/estado_alarmas.dart';
|
||||
import 'package:pluriwave/modelos/alarma_musical.dart';
|
||||
import 'package:pluriwave/servicios/servicio_alarmas.dart';
|
||||
import 'package:pluriwave/servicios/servicio_alarmas_android.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import '../helpers/fakes_alarmas.dart';
|
||||
|
||||
/// Reported on-device: an alarm set for Monday 16:20 never rang, and the
|
||||
/// "next alarm" banner showed a DIFFERENT alarm (the next morning's) instead.
|
||||
///
|
||||
/// Root cause: `finalizarEjecucion` ("Detener") anchored the completed
|
||||
/// occurrence to `proximaEjecucion` with no check that it was the one
|
||||
/// actually ringing. On the native-fire path the fire-time sync advances
|
||||
/// `proximaEjecucion` to the NEXT occurrence BEFORE the user can reach the
|
||||
/// ring screen — so stopping today's ring recorded NEXT week's occurrence as
|
||||
/// already handled. `ServicioProgramacionAlarmas._esValida` then rejected
|
||||
/// that occurrence for real, and the alarm silently jumped past it: it never
|
||||
/// rang, and every sibling alarm outranked it in the banner.
|
||||
///
|
||||
/// This is the exact hazard `posponerAlarma` was fixed for in `9c7cf4e`
|
||||
/// ("anchor snooze to the ringing occurrence, never a future one"). The guard
|
||||
/// landed on the snooze path and never on the stop path, which sits directly
|
||||
/// below it in the same file.
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
setUp(() {
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
});
|
||||
|
||||
AlarmaMusical semanalLunes(String id) => AlarmaMusical(
|
||||
id: id,
|
||||
nombre: 'Tarde del lunes',
|
||||
hora: 16,
|
||||
minuto: 20,
|
||||
tipoProgramacion: TipoProgramacionAlarma.diasSemana,
|
||||
diasSemana: const [DateTime.monday],
|
||||
);
|
||||
|
||||
test('Detener cierra la ocurrencia que sonaba, no quema la siguiente '
|
||||
'(el nativo ya avanzó proximaEjecucion antes de que el usuario '
|
||||
'llegue a la pantalla)', () async {
|
||||
// Monday 2026-08-03.
|
||||
var ahora = DateTime(2026, 8, 3, 16, 0);
|
||||
final android = FakePuertoAlarmasAndroid();
|
||||
final estado = EstadoAlarmas(
|
||||
servicio: ServicioAlarmas(reloj: () => ahora),
|
||||
android: android,
|
||||
iniciarAutomaticamente: false,
|
||||
);
|
||||
addTearDown(estado.dispose);
|
||||
addTearDown(android.dispose);
|
||||
|
||||
await estado.guardarAlarma(semanalLunes('a1'));
|
||||
expect(
|
||||
estado.alarmas.single.proximaEjecucion,
|
||||
DateTime(2026, 8, 3, 16, 20),
|
||||
);
|
||||
|
||||
// 16:20 — it rings. The native scheduler records the occurrence handled
|
||||
// and rearms; the cold-start sync brings that over, which advances
|
||||
// proximaEjecucion to NEXT Monday while the alarm is still ringing.
|
||||
ahora = DateTime(2026, 8, 3, 16, 20, 5);
|
||||
android.ejecucionesNativas.add(
|
||||
EjecucionAlarmaNativa(
|
||||
alarmaId: 'a1',
|
||||
gestionadaEn: DateTime(2026, 8, 3, 16, 20),
|
||||
),
|
||||
);
|
||||
await estado.inicializar();
|
||||
|
||||
expect(
|
||||
estado.alarmas.single.proximaEjecucion,
|
||||
DateTime(2026, 8, 10, 16, 20),
|
||||
reason: 'precondición: el nativo ya avanzó a la semana siguiente',
|
||||
);
|
||||
|
||||
// NOW the user taps "Detener" on the ring screen.
|
||||
await estado.finalizarEjecucion('a1');
|
||||
|
||||
expect(
|
||||
estado.alarmas.single.proximaEjecucion,
|
||||
DateTime(2026, 8, 10, 16, 20),
|
||||
reason:
|
||||
'Detener debe cerrar la ocurrencia que sonaba (hoy), no consumir '
|
||||
'la del lunes que viene empujándola a 2026-08-17',
|
||||
);
|
||||
expect(
|
||||
estado.alarmas.single.ultimaEjecucionGestionada,
|
||||
isNot(DateTime(2026, 8, 10, 16, 20)),
|
||||
reason:
|
||||
'marcar como gestionada una ocurrencia futura es justo lo que hace '
|
||||
'que _esValida la rechace y esa alarma no suene ese día',
|
||||
);
|
||||
});
|
||||
|
||||
test('estado ya envenenado se cura solo: una ocurrencia futura marcada '
|
||||
'como gestionada se descarta al recalcular', () async {
|
||||
// Devices that ran the buggy build carry the poisoned value in
|
||||
// SharedPreferences. Without this, the fix would still leave the
|
||||
// affected alarm skipping one more time, with nothing in the UI to
|
||||
// explain it -- and the user would reasonably read that as "not fixed".
|
||||
final ahora = DateTime(2026, 8, 3, 9, 0);
|
||||
final servicio = ServicioAlarmas(reloj: () => ahora);
|
||||
|
||||
// Saved by the buggy stop path: next Monday recorded as already handled.
|
||||
await servicio.guardarAlarma(
|
||||
semanalLunes(
|
||||
'a3',
|
||||
).copyWith(ultimaEjecucionGestionada: DateTime(2026, 8, 10, 16, 20)),
|
||||
);
|
||||
|
||||
final config = await servicio.recalcularTodas();
|
||||
final alarma = config.alarmas.single;
|
||||
|
||||
expect(alarma.ultimaEjecucionGestionada, isNull);
|
||||
expect(
|
||||
alarma.proximaEjecucion,
|
||||
DateTime(2026, 8, 3, 16, 20),
|
||||
reason: 'y con el dato corrupto fuera, hoy vuelve a ser candidata',
|
||||
);
|
||||
});
|
||||
|
||||
test('una ocurrencia gestionada REAL (pasada) se conserva: es la que evita '
|
||||
'que la alarma vuelva a sonar en el mismo minuto', () async {
|
||||
final ahora = DateTime(2026, 8, 3, 16, 20, 30);
|
||||
final servicio = ServicioAlarmas(reloj: () => ahora);
|
||||
final gestionada = DateTime(2026, 8, 3, 16, 20);
|
||||
|
||||
await servicio.guardarAlarma(
|
||||
semanalLunes('a4').copyWith(ultimaEjecucionGestionada: gestionada),
|
||||
);
|
||||
|
||||
final alarma = (await servicio.recalcularTodas()).alarmas.single;
|
||||
|
||||
expect(alarma.ultimaEjecucionGestionada, gestionada);
|
||||
expect(
|
||||
alarma.proximaEjecucion,
|
||||
DateTime(2026, 8, 10, 16, 20),
|
||||
reason: 'la de hoy ya sonó, la siguiente es el lunes que viene',
|
||||
);
|
||||
});
|
||||
|
||||
test(
|
||||
'Detener sin nada sonando tampoco consume la próxima ocurrencia',
|
||||
() async {
|
||||
// Defensive: the ring screen is the only production caller, but a stale
|
||||
// route or a duplicated stop event must not silently eat a day.
|
||||
var ahora = DateTime(2026, 8, 3, 9, 0);
|
||||
final android = FakePuertoAlarmasAndroid();
|
||||
final estado = EstadoAlarmas(
|
||||
servicio: ServicioAlarmas(reloj: () => ahora),
|
||||
android: android,
|
||||
iniciarAutomaticamente: false,
|
||||
);
|
||||
addTearDown(estado.dispose);
|
||||
addTearDown(android.dispose);
|
||||
|
||||
await estado.guardarAlarma(semanalLunes('a2'));
|
||||
expect(
|
||||
estado.alarmas.single.proximaEjecucion,
|
||||
DateTime(2026, 8, 3, 16, 20),
|
||||
);
|
||||
|
||||
ahora = DateTime(2026, 8, 3, 9, 1);
|
||||
await estado.finalizarEjecucion('a2');
|
||||
|
||||
expect(
|
||||
estado.alarmas.single.proximaEjecucion,
|
||||
DateTime(2026, 8, 3, 16, 20),
|
||||
reason: 'a las 09:01 la ocurrencia de las 16:20 no está sonando',
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:pluriwave/estado/estado_alarmas.dart';
|
||||
import 'package:pluriwave/modelos/alarma_musical.dart';
|
||||
import 'package:pluriwave/servicios/servicio_alarmas_android.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import '../helpers/fakes_alarmas.dart';
|
||||
|
||||
/// The native scheduler records three failures entirely on its own, outside
|
||||
/// any Dart call: 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.
|
||||
///
|
||||
/// Before this wiring existed, all three logged to logcat and stopped there.
|
||||
/// The alarm stayed switched on in the list, drawn exactly as if scheduling
|
||||
/// had succeeded, and simply never fired — the reported "as if there were no
|
||||
/// alarm at all". These tests pin the drain path that makes them visible.
|
||||
///
|
||||
/// They deliberately build fixtures through [FalloProgramacionNativo.fromMap]
|
||||
/// using the REAL native key names (`alarmId`/`type`/`atMillis`, see
|
||||
/// `AlarmScheduler.kt:1389-1391`). An earlier draft seeded the fake with
|
||||
/// guessed Spanish keys and consumed the same guess, so every entry would
|
||||
/// have been silently dropped in production while the tests stayed green.
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
late FakePuertoAlarmasAndroid android;
|
||||
|
||||
setUp(() {
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
android = FakePuertoAlarmasAndroid();
|
||||
});
|
||||
|
||||
EstadoAlarmas crearEstado() =>
|
||||
EstadoAlarmas(android: android, iniciarAutomaticamente: false);
|
||||
|
||||
/// Mirrors exactly what the native side puts on the channel.
|
||||
FalloProgramacionNativo falloNativo(String alarmaId, String tipo) =>
|
||||
FalloProgramacionNativo.fromMap({
|
||||
'alarmId': alarmaId,
|
||||
'type': tipo,
|
||||
'atMillis': 1700000000000,
|
||||
});
|
||||
|
||||
test('the fixture helper decodes the real native key names', () {
|
||||
final fallo = falloNativo('a0', ExcepcionAlarma.tipoFalloPreaviso);
|
||||
|
||||
expect(fallo.alarmaId, 'a0');
|
||||
expect(fallo.tipo, ExcepcionAlarma.tipoFalloPreaviso);
|
||||
});
|
||||
|
||||
test('a native pre-notice failure becomes a per-alarm exception', () async {
|
||||
android.fallosNativos = [
|
||||
falloNativo('a1', ExcepcionAlarma.tipoFalloPreaviso),
|
||||
];
|
||||
final estado = crearEstado();
|
||||
addTearDown(estado.dispose);
|
||||
|
||||
await estado.cargarFallosNativos();
|
||||
|
||||
final fallo = estado.ultimaExcepcionPara('a1');
|
||||
expect(fallo, isNotNull);
|
||||
expect(fallo!.tipo, ExcepcionAlarma.tipoFalloPreaviso);
|
||||
});
|
||||
|
||||
test(
|
||||
'a refused foreground-service start becomes a per-alarm exception',
|
||||
() async {
|
||||
android.fallosNativos = [
|
||||
falloNativo('a2', ExcepcionAlarma.tipoFalloServicioSonido),
|
||||
];
|
||||
final estado = crearEstado();
|
||||
addTearDown(estado.dispose);
|
||||
|
||||
await estado.cargarFallosNativos();
|
||||
|
||||
expect(
|
||||
estado.ultimaExcepcionPara('a2')?.tipo,
|
||||
ExcepcionAlarma.tipoFalloServicioSonido,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
test('only the alarm whose reschedule failed is marked', () async {
|
||||
android.fallosNativos = [
|
||||
falloNativo('a3', ExcepcionAlarma.tipoFalloReprogramacionArranque),
|
||||
];
|
||||
final estado = crearEstado();
|
||||
addTearDown(estado.dispose);
|
||||
|
||||
await estado.cargarFallosNativos();
|
||||
|
||||
expect(estado.ultimaExcepcionPara('a3'), isNotNull);
|
||||
expect(
|
||||
estado.ultimaExcepcionPara('a4'),
|
||||
isNull,
|
||||
reason: 'a sibling alarm that scheduled fine must stay unmarked',
|
||||
);
|
||||
});
|
||||
|
||||
test('a failing native read never surfaces as an alarm error', () async {
|
||||
// A diagnostics gap must not look like a scheduling problem: an older
|
||||
// native build simply has no such channel method.
|
||||
android.fallaLecturaFallosNativos = true;
|
||||
final estado = crearEstado();
|
||||
addTearDown(estado.dispose);
|
||||
|
||||
await estado.cargarFallosNativos();
|
||||
|
||||
expect(estado.error, isNull);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:pluriwave/estado/estado_alarmas.dart';
|
||||
import 'package:pluriwave/modelos/alarma_musical.dart';
|
||||
import 'package:pluriwave/servicios/servicio_alarmas.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import '../helpers/fakes_alarmas.dart';
|
||||
|
||||
/// Reported twice on-device: "Posponer" on the pre-notice notification left
|
||||
/// the alarm snoozed 1400+ minutes — a whole day — instead of the configured
|
||||
/// few minutes.
|
||||
///
|
||||
/// The first fix (7054a4c) guarded the NATIVE anchor, and it was not enough,
|
||||
/// because Dart runs AFTERWARDS on this path: the receiver's `postponeNext`
|
||||
/// fires, then `startActivity`, then `app.dart` dispatches here, and this
|
||||
/// method persists and reschedules. Whatever Dart computes is the value that
|
||||
/// survives. It was the last snooze path with no occurrence guard at all.
|
||||
///
|
||||
/// It also cannot simply reuse `_ocurrenciaSonando`: the pre-notice's
|
||||
/// occurrence legitimately has NOT arrived yet (the reminder is armed 30 min
|
||||
/// ahead), so the ringing-screen guard would reject a perfectly good anchor.
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
setUp(() {
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
});
|
||||
|
||||
AlarmaMusical diaria(String id) => AlarmaMusical(
|
||||
id: id,
|
||||
nombre: 'Mañana',
|
||||
hora: 16,
|
||||
minuto: 20,
|
||||
tipoProgramacion: TipoProgramacionAlarma.diaria,
|
||||
diasSemana: const [],
|
||||
snoozeMinutos: 5,
|
||||
);
|
||||
|
||||
({EstadoAlarmas estado, FakePuertoAlarmasAndroid android}) montar(
|
||||
DateTime Function() reloj,
|
||||
) {
|
||||
final android = FakePuertoAlarmasAndroid();
|
||||
final estado = EstadoAlarmas(
|
||||
servicio: ServicioAlarmas(reloj: reloj),
|
||||
android: android,
|
||||
iniciarAutomaticamente: false,
|
||||
);
|
||||
addTearDown(estado.dispose);
|
||||
addTearDown(android.dispose);
|
||||
return (estado: estado, android: android);
|
||||
}
|
||||
|
||||
test('una ocurrencia del DÍA SIGUIENTE se rechaza: pospone minutos, '
|
||||
'no 24 horas', () async {
|
||||
// The exact reported shape. The pre-notice for today's 16:20 is on
|
||||
// screen at 16:16; the anchor handed in points at TOMORROW (either the
|
||||
// native spec was already advanced, or app.dart fell back to a
|
||||
// proximaEjecucion that had moved on).
|
||||
var ahora = DateTime(2026, 8, 3, 16, 0);
|
||||
final m = montar(() => ahora);
|
||||
await m.estado.guardarAlarma(diaria('p1'));
|
||||
|
||||
ahora = DateTime(2026, 8, 3, 16, 16);
|
||||
await m.estado.posponerProximaDesdePreaviso(
|
||||
m.estado.alarmas.single,
|
||||
5,
|
||||
DateTime(2026, 8, 4, 16, 20), // <- tomorrow
|
||||
);
|
||||
|
||||
final snooze = m.estado.alarmas.single.snoozeHasta!;
|
||||
final minutos = snooze.difference(ahora).inMinutes;
|
||||
expect(
|
||||
minutos,
|
||||
lessThan(60),
|
||||
reason:
|
||||
'la alarma quedó a $minutos min ($snooze). El reporte fue "más de '
|
||||
'1400 minutos"; cualquier cosa por encima de una hora es el mismo bug',
|
||||
);
|
||||
expect(
|
||||
m.estado.alarmas.single.snoozeOrigen,
|
||||
isNot(DateTime(2026, 8, 4, 16, 20)),
|
||||
reason:
|
||||
'el ancla sin validar también se guarda como snoozeOrigen y como '
|
||||
'ultimaEjecucionGestionada — envenenaría el estado que a9da855 y '
|
||||
'0430059 existen para mantener limpio',
|
||||
);
|
||||
});
|
||||
|
||||
test('la ocurrencia REAL del preaviso se respeta aunque esté en el futuro: '
|
||||
'ancla en la ocurrencia + N, no en ahora + N', () async {
|
||||
// The whole reason this path needs its own guard instead of reusing
|
||||
// _ocurrenciaSonando: 30 minutes ahead is legitimate here.
|
||||
var ahora = DateTime(2026, 8, 3, 16, 0);
|
||||
final m = montar(() => ahora);
|
||||
await m.estado.guardarAlarma(diaria('p2'));
|
||||
|
||||
// Pre-notice fires at 15:50; the user taps at 15:52, 28 min before.
|
||||
ahora = DateTime(2026, 8, 3, 15, 52);
|
||||
await m.estado.posponerProximaDesdePreaviso(
|
||||
m.estado.alarmas.single,
|
||||
5,
|
||||
DateTime(2026, 8, 3, 16, 20),
|
||||
);
|
||||
|
||||
expect(m.estado.alarmas.single.snoozeHasta, DateTime(2026, 8, 3, 16, 25));
|
||||
expect(m.estado.alarmas.single.snoozeOrigen, DateTime(2026, 8, 3, 16, 20));
|
||||
});
|
||||
|
||||
test('un snooze ya envenenado en disco se cura al recalcular', () async {
|
||||
// Devices that ran the buggy build carry snoozeHasta = tomorrow in
|
||||
// SharedPreferences. Without healing it, the alarm keeps reporting
|
||||
// tomorrow on every tick and the user sees no change after updating.
|
||||
final ahora = DateTime(2026, 8, 3, 16, 0);
|
||||
final servicio = ServicioAlarmas(reloj: () => ahora);
|
||||
|
||||
await servicio.guardarAlarma(
|
||||
diaria('p4').copyWith(
|
||||
snoozeHasta: DateTime(2026, 8, 4, 16, 25),
|
||||
snoozeOrigen: DateTime(2026, 8, 4, 16, 20),
|
||||
),
|
||||
);
|
||||
|
||||
final alarma = (await servicio.recalcularTodas()).alarmas.single;
|
||||
|
||||
expect(alarma.snoozeHasta, isNull);
|
||||
expect(alarma.proximaProgramable, DateTime(2026, 8, 3, 16, 20));
|
||||
});
|
||||
|
||||
test('un snooze legítimo de 2 horas NO se toca', () async {
|
||||
// posponerEjecucion clamps to 120 minutes, so the ceiling has to sit
|
||||
// above that or the heal would eat real snoozes.
|
||||
final ahora = DateTime(2026, 8, 3, 16, 0);
|
||||
final servicio = ServicioAlarmas(reloj: () => ahora);
|
||||
final hasta = DateTime(2026, 8, 3, 18, 0);
|
||||
|
||||
await servicio.guardarAlarma(
|
||||
diaria('p5').copyWith(snoozeHasta: hasta, snoozeOrigen: ahora),
|
||||
);
|
||||
|
||||
expect(
|
||||
(await servicio.recalcularTodas()).alarmas.single.snoozeHasta,
|
||||
hasta,
|
||||
);
|
||||
});
|
||||
|
||||
test('un ancla absurdamente lejana cae a la ocurrencia propia de la alarma, '
|
||||
'no a un valor inventado', () async {
|
||||
var ahora = DateTime(2026, 8, 3, 16, 0);
|
||||
final m = montar(() => ahora);
|
||||
await m.estado.guardarAlarma(diaria('p3'));
|
||||
|
||||
ahora = DateTime(2026, 8, 3, 16, 10);
|
||||
await m.estado.posponerProximaDesdePreaviso(
|
||||
m.estado.alarmas.single,
|
||||
5,
|
||||
DateTime(2027, 1, 1, 16, 20), // absurd
|
||||
);
|
||||
|
||||
// proximaEjecucion (today 16:20) is inside the pre-notice window, so it
|
||||
// is the right fallback and the snooze lands on 16:25.
|
||||
expect(m.estado.alarmas.single.snoozeHasta, DateTime(2026, 8, 3, 16, 25));
|
||||
});
|
||||
}
|
||||
@@ -611,6 +611,145 @@ void main() {
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'guardarAlarma: cuando android.programar falla, marca la alarma con una '
|
||||
'excepcion de fallo visible via ultimaExcepcionPara',
|
||||
() async {
|
||||
final android = FakePuertoAlarmasAndroid()..fallaProgramar = true;
|
||||
final estado = EstadoAlarmas(
|
||||
servicio: ServicioAlarmas(reloj: () => DateTime(2026, 5, 25, 7)),
|
||||
android: android,
|
||||
iniciarAutomaticamente: false,
|
||||
);
|
||||
addTearDown(estado.dispose);
|
||||
addTearDown(android.dispose);
|
||||
|
||||
await estado.guardarAlarma(
|
||||
const AlarmaMusical(
|
||||
id: 'fallo1',
|
||||
nombre: 'Diaria',
|
||||
hora: 7,
|
||||
minuto: 30,
|
||||
tipoProgramacion: TipoProgramacionAlarma.diaria,
|
||||
diasSemana: [],
|
||||
),
|
||||
);
|
||||
|
||||
final excepcion = estado.ultimaExcepcionPara('fallo1');
|
||||
expect(excepcion, isNotNull);
|
||||
expect(excepcion!.tipo, ExcepcionAlarma.tipoFalloProgramacion);
|
||||
expect(estado.error, isNotNull);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'guardarAlarma: un reintento exitoso limpia la excepcion de fallo previa',
|
||||
() async {
|
||||
final android = FakePuertoAlarmasAndroid()..fallaProgramar = true;
|
||||
final estado = EstadoAlarmas(
|
||||
servicio: ServicioAlarmas(reloj: () => DateTime(2026, 5, 25, 7)),
|
||||
android: android,
|
||||
iniciarAutomaticamente: false,
|
||||
);
|
||||
addTearDown(estado.dispose);
|
||||
addTearDown(android.dispose);
|
||||
final alarma = const AlarmaMusical(
|
||||
id: 'fallo2',
|
||||
nombre: 'Diaria',
|
||||
hora: 7,
|
||||
minuto: 30,
|
||||
tipoProgramacion: TipoProgramacionAlarma.diaria,
|
||||
diasSemana: [],
|
||||
);
|
||||
await estado.guardarAlarma(alarma);
|
||||
expect(estado.ultimaExcepcionPara('fallo2'), isNotNull);
|
||||
|
||||
android.fallaProgramar = false;
|
||||
await estado.guardarAlarma(estado.alarmas.single);
|
||||
|
||||
expect(estado.ultimaExcepcionPara('fallo2'), isNull);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'guardarAlarma en el camino feliz nunca registra una excepcion de fallo',
|
||||
() async {
|
||||
final android = FakePuertoAlarmasAndroid();
|
||||
final estado = EstadoAlarmas(
|
||||
servicio: ServicioAlarmas(reloj: () => DateTime(2026, 5, 25, 7)),
|
||||
android: android,
|
||||
iniciarAutomaticamente: false,
|
||||
);
|
||||
addTearDown(estado.dispose);
|
||||
addTearDown(android.dispose);
|
||||
|
||||
await estado.guardarAlarma(
|
||||
const AlarmaMusical(
|
||||
id: 'ok1',
|
||||
nombre: 'Diaria',
|
||||
hora: 7,
|
||||
minuto: 30,
|
||||
tipoProgramacion: TipoProgramacionAlarma.diaria,
|
||||
diasSemana: [],
|
||||
),
|
||||
);
|
||||
|
||||
expect(estado.ultimaExcepcionPara('ok1'), isNull);
|
||||
expect(estado.error, isNull);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'inicializar: un fallo de programacion en UNA alarma no aborta la '
|
||||
'sincronizacion de las demas (S-sincronizarTodas continua tras error)',
|
||||
() async {
|
||||
final android = FakePuertoAlarmasAndroid()
|
||||
..idsFallanProgramar.add('rota');
|
||||
final servicio = ServicioAlarmas(
|
||||
reloj: () => DateTime(2026, 5, 25, 6, 0),
|
||||
);
|
||||
await servicio.guardarAlarma(
|
||||
AlarmaMusical(
|
||||
id: 'rota',
|
||||
nombre: 'Rota',
|
||||
hora: 7,
|
||||
minuto: 0,
|
||||
tipoProgramacion: TipoProgramacionAlarma.diaria,
|
||||
diasSemana: const [],
|
||||
),
|
||||
);
|
||||
await servicio.guardarAlarma(
|
||||
AlarmaMusical(
|
||||
id: 'sana',
|
||||
nombre: 'Sana',
|
||||
hora: 8,
|
||||
minuto: 0,
|
||||
tipoProgramacion: TipoProgramacionAlarma.diaria,
|
||||
diasSemana: const [],
|
||||
),
|
||||
);
|
||||
|
||||
final estado = EstadoAlarmas(
|
||||
servicio: servicio,
|
||||
android: android,
|
||||
iniciarAutomaticamente: false,
|
||||
);
|
||||
addTearDown(estado.dispose);
|
||||
addTearDown(android.dispose);
|
||||
|
||||
await estado.inicializar();
|
||||
|
||||
expect(
|
||||
android.programadas.map((a) => a.id),
|
||||
contains('sana'),
|
||||
reason:
|
||||
'la alarma sana debe seguir programandose aunque la rota falle',
|
||||
);
|
||||
expect(estado.ultimaExcepcionPara('rota'), isNotNull);
|
||||
expect(estado.ultimaExcepcionPara('sana'), isNull);
|
||||
},
|
||||
);
|
||||
|
||||
group('EstadoAlarmas — consultas de vacaciones (ADR-6, WU9)', () {
|
||||
test('rangoVacacionesActivo devuelve el rango cuyo intervalo incluye '
|
||||
'"ahora" (dias restantes derivables de finDia), o null si ninguno '
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:pluriwave/estado/estado_alarmas.dart';
|
||||
import 'package:pluriwave/modelos/alarma_musical.dart';
|
||||
import 'package:pluriwave/servicios/servicio_alarmas.dart';
|
||||
import 'package:pluriwave/servicios/servicio_alarmas_android.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import '../helpers/fakes_alarmas.dart';
|
||||
|
||||
/// fix/alarmas-fallos-silenciosos, item 3: "verify the alarm is actually
|
||||
/// registered, and say so if it is not". `android.programar` returning
|
||||
/// without throwing is not proof enough by itself -- this is the check that
|
||||
/// would have caught the reported case immediately (the native side can
|
||||
/// silently fail to persist the registration even when the channel call
|
||||
/// itself reports success).
|
||||
void main() {
|
||||
setUp(() {
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
});
|
||||
|
||||
test('guardarAlarma detecta que el conteo nativo no refleja la alarma '
|
||||
'guardada, aunque android.programar no haya lanzado', () async {
|
||||
// Fixed at 0 regardless of what programar() does internally --
|
||||
// simulates the native side accepting the channel call but never
|
||||
// actually persisting the registration.
|
||||
final android = FakePuertoAlarmasAndroid()..alarmasNativasPendientes = 0;
|
||||
final estado = EstadoAlarmas(
|
||||
servicio: ServicioAlarmas(reloj: () => DateTime(2026, 5, 25, 7)),
|
||||
android: android,
|
||||
iniciarAutomaticamente: false,
|
||||
);
|
||||
addTearDown(estado.dispose);
|
||||
addTearDown(android.dispose);
|
||||
|
||||
await estado.guardarAlarma(
|
||||
const AlarmaMusical(
|
||||
id: 'silenciosa1',
|
||||
nombre: 'Diaria',
|
||||
hora: 7,
|
||||
minuto: 30,
|
||||
tipoProgramacion: TipoProgramacionAlarma.diaria,
|
||||
diasSemana: [],
|
||||
),
|
||||
);
|
||||
|
||||
final excepcion = estado.ultimaExcepcionPara('silenciosa1');
|
||||
expect(excepcion, isNotNull);
|
||||
expect(excepcion!.tipo, ExcepcionAlarma.tipoFalloProgramacion);
|
||||
expect(estado.error, isNotNull);
|
||||
});
|
||||
|
||||
test('guardarAlarma en el camino feliz (conteo nativo coincide) no registra '
|
||||
'fallo alguno', () async {
|
||||
final android = FakePuertoAlarmasAndroid();
|
||||
final estado = EstadoAlarmas(
|
||||
servicio: ServicioAlarmas(reloj: () => DateTime(2026, 5, 25, 7)),
|
||||
android: android,
|
||||
iniciarAutomaticamente: false,
|
||||
);
|
||||
addTearDown(estado.dispose);
|
||||
addTearDown(android.dispose);
|
||||
|
||||
await estado.guardarAlarma(
|
||||
const AlarmaMusical(
|
||||
id: 'sana1',
|
||||
nombre: 'Diaria',
|
||||
hora: 7,
|
||||
minuto: 30,
|
||||
tipoProgramacion: TipoProgramacionAlarma.diaria,
|
||||
diasSemana: [],
|
||||
),
|
||||
);
|
||||
|
||||
expect(estado.ultimaExcepcionPara('sana1'), isNull);
|
||||
expect(estado.error, isNull);
|
||||
});
|
||||
|
||||
test(
|
||||
'inicializar importa los fallos nativos (pre-aviso, servicio en primer '
|
||||
'plano, reprogramacion tras arranque) como excepciones por alarma',
|
||||
() async {
|
||||
final android =
|
||||
FakePuertoAlarmasAndroid()
|
||||
..fallosProgramacionNativos.addAll([
|
||||
FalloProgramacionNativo(
|
||||
alarmaId: 'con-preaviso-roto',
|
||||
tipo: ExcepcionAlarma.tipoFalloPreaviso,
|
||||
ocurridoEn: DateTime(2026, 5, 25, 7),
|
||||
),
|
||||
FalloProgramacionNativo(
|
||||
alarmaId: 'servicio-rechazado',
|
||||
tipo: ExcepcionAlarma.tipoFalloServicioSonido,
|
||||
ocurridoEn: DateTime(2026, 5, 25, 7),
|
||||
),
|
||||
]);
|
||||
final servicio = ServicioAlarmas(
|
||||
reloj: () => DateTime(2026, 5, 25, 7, 30),
|
||||
);
|
||||
await servicio.guardarAlarma(
|
||||
AlarmaMusical(
|
||||
id: 'con-preaviso-roto',
|
||||
nombre: 'Diaria',
|
||||
hora: 7,
|
||||
minuto: 30,
|
||||
tipoProgramacion: TipoProgramacionAlarma.diaria,
|
||||
diasSemana: const [],
|
||||
),
|
||||
);
|
||||
await servicio.guardarAlarma(
|
||||
AlarmaMusical(
|
||||
id: 'servicio-rechazado',
|
||||
nombre: 'Diaria',
|
||||
hora: 8,
|
||||
minuto: 0,
|
||||
tipoProgramacion: TipoProgramacionAlarma.diaria,
|
||||
diasSemana: const [],
|
||||
),
|
||||
);
|
||||
|
||||
final estado = EstadoAlarmas(
|
||||
servicio: servicio,
|
||||
android: android,
|
||||
iniciarAutomaticamente: false,
|
||||
);
|
||||
addTearDown(estado.dispose);
|
||||
addTearDown(android.dispose);
|
||||
|
||||
await estado.inicializar();
|
||||
|
||||
final falloPreaviso = estado.ultimaExcepcionPara('con-preaviso-roto');
|
||||
expect(falloPreaviso, isNotNull);
|
||||
expect(falloPreaviso!.tipo, ExcepcionAlarma.tipoFalloPreaviso);
|
||||
|
||||
final falloServicio = estado.ultimaExcepcionPara('servicio-rechazado');
|
||||
expect(falloServicio, isNotNull);
|
||||
expect(falloServicio!.tipo, ExcepcionAlarma.tipoFalloServicioSonido);
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:pluriwave/estado/estado_grabacion.dart';
|
||||
import 'package:pluriwave/modelos/emisora.dart';
|
||||
|
||||
/// Reported: recording a station stopped working, with this on screen —
|
||||
///
|
||||
/// No se pudo iniciar la grabación: Invalid argument(s): Unsupported scheme
|
||||
/// 'content' in URI content://com.android.externalstorage.documents/tree/
|
||||
/// primary%3AMusic/document/primary%3AMusic%2F...%2FNew Limit - Smile.mp3
|
||||
///
|
||||
/// The URI in the message is a LOCAL MP3, not a station.
|
||||
/// `PluriWaveAudioHandler._cambiarFuente` sets `emisoraActual` for every
|
||||
/// source it plays, so a local track becomes an `Emisora` whose `url` is the
|
||||
/// SAF `content://` document URI it was opened from. The recorder then tried
|
||||
/// to open that as an HTTP stream.
|
||||
///
|
||||
/// "It used to work" is accurate: before local music playback existed,
|
||||
/// whatever was playing was always a real station, so this could not happen.
|
||||
void main() {
|
||||
Emisora conUrl(String url) => Emisora(uuid: 'u', nombre: 'n', url: url);
|
||||
|
||||
test('un stream de red es grabable', () {
|
||||
expect(esEmisoraGrabable(conUrl('http://stream.example.com/live')), isTrue);
|
||||
expect(
|
||||
esEmisoraGrabable(conUrl('https://stream.example.com/live')),
|
||||
isTrue,
|
||||
);
|
||||
expect(
|
||||
esEmisoraGrabable(conUrl('HTTPS://STREAM.EXAMPLE.COM/live')),
|
||||
isTrue,
|
||||
reason: 'el esquema no distingue mayúsculas',
|
||||
);
|
||||
});
|
||||
|
||||
test('la URI content:// de una pista local NO es grabable — el caso '
|
||||
'exacto del reporte', () {
|
||||
expect(
|
||||
esEmisoraGrabable(
|
||||
conUrl(
|
||||
'content://com.android.externalstorage.documents/tree/'
|
||||
'primary%3AMusic/document/primary%3AMusic%2FNew%20Limit%20-%20'
|
||||
'Smile.mp3',
|
||||
),
|
||||
),
|
||||
isFalse,
|
||||
);
|
||||
});
|
||||
|
||||
test('ni un fichero local, ni una url vacía o rota', () {
|
||||
expect(
|
||||
esEmisoraGrabable(conUrl('file:///storage/emulated/0/a.mp3')),
|
||||
isFalse,
|
||||
);
|
||||
expect(esEmisoraGrabable(conUrl('')), isFalse);
|
||||
expect(esEmisoraGrabable(conUrl('no es una uri')), isFalse);
|
||||
});
|
||||
}
|
||||
@@ -607,6 +607,136 @@ void main() {
|
||||
);
|
||||
});
|
||||
|
||||
test(
|
||||
'fix android-auto-orden: el snapshot de favoritos es EXACTAMENTE '
|
||||
'listaFavoritosManual (el orden manual que la pantalla Favoritos '
|
||||
'muestra y reordena), nunca la lista cruda ni un getter reordenado',
|
||||
() async {
|
||||
final favoritosServicio = FakeServicioFavoritos();
|
||||
// Agregados en orden Z, A: si el push usara un getter reordenado
|
||||
// (p. ej. alfabético), 'alfa' iría primero. El orden manual conserva
|
||||
// el orden de inserción/persistencia: Z, A.
|
||||
await favoritosServicio.agregar(
|
||||
emisoraDemo(uuid: 'zulu', nombre: 'Zulu Fav'),
|
||||
);
|
||||
await favoritosServicio.agregar(
|
||||
emisoraDemo(uuid: 'alfa', nombre: 'Alfa Fav'),
|
||||
);
|
||||
final fuenteAuto = _FuenteEmisorasAutoEspia();
|
||||
final estado = EstadoRadio(
|
||||
audio: FakeServicioAudio(),
|
||||
favoritos: favoritosServicio,
|
||||
radio: FakeServicioRadio(),
|
||||
servicioEcualizador: FakeServicioEcualizador(),
|
||||
resolverArchivoCustom: _archivoCustomVacio,
|
||||
fuenteAuto: fuenteAuto,
|
||||
iniciarAutomaticamente: false,
|
||||
);
|
||||
|
||||
await estado.inicializar();
|
||||
|
||||
expect(fuenteAuto.ultimoFavoritos?.map((e) => e.uuid).toList(), [
|
||||
'zulu',
|
||||
'alfa',
|
||||
]);
|
||||
expect(fuenteAuto.ultimoFavoritos, equals(estado.listaFavoritosManual));
|
||||
},
|
||||
);
|
||||
|
||||
test('fix android-auto-orden: el snapshot de "Todas" honra el orden '
|
||||
'global (ordenListas) igual que "Tendencias" en el teléfono, no el '
|
||||
'orden crudo de llegada de la API', () async {
|
||||
SharedPreferences.setMockInitialValues({
|
||||
'orden_listas_emisoras_v1': 'nombre',
|
||||
});
|
||||
final fuenteAuto = _FuenteEmisorasAutoEspia();
|
||||
final estado = EstadoRadio(
|
||||
audio: FakeServicioAudio(),
|
||||
favoritos: FakeServicioFavoritos(),
|
||||
radio: FakeServicioRadio(
|
||||
populares: [
|
||||
emisoraDemo(uuid: 'zulu-pop', nombre: 'Zulu Pop'),
|
||||
emisoraDemo(uuid: 'alfa-pop', nombre: 'Alfa Pop'),
|
||||
],
|
||||
),
|
||||
servicioEcualizador: FakeServicioEcualizador(),
|
||||
resolverArchivoCustom: _archivoCustomVacio,
|
||||
fuenteAuto: fuenteAuto,
|
||||
iniciarAutomaticamente: false,
|
||||
);
|
||||
|
||||
await estado.inicializar();
|
||||
|
||||
expect(fuenteAuto.ultimoTodas?.map((e) => e.uuid).toList(), [
|
||||
'alfa-pop',
|
||||
'zulu-pop',
|
||||
]);
|
||||
});
|
||||
|
||||
test('fix android-auto-orden: el snapshot de "Mis emisoras" honra el '
|
||||
'orden global (ordenListas), no el orden crudo del archivo', () async {
|
||||
SharedPreferences.setMockInitialValues({
|
||||
'orden_listas_emisoras_v1': 'nombre',
|
||||
});
|
||||
final archivo = await _crearArchivoCustom([
|
||||
emisoraDemo(uuid: 'zulu-custom', nombre: 'Zulu Custom'),
|
||||
emisoraDemo(uuid: 'alfa-custom', nombre: 'Alfa Custom'),
|
||||
]);
|
||||
final fuenteAuto = _FuenteEmisorasAutoEspia();
|
||||
final estado = EstadoRadio(
|
||||
audio: FakeServicioAudio(),
|
||||
favoritos: FakeServicioFavoritos(),
|
||||
radio: FakeServicioRadio(),
|
||||
servicioEcualizador: FakeServicioEcualizador(),
|
||||
resolverArchivoCustom: () async => archivo,
|
||||
fuenteAuto: fuenteAuto,
|
||||
iniciarAutomaticamente: false,
|
||||
);
|
||||
|
||||
await estado.inicializar();
|
||||
|
||||
expect(fuenteAuto.ultimoMisEmisoras?.map((e) => e.uuid).toList(), [
|
||||
'alfa-custom',
|
||||
'zulu-custom',
|
||||
]);
|
||||
});
|
||||
|
||||
test('fix android-auto-orden: cambiarOrdenListas re-empuja de inmediato '
|
||||
'los snapshots de "Todas" y "Mis emisoras" con el nuevo orden, sin '
|
||||
'esperar a la próxima recarga completa', () async {
|
||||
final archivo = await _crearArchivoCustom([
|
||||
emisoraDemo(uuid: 'zulu-custom', nombre: 'Zulu Custom'),
|
||||
emisoraDemo(uuid: 'alfa-custom', nombre: 'Alfa Custom'),
|
||||
]);
|
||||
final fuenteAuto = _FuenteEmisorasAutoEspia();
|
||||
final estado = EstadoRadio(
|
||||
audio: FakeServicioAudio(),
|
||||
favoritos: FakeServicioFavoritos(),
|
||||
radio: FakeServicioRadio(
|
||||
populares: [
|
||||
emisoraDemo(uuid: 'zulu-pop', nombre: 'Zulu Pop'),
|
||||
emisoraDemo(uuid: 'alfa-pop', nombre: 'Alfa Pop'),
|
||||
],
|
||||
),
|
||||
servicioEcualizador: FakeServicioEcualizador(),
|
||||
resolverArchivoCustom: () async => archivo,
|
||||
fuenteAuto: fuenteAuto,
|
||||
iniciarAutomaticamente: false,
|
||||
);
|
||||
|
||||
await estado.inicializar();
|
||||
await estado.cambiarOrdenListas(OrdenEmisoras.nombre);
|
||||
|
||||
expect(fuenteAuto.ultimoTodas?.map((e) => e.uuid).toList(), [
|
||||
'alfa-pop',
|
||||
'zulu-pop',
|
||||
]);
|
||||
expect(fuenteAuto.ultimoMisEmisoras?.map((e) => e.uuid).toList(), [
|
||||
'alfa-custom',
|
||||
'zulu-custom',
|
||||
]);
|
||||
});
|
||||
|
||||
test('reconcilia _emisoraSeleccionada cuando la selección viene desde '
|
||||
'el auto (no via reproducir())', () async {
|
||||
final audio = _AudioControlado();
|
||||
|
||||
@@ -14,15 +14,57 @@ class FakePuertoAlarmasAndroid implements PuertoAlarmasAndroid {
|
||||
final soloOcultadas = <String>[];
|
||||
final ejecucionesNativas = <EjecucionAlarmaNativa>[];
|
||||
final snoozesNativos = <EstadoSnoozeNativo>[];
|
||||
final fallosProgramacionNativos = <FalloProgramacionNativo>[];
|
||||
final _eventos = StreamController<EventoAlarmaAndroid>.broadcast();
|
||||
bool ignoraOptimizacionBateria = true;
|
||||
int solicitudesExencionBateria = 0;
|
||||
int aperturasConfiguracionNotificaciones = 0;
|
||||
|
||||
/// Extra diagnostico() fields (fix/alarmas-fiabilidad diagnostics screen).
|
||||
/// Default values mirror the previous hardcoded literals in [diagnostico],
|
||||
/// so every existing test that never sets these keeps seeing the exact
|
||||
/// same snapshot as before.
|
||||
bool puedeProgramarExactas = true;
|
||||
bool notificacionesPermitidas = true;
|
||||
bool puedeUsarPantallaCompleta = true;
|
||||
String fabricante = 'test';
|
||||
int versionSdk = 35;
|
||||
|
||||
/// Ids [programar] most recently scheduled as active-with-a-next-run (kept
|
||||
/// in sync with [cancelar] too), mirroring the real native scheduler's own
|
||||
/// pending-alarm registry (fix/alarmas-fallos-silenciosos, item 3: "verify
|
||||
/// the alarm is actually registered"). Backs [alarmasNativasPendientes]'s
|
||||
/// DEFAULT so a test that never touches that field gets a value that
|
||||
/// tracks reality instead of a frozen `0` -- a test that explicitly
|
||||
/// assigns the field (many `pantalla_diagnostico_alarmas_test.dart` cases
|
||||
/// do, to model a stale/corrupt native count on purpose) keeps getting
|
||||
/// EXACTLY that value regardless of what programar/cancelar do afterward.
|
||||
final _idsRegistradosNativamente = <String>{};
|
||||
int? _alarmasNativasPendientesFijado;
|
||||
|
||||
int get alarmasNativasPendientes =>
|
||||
_alarmasNativasPendientesFijado ?? _idsRegistradosNativamente.length;
|
||||
|
||||
set alarmasNativasPendientes(int valor) =>
|
||||
_alarmasNativasPendientesFijado = valor;
|
||||
|
||||
/// Test-only failure switch (diagnostics screen, "intent not resolving"
|
||||
/// coverage): when true, every `abrir*`/`solicitar*` system-screen action
|
||||
/// below reports failure (as a real device does when a ROM lacks that
|
||||
/// settings screen), while still recording the attempt via its counter.
|
||||
bool fallaAccionSistema = false;
|
||||
|
||||
/// Test-only failure switch (Design D7): when true, [programar] throws
|
||||
/// instead of scheduling, enabling failure-path coverage that the fake
|
||||
/// could not otherwise produce.
|
||||
bool fallaProgramar = false;
|
||||
|
||||
/// Test-only PER-ALARM failure switch (fix/alarmas-fallos-silenciosos):
|
||||
/// [programar] throws only for ids in this set, letting a test simulate
|
||||
/// one alarm failing to schedule while its siblings succeed -- the global
|
||||
/// [fallaProgramar] switch cannot express that (it fails everything).
|
||||
final Set<String> idsFallanProgramar = {};
|
||||
|
||||
/// Test-only failure switch: when true, [detenerSonidoActivo] reports an
|
||||
/// unconfirmed/failed stop instead of a confirmed one.
|
||||
bool fallaDetener = false;
|
||||
@@ -55,15 +97,21 @@ class FakePuertoAlarmasAndroid implements PuertoAlarmasAndroid {
|
||||
|
||||
@override
|
||||
Future<void> programar(AlarmaMusical alarma) async {
|
||||
if (fallaProgramar) {
|
||||
if (fallaProgramar || idsFallanProgramar.contains(alarma.id)) {
|
||||
throw StateError('fake programar failure');
|
||||
}
|
||||
programadas.add(alarma);
|
||||
if (alarma.activa && alarma.proximaProgramable != null) {
|
||||
_idsRegistradosNativamente.add(alarma.id);
|
||||
} else {
|
||||
_idsRegistradosNativamente.remove(alarma.id);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> cancelar(String alarmaId) async {
|
||||
canceladas.add(alarmaId);
|
||||
_idsRegistradosNativamente.remove(alarmaId);
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -107,19 +155,25 @@ class FakePuertoAlarmasAndroid implements PuertoAlarmasAndroid {
|
||||
@override
|
||||
Future<DiagnosticoAlarmasAndroid> diagnostico() async =>
|
||||
DiagnosticoAlarmasAndroid(
|
||||
puedeProgramarExactas: true,
|
||||
notificacionesPermitidas: true,
|
||||
puedeUsarPantallaCompleta: true,
|
||||
puedeProgramarExactas: puedeProgramarExactas,
|
||||
notificacionesPermitidas: notificacionesPermitidas,
|
||||
puedeUsarPantallaCompleta: puedeUsarPantallaCompleta,
|
||||
ignoraOptimizacionBateria: ignoraOptimizacionBateria,
|
||||
alarmasNativasPendientes: 0,
|
||||
fabricante: 'test',
|
||||
versionSdk: 35,
|
||||
alarmasNativasPendientes: alarmasNativasPendientes,
|
||||
fabricante: fabricante,
|
||||
versionSdk: versionSdk,
|
||||
);
|
||||
|
||||
@override
|
||||
Future<bool> solicitarExencionBateria() async {
|
||||
solicitudesExencionBateria++;
|
||||
return true;
|
||||
return !fallaAccionSistema;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<bool> abrirConfiguracionNotificaciones() async {
|
||||
aperturasConfiguracionNotificaciones++;
|
||||
return !fallaAccionSistema;
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -134,13 +188,46 @@ class FakePuertoAlarmasAndroid implements PuertoAlarmasAndroid {
|
||||
List.of(snoozesNativos);
|
||||
|
||||
@override
|
||||
Future<bool> solicitarPermisoAlarmasExactas() async => true;
|
||||
Future<List<FalloProgramacionNativo>>
|
||||
obtenerFallosProgramacionNativos() async =>
|
||||
List.of(fallosProgramacionNativos);
|
||||
|
||||
int solicitudesPermisoAlarmasExactas = 0;
|
||||
int solicitudesPermisoPantallaCompleta = 0;
|
||||
|
||||
/// Native-recorded failures the next read should return. Tests seed this
|
||||
/// to simulate a pre-notice that never armed, a refused foreground-service
|
||||
/// start, or a per-alarm reschedule that failed after a reboot.
|
||||
List<FalloProgramacionNativo> fallosNativos = const [];
|
||||
|
||||
int lecturasFallosNativos = 0;
|
||||
|
||||
/// Simulates an older native build with no such channel method.
|
||||
bool fallaLecturaFallosNativos = false;
|
||||
|
||||
@override
|
||||
Future<List<FalloProgramacionNativo>> fallosNativosProgramacion() async {
|
||||
lecturasFallosNativos++;
|
||||
if (fallaLecturaFallosNativos) {
|
||||
throw StateError('canal no disponible');
|
||||
}
|
||||
return fallosNativos;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<bool> solicitarPermisoAlarmasExactas() async {
|
||||
solicitudesPermisoAlarmasExactas++;
|
||||
return !fallaAccionSistema;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<bool> solicitarPermisoNotificaciones() async => true;
|
||||
|
||||
@override
|
||||
Future<bool> solicitarPermisoPantallaCompleta() async => true;
|
||||
Future<bool> solicitarPermisoPantallaCompleta() async {
|
||||
solicitudesPermisoPantallaCompleta++;
|
||||
return !fallaAccionSistema;
|
||||
}
|
||||
|
||||
Future<void> dispose() => _eventos.close();
|
||||
}
|
||||
|
||||
@@ -272,4 +272,8 @@ const Set<(String locale, String key)> identicalValueAllowlist = {
|
||||
'pt',
|
||||
'searchResultsCount',
|
||||
), // WU18 new key (task 18.3) -- "resultado(s)" is an es/pt cognate
|
||||
(
|
||||
'pt',
|
||||
'alarmDiagnosticsManufacturerLabel',
|
||||
), // fix/alarmas-fiabilidad new key -- "Fabricante" is identical in pt/es
|
||||
};
|
||||
|
||||
@@ -5,6 +5,7 @@ import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:pluriwave/estado/estado_radio.dart';
|
||||
import 'package:pluriwave/l10n/gen/app_localizations.dart';
|
||||
import 'package:pluriwave/pantallas/ajustes/pantalla_ajustes_info.dart';
|
||||
import 'package:pluriwave/pantallas/pantalla_tutorial_ayuda.dart';
|
||||
import 'package:pluriwave/widgets/pluri_push_scaffold.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
@@ -98,4 +99,24 @@ void main() {
|
||||
expect(find.text('Help and tutorial'), findsOneWidget);
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets('tapping "Help and tutorial" opens the tutorial carousel with '
|
||||
'primerArranque: false (so its last page reads "Close", not '
|
||||
'"Start listening")', (tester) async {
|
||||
_suppressListTileInkAssertion();
|
||||
final estado = await crearEstado();
|
||||
addTearDown(estado.dispose);
|
||||
|
||||
await tester.pumpWidget(buildScreen(estado));
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 100));
|
||||
|
||||
await tester.tap(find.text('Help and tutorial'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final pantalla = tester.widget<PantallaTutorialAyuda>(
|
||||
find.byType(PantallaTutorialAyuda),
|
||||
);
|
||||
expect(pantalla.primerArranque, isFalse);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -306,6 +306,32 @@ void main() {
|
||||
expect(find.text('RECORDINGS & MUSIC'), findsOneWidget);
|
||||
expect(find.text('APPLICATION'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets(
|
||||
'Issue 3 (feedback-pruebas): the gap between stacked settings groups '
|
||||
'is 16, matching t4:523/534/541 -- not 12',
|
||||
(tester) async {
|
||||
setLargeSurface(tester);
|
||||
_suppressListTileInkAssertion();
|
||||
final estado = await crearEstado();
|
||||
addTearDown(estado.dispose);
|
||||
|
||||
await tester.pumpWidget(buildAjustes(estado));
|
||||
await pumpStable(tester);
|
||||
|
||||
for (final key in [
|
||||
'ajustes-group-gap-1',
|
||||
'ajustes-group-gap-2',
|
||||
'ajustes-group-gap-3',
|
||||
]) {
|
||||
expect(
|
||||
tester.getSize(find.byKey(ValueKey(key))).height,
|
||||
16,
|
||||
reason: 't4:523/534/541 all draw a 16px gap between stacked groups',
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -99,8 +99,8 @@ void main() {
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
});
|
||||
|
||||
testWidgets('visual fidelity (audit 9.4): the date line renders between the '
|
||||
'schedule pill and the hero time (t4:419)', (tester) async {
|
||||
testWidgets('visual fidelity (audit 9.4): the date line renders BELOW the '
|
||||
'hero time (t4:415-419: pill, then 7:30, then the date)', (tester) async {
|
||||
await _montarPantalla(tester);
|
||||
|
||||
final localeTag =
|
||||
@@ -111,16 +111,22 @@ void main() {
|
||||
|
||||
expect(find.text(esperado), findsOneWidget);
|
||||
|
||||
// Order: pill above the date line, date line above the hero time.
|
||||
// Order: pill, then the hero time, then the date line. This test used
|
||||
// to assert date-before-time and cited "t4:419" for it — but 419 is
|
||||
// simply the source line the date occupies, and in the prototype it
|
||||
// comes AFTER the 88px time on line 417. The citation refuted the
|
||||
// assertion it was supporting.
|
||||
final pillY =
|
||||
tester
|
||||
.getBottomLeft(find.byKey(const ValueKey('ringing-schedule-pill')))
|
||||
.dy;
|
||||
final dateY = tester.getTopLeft(find.text(esperado)).dy;
|
||||
final timeY =
|
||||
tester.getTopLeft(find.byKey(const ValueKey('ringing-hero-time'))).dy;
|
||||
expect(pillY <= dateY, isTrue);
|
||||
expect(dateY <= timeY, isTrue);
|
||||
tester
|
||||
.getBottomLeft(find.byKey(const ValueKey('ringing-hero-time')))
|
||||
.dy;
|
||||
final dateY = tester.getTopLeft(find.text(esperado)).dy;
|
||||
expect(pillY <= timeY, isTrue, reason: 'pill sits above the time');
|
||||
expect(timeY <= dateY, isTrue, reason: 'the date sits below the time');
|
||||
|
||||
// Regression guard: pumpAndSettle must still complete (purely
|
||||
// additive static text, no new animation).
|
||||
@@ -159,4 +165,49 @@ void main() {
|
||||
await tester.pumpAndSettle();
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets(
|
||||
'Issue 3 (feedback-pruebas): the gap above the snooze tiles matches the '
|
||||
'gap below them (t4:427 draws a uniform gap:12 flex column) -- the '
|
||||
'previous 10/14 pair matched neither the prototype nor each other',
|
||||
(tester) async {
|
||||
await _montarPantalla(tester);
|
||||
final l10n = AppLocalizations.of(
|
||||
tester.element(find.byType(PantallaAlarmaSonando)),
|
||||
);
|
||||
|
||||
final eyebrowBottom =
|
||||
tester
|
||||
.getBottomLeft(
|
||||
find
|
||||
.ancestor(
|
||||
of: find.byIcon(Icons.snooze_rounded),
|
||||
matching: find.byType(Row),
|
||||
)
|
||||
.first,
|
||||
)
|
||||
.dy;
|
||||
final tileDestacado = find.ancestor(
|
||||
of: find.text(l10n.alarmSnoozeOptionLabel(10)),
|
||||
matching: find.byType(FilledButton),
|
||||
);
|
||||
final tilesTop = tester.getTopLeft(tileDestacado).dy;
|
||||
final tilesBottom = tester.getBottomLeft(tileDestacado).dy;
|
||||
final stopButtonTop =
|
||||
tester
|
||||
.getTopLeft(find.byKey(const ValueKey('ringing-stop-button')))
|
||||
.dy;
|
||||
|
||||
expect(
|
||||
tilesTop - eyebrowBottom,
|
||||
12,
|
||||
reason: 't4:427: gap:12 above the snooze tiles',
|
||||
);
|
||||
expect(
|
||||
stopButtonTop - tilesBottom,
|
||||
12,
|
||||
reason: 't4:427: gap:12 below the snooze tiles, same as above',
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -190,9 +190,26 @@ void main() {
|
||||
expect(antes, isNot(l10n.alarmNoNextExecution));
|
||||
|
||||
// Lunes -> Martes: la fecha calculada SIEMPRE cambia, sea cual sea hoy.
|
||||
await tester.tap(find.text(l10n.weekdayShortTuesday));
|
||||
//
|
||||
// Item 5: the alarm CARD underneath now also renders the real day
|
||||
// abbreviation ("Lun") for a diasSemana alarm, so a bare
|
||||
// `find.text(...)` for a weekday letter is ambiguous while the
|
||||
// editor sheet is open on top of the list — scope to the sheet's own
|
||||
// BottomSheet subtree to target the day-picker circle specifically.
|
||||
final hojaEditor = find.byType(BottomSheet);
|
||||
await tester.tap(
|
||||
find.descendant(
|
||||
of: hojaEditor,
|
||||
matching: find.text(l10n.weekdayShortTuesday),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text(l10n.weekdayShortMonday));
|
||||
await tester.tap(
|
||||
find.descendant(
|
||||
of: hojaEditor,
|
||||
matching: find.text(l10n.weekdayShortMonday),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final despues = _textoPreview(tester);
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:pluriwave/estado/estado_alarmas.dart';
|
||||
import 'package:pluriwave/estado/estado_radio.dart';
|
||||
import 'package:pluriwave/l10n/gen/app_localizations.dart';
|
||||
import 'package:pluriwave/modelos/alarma_musical.dart';
|
||||
import 'package:pluriwave/pantallas/pantalla_alarmas.dart';
|
||||
import 'package:pluriwave/pantallas/pantalla_diagnostico_alarmas.dart';
|
||||
import 'package:pluriwave/servicios/servicio_alarmas.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import '../helpers/fakes.dart';
|
||||
import '../helpers/fakes_alarmas.dart';
|
||||
|
||||
/// Card-level visibility for a failed scheduling attempt (fix/alarmas-
|
||||
/// fallos-silenciosos): before this, `ultimaExcepcionPara` existed but was
|
||||
/// never read from any screen, so a failed alarm rendered exactly like a
|
||||
/// working one -- switched on, no visible sign anything was wrong.
|
||||
void main() {
|
||||
setUp(() {
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
});
|
||||
|
||||
Future<void> montarPantalla(
|
||||
WidgetTester tester,
|
||||
EstadoAlarmas estadoAlarmas,
|
||||
) async {
|
||||
tester.view.physicalSize = const Size(1440, 3200);
|
||||
tester.view.devicePixelRatio = 1.0;
|
||||
addTearDown(tester.view.resetPhysicalSize);
|
||||
addTearDown(tester.view.resetDevicePixelRatio);
|
||||
|
||||
final radio = EstadoRadio(
|
||||
audio: FakeServicioAudio(),
|
||||
favoritos: FakeServicioFavoritos(),
|
||||
radio: FakeServicioRadio(),
|
||||
servicioEcualizador: FakeServicioEcualizador(),
|
||||
servicioGrabacion: FakeServicioGrabacionRadioInactiva(),
|
||||
iniciarAutomaticamente: false,
|
||||
);
|
||||
addTearDown(radio.dispose);
|
||||
|
||||
await tester.pumpWidget(
|
||||
MultiProvider(
|
||||
providers: [
|
||||
ChangeNotifierProvider<EstadoRadio>.value(value: radio),
|
||||
ChangeNotifierProvider<EstadoAlarmas>.value(value: estadoAlarmas),
|
||||
],
|
||||
child: MaterialApp(
|
||||
locale: const Locale('es'),
|
||||
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||
supportedLocales: AppLocalizations.supportedLocales,
|
||||
home: const Scaffold(body: PantallaAlarmas()),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 100));
|
||||
}
|
||||
|
||||
testWidgets(
|
||||
'una alarma cuya programacion fallo muestra un aviso en su tarjeta',
|
||||
(tester) async {
|
||||
final android = FakePuertoAlarmasAndroid()..fallaProgramar = true;
|
||||
final estadoAlarmas = EstadoAlarmas(
|
||||
servicio: ServicioAlarmas(reloj: () => DateTime(2026, 6, 1, 6, 0)),
|
||||
android: android,
|
||||
iniciarAutomaticamente: false,
|
||||
);
|
||||
addTearDown(estadoAlarmas.dispose);
|
||||
addTearDown(android.dispose);
|
||||
await estadoAlarmas.guardarAlarma(
|
||||
const AlarmaMusical(
|
||||
id: 'r1',
|
||||
nombre: 'Rota',
|
||||
hora: 7,
|
||||
minuto: 0,
|
||||
tipoProgramacion: TipoProgramacionAlarma.diaria,
|
||||
diasSemana: [],
|
||||
),
|
||||
);
|
||||
|
||||
await montarPantalla(tester, estadoAlarmas);
|
||||
|
||||
expect(
|
||||
find.byKey(const ValueKey('tarjeta-alarma-fallo-r1')),
|
||||
findsOneWidget,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets(
|
||||
'una alarma programada correctamente NO muestra el aviso de fallo',
|
||||
(tester) async {
|
||||
final android = FakePuertoAlarmasAndroid();
|
||||
final estadoAlarmas = EstadoAlarmas(
|
||||
servicio: ServicioAlarmas(reloj: () => DateTime(2026, 6, 1, 6, 0)),
|
||||
android: android,
|
||||
iniciarAutomaticamente: false,
|
||||
);
|
||||
addTearDown(estadoAlarmas.dispose);
|
||||
addTearDown(android.dispose);
|
||||
await estadoAlarmas.guardarAlarma(
|
||||
const AlarmaMusical(
|
||||
id: 'ok1',
|
||||
nombre: 'Sana',
|
||||
hora: 7,
|
||||
minuto: 0,
|
||||
tipoProgramacion: TipoProgramacionAlarma.diaria,
|
||||
diasSemana: [],
|
||||
),
|
||||
);
|
||||
|
||||
await montarPantalla(tester, estadoAlarmas);
|
||||
|
||||
expect(
|
||||
find.byKey(const ValueKey('tarjeta-alarma-fallo-ok1')),
|
||||
findsNothing,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets('el aviso de fallo abre la pantalla de diagnostico al tocarlo', (
|
||||
tester,
|
||||
) async {
|
||||
final android = FakePuertoAlarmasAndroid()..fallaProgramar = true;
|
||||
final estadoAlarmas = EstadoAlarmas(
|
||||
servicio: ServicioAlarmas(reloj: () => DateTime(2026, 6, 1, 6, 0)),
|
||||
android: android,
|
||||
iniciarAutomaticamente: false,
|
||||
);
|
||||
addTearDown(estadoAlarmas.dispose);
|
||||
addTearDown(android.dispose);
|
||||
await estadoAlarmas.guardarAlarma(
|
||||
const AlarmaMusical(
|
||||
id: 'r1',
|
||||
nombre: 'Rota',
|
||||
hora: 7,
|
||||
minuto: 0,
|
||||
tipoProgramacion: TipoProgramacionAlarma.diaria,
|
||||
diasSemana: [],
|
||||
),
|
||||
);
|
||||
|
||||
await montarPantalla(tester, estadoAlarmas);
|
||||
|
||||
await tester.tap(find.byKey(const ValueKey('tarjeta-alarma-fallo-r1')));
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 300));
|
||||
|
||||
expect(find.byType(PantallaDiagnosticoAlarmas), findsOneWidget);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,330 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:pluriwave/estado/estado_alarmas.dart';
|
||||
import 'package:pluriwave/estado/estado_radio.dart';
|
||||
import 'package:pluriwave/l10n/gen/app_localizations.dart';
|
||||
import 'package:pluriwave/modelos/alarma_musical.dart';
|
||||
import 'package:pluriwave/pantallas/pantalla_alarmas.dart';
|
||||
import 'package:pluriwave/servicios/servicio_alarmas.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import '../helpers/fakes.dart';
|
||||
import '../helpers/fakes_alarmas.dart';
|
||||
|
||||
/// Item 5: the alarm list must show which days a `diasSemana` alarm
|
||||
/// actually fires on (e.g. "Lun, Mié, Vie"), not the generic "Días" label,
|
||||
/// plus surface fade/volume/vacation-pause state when they are genuinely
|
||||
/// informative -- without cluttering the row.
|
||||
void main() {
|
||||
setUp(() {
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
});
|
||||
|
||||
Future<(EstadoRadio, EstadoAlarmas)> montar(
|
||||
WidgetTester tester, {
|
||||
required AlarmaMusical alarma,
|
||||
List<RangoVacaciones> vacaciones = const [],
|
||||
}) async {
|
||||
tester.view.physicalSize = const Size(1440, 3200);
|
||||
tester.view.devicePixelRatio = 1.0;
|
||||
addTearDown(tester.view.resetPhysicalSize);
|
||||
addTearDown(tester.view.resetDevicePixelRatio);
|
||||
|
||||
final radio = EstadoRadio(
|
||||
audio: FakeServicioAudio(),
|
||||
favoritos: FakeServicioFavoritos(),
|
||||
radio: FakeServicioRadio(),
|
||||
servicioEcualizador: FakeServicioEcualizador(),
|
||||
servicioGrabacion: FakeServicioGrabacionRadioInactiva(),
|
||||
iniciarAutomaticamente: false,
|
||||
);
|
||||
addTearDown(radio.dispose);
|
||||
|
||||
final android = FakePuertoAlarmasAndroid();
|
||||
final estadoAlarmas = EstadoAlarmas(
|
||||
servicio: ServicioAlarmas(reloj: () => DateTime(2026, 6, 11, 6, 0)),
|
||||
android: android,
|
||||
iniciarAutomaticamente: false,
|
||||
);
|
||||
addTearDown(estadoAlarmas.dispose);
|
||||
addTearDown(android.dispose);
|
||||
|
||||
await estadoAlarmas.guardarAlarma(alarma);
|
||||
if (vacaciones.isNotEmpty) {
|
||||
await estadoAlarmas.guardarVacaciones(vacaciones);
|
||||
}
|
||||
|
||||
await tester.pumpWidget(
|
||||
MultiProvider(
|
||||
providers: [
|
||||
ChangeNotifierProvider<EstadoRadio>.value(value: radio),
|
||||
ChangeNotifierProvider<EstadoAlarmas>.value(value: estadoAlarmas),
|
||||
],
|
||||
child: MaterialApp(
|
||||
locale: const Locale('es'),
|
||||
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||
supportedLocales: AppLocalizations.supportedLocales,
|
||||
home: const Scaffold(body: PantallaAlarmas()),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 100));
|
||||
|
||||
return (radio, estadoAlarmas);
|
||||
}
|
||||
|
||||
testWidgets(
|
||||
'diasSemana alarm shows the ACTUAL configured days (Lun, Mié, Vie), '
|
||||
'not the generic "Días" label',
|
||||
(tester) async {
|
||||
await montar(
|
||||
tester,
|
||||
alarma: const AlarmaMusical(
|
||||
id: 'a-dias',
|
||||
nombre: 'Entre semana',
|
||||
hora: 7,
|
||||
minuto: 0,
|
||||
tipoProgramacion: TipoProgramacionAlarma.diasSemana,
|
||||
diasSemana: [DateTime.monday, DateTime.wednesday, DateTime.friday],
|
||||
),
|
||||
);
|
||||
|
||||
expect(find.text('Lun, Mié, Vie'), findsOneWidget);
|
||||
expect(find.text('Días'), findsNothing);
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets('daily alarm still shows "Diaria" (unaffected)', (
|
||||
tester,
|
||||
) async {
|
||||
await montar(
|
||||
tester,
|
||||
alarma: const AlarmaMusical(
|
||||
id: 'a-diaria',
|
||||
nombre: 'Todos los días',
|
||||
hora: 7,
|
||||
minuto: 0,
|
||||
tipoProgramacion: TipoProgramacionAlarma.diaria,
|
||||
diasSemana: [],
|
||||
),
|
||||
);
|
||||
|
||||
expect(find.text('Diaria'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('one-time alarm still shows "Una vez" (unaffected)', (
|
||||
tester,
|
||||
) async {
|
||||
await montar(
|
||||
tester,
|
||||
alarma: const AlarmaMusical(
|
||||
id: 'a-unica',
|
||||
nombre: 'Una sola vez',
|
||||
hora: 7,
|
||||
minuto: 0,
|
||||
tipoProgramacion: TipoProgramacionAlarma.unica,
|
||||
diasSemana: [],
|
||||
fechaUnica: null,
|
||||
),
|
||||
);
|
||||
|
||||
expect(find.text('Una vez'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets(
|
||||
'a diasSemana alarm with an (invalid/legacy) empty diasSemana falls '
|
||||
'back to the generic label instead of showing nothing',
|
||||
(tester) async {
|
||||
await montar(
|
||||
tester,
|
||||
alarma: const AlarmaMusical(
|
||||
id: 'a-dias-vacio',
|
||||
nombre: 'Corrupta',
|
||||
hora: 7,
|
||||
minuto: 0,
|
||||
tipoProgramacion: TipoProgramacionAlarma.diasSemana,
|
||||
diasSemana: [],
|
||||
),
|
||||
);
|
||||
|
||||
expect(find.text('Días'), findsOneWidget);
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets('a configured fade-in shows a compact "Fade-in Ns" detail', (
|
||||
tester,
|
||||
) async {
|
||||
await montar(
|
||||
tester,
|
||||
alarma: const AlarmaMusical(
|
||||
id: 'a-fade',
|
||||
nombre: 'Con fade',
|
||||
hora: 7,
|
||||
minuto: 0,
|
||||
tipoProgramacion: TipoProgramacionAlarma.diaria,
|
||||
diasSemana: [],
|
||||
fadeInSegundos: 8,
|
||||
),
|
||||
);
|
||||
|
||||
expect(find.textContaining('Fade-in 8s'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('no fade-in (0s, the default) shows no fade detail', (
|
||||
tester,
|
||||
) async {
|
||||
await montar(
|
||||
tester,
|
||||
alarma: const AlarmaMusical(
|
||||
id: 'a-sin-fade',
|
||||
nombre: 'Sin fade',
|
||||
hora: 7,
|
||||
minuto: 0,
|
||||
tipoProgramacion: TipoProgramacionAlarma.diaria,
|
||||
diasSemana: [],
|
||||
fadeInSegundos: 0,
|
||||
),
|
||||
);
|
||||
|
||||
expect(find.textContaining('Fade-in'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets(
|
||||
'a non-default volume shows a compact percentage detail',
|
||||
(tester) async {
|
||||
await montar(
|
||||
tester,
|
||||
alarma: const AlarmaMusical(
|
||||
id: 'a-vol',
|
||||
nombre: 'Volumen bajo',
|
||||
hora: 7,
|
||||
minuto: 0,
|
||||
tipoProgramacion: TipoProgramacionAlarma.diaria,
|
||||
diasSemana: [],
|
||||
volumen: 0.5,
|
||||
),
|
||||
);
|
||||
|
||||
expect(find.textContaining('50%'), findsOneWidget);
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets('the default volume (85%) shows no volume detail', (
|
||||
tester,
|
||||
) async {
|
||||
await montar(
|
||||
tester,
|
||||
alarma: const AlarmaMusical(
|
||||
id: 'a-vol-default',
|
||||
nombre: 'Volumen default',
|
||||
hora: 7,
|
||||
minuto: 0,
|
||||
tipoProgramacion: TipoProgramacionAlarma.diaria,
|
||||
diasSemana: [],
|
||||
),
|
||||
);
|
||||
|
||||
expect(find.textContaining('85%'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets(
|
||||
'an alarm paused by a CURRENTLY active vacation range shows a '
|
||||
'vacation-paused detail',
|
||||
(tester) async {
|
||||
final l10n = lookupAppLocalizations(const Locale('es'));
|
||||
await montar(
|
||||
tester,
|
||||
alarma: const AlarmaMusical(
|
||||
id: 'a-vacaciones',
|
||||
nombre: 'Pausada',
|
||||
hora: 7,
|
||||
minuto: 0,
|
||||
tipoProgramacion: TipoProgramacionAlarma.diaria,
|
||||
diasSemana: [],
|
||||
sonarEnVacaciones: false,
|
||||
),
|
||||
vacaciones: [
|
||||
// Wide, real-wall-clock-safe range (rangoVacacionesActivo()
|
||||
// defaults to the REAL DateTime.now(), not this file's injected
|
||||
// `reloj`) -- deliberately spans many years so the test stays
|
||||
// valid regardless of exactly when it runs.
|
||||
RangoVacaciones(
|
||||
id: 'v1',
|
||||
nombre: 'Verano',
|
||||
inicio: DateTime(2020, 1, 1),
|
||||
fin: DateTime(2030, 12, 31),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
expect(
|
||||
find.textContaining(l10n.alarmCardVacationPausedBadge),
|
||||
findsOneWidget,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets(
|
||||
'an alarm that DOES sound during vacations shows NO vacation-paused '
|
||||
'detail even with an active range',
|
||||
(tester) async {
|
||||
final l10n = lookupAppLocalizations(const Locale('es'));
|
||||
await montar(
|
||||
tester,
|
||||
alarma: const AlarmaMusical(
|
||||
id: 'a-suena-vacaciones',
|
||||
nombre: 'Suena igual',
|
||||
hora: 7,
|
||||
minuto: 0,
|
||||
tipoProgramacion: TipoProgramacionAlarma.diaria,
|
||||
diasSemana: [],
|
||||
sonarEnVacaciones: true,
|
||||
),
|
||||
vacaciones: [
|
||||
// Wide, real-wall-clock-safe range (rangoVacacionesActivo()
|
||||
// defaults to the REAL DateTime.now(), not this file's injected
|
||||
// `reloj`) -- deliberately spans many years so the test stays
|
||||
// valid regardless of exactly when it runs.
|
||||
RangoVacaciones(
|
||||
id: 'v1',
|
||||
nombre: 'Verano',
|
||||
inicio: DateTime(2020, 1, 1),
|
||||
fin: DateTime(2030, 12, 31),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
expect(
|
||||
find.textContaining(l10n.alarmCardVacationPausedBadge),
|
||||
findsNothing,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets(
|
||||
'sonarEnVacaciones:false with NO currently-active vacation range shows '
|
||||
'no vacation-paused detail (nothing to be paused BY right now)',
|
||||
(tester) async {
|
||||
final l10n = lookupAppLocalizations(const Locale('es'));
|
||||
await montar(
|
||||
tester,
|
||||
alarma: const AlarmaMusical(
|
||||
id: 'a-sin-rango-activo',
|
||||
nombre: 'Sin vacaciones activas',
|
||||
hora: 7,
|
||||
minuto: 0,
|
||||
tipoProgramacion: TipoProgramacionAlarma.diaria,
|
||||
diasSemana: [],
|
||||
sonarEnVacaciones: false,
|
||||
),
|
||||
);
|
||||
|
||||
expect(
|
||||
find.textContaining(l10n.alarmCardVacationPausedBadge),
|
||||
findsNothing,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,332 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:pluriwave/estado/estado_alarmas.dart';
|
||||
import 'package:pluriwave/l10n/gen/app_localizations.dart';
|
||||
import 'package:pluriwave/modelos/alarma_musical.dart';
|
||||
import 'package:pluriwave/pantallas/pantalla_diagnostico_alarmas.dart';
|
||||
import 'package:pluriwave/servicios/servicio_alarmas.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import '../helpers/fakes_alarmas.dart';
|
||||
|
||||
final _l10n = lookupAppLocalizations(const Locale('en'));
|
||||
|
||||
Future<EstadoAlarmas> _crearEstado({
|
||||
required FakePuertoAlarmasAndroid android,
|
||||
List<AlarmaMusical> alarmas = const [],
|
||||
bool cargarDiagnostico = true,
|
||||
}) async {
|
||||
final estado = EstadoAlarmas(
|
||||
servicio: ServicioAlarmas(),
|
||||
android: android,
|
||||
iniciarAutomaticamente: false,
|
||||
);
|
||||
for (final alarma in alarmas) {
|
||||
await estado.guardarAlarma(alarma);
|
||||
}
|
||||
if (cargarDiagnostico) {
|
||||
await estado.cargarDiagnostico();
|
||||
}
|
||||
return estado;
|
||||
}
|
||||
|
||||
Widget _buildScreen(EstadoAlarmas estado) {
|
||||
return ChangeNotifierProvider<EstadoAlarmas>.value(
|
||||
value: estado,
|
||||
child: MaterialApp(
|
||||
locale: const Locale('en'),
|
||||
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||
supportedLocales: AppLocalizations.supportedLocales,
|
||||
home: const PantallaDiagnosticoAlarmas(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _montarPantalla(WidgetTester tester, EstadoAlarmas estado) async {
|
||||
tester.view.physicalSize = const Size(1440, 3200);
|
||||
tester.view.devicePixelRatio = 1.0;
|
||||
addTearDown(tester.view.resetPhysicalSize);
|
||||
addTearDown(tester.view.resetDevicePixelRatio);
|
||||
|
||||
await tester.pumpWidget(_buildScreen(estado));
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
AlarmaMusical _alarmaActiva() => const AlarmaMusical(
|
||||
id: 'a1',
|
||||
nombre: 'Despertar',
|
||||
hora: 7,
|
||||
minuto: 30,
|
||||
tipoProgramacion: TipoProgramacionAlarma.diaria,
|
||||
diasSemana: [],
|
||||
);
|
||||
|
||||
void main() {
|
||||
setUp(() {
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
});
|
||||
|
||||
testWidgets(
|
||||
'caso todo OK: no muestra ningun boton "Fix" ni el estado de atencion, '
|
||||
'y sin fabricante conocido no muestra la guia de autostart',
|
||||
(tester) async {
|
||||
final android =
|
||||
FakePuertoAlarmasAndroid()
|
||||
..fabricante = 'Google'
|
||||
// A fresh fake defaults alarmasNativasPendientes to 0, which
|
||||
// WOULD read as needs-attention once an alarm is active (that
|
||||
// combination is exactly the diagnostic signal this screen
|
||||
// exists to surface) -- give it a registered count so this
|
||||
// specific scenario is genuinely all-OK.
|
||||
..alarmasNativasPendientes = 1;
|
||||
final estado = await _crearEstado(
|
||||
android: android,
|
||||
alarmas: [_alarmaActiva()],
|
||||
);
|
||||
addTearDown(estado.dispose);
|
||||
addTearDown(android.dispose);
|
||||
|
||||
await _montarPantalla(tester, estado);
|
||||
|
||||
expect(find.text(_l10n.androidReliabilityTitle), findsOneWidget);
|
||||
expect(find.text(_l10n.alarmDiagnosticsFixAction), findsNothing);
|
||||
expect(
|
||||
find.text(_l10n.alarmDiagnosticsNeedsAttentionStatus),
|
||||
findsNothing,
|
||||
);
|
||||
expect(find.text(_l10n.alarmDiagnosticsAutostartTitle), findsNothing);
|
||||
expect(find.text('Google'), findsOneWidget);
|
||||
expect(find.text('35'), findsOneWidget);
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets(
|
||||
'alarmas exactas en atencion: muestra el boton Fix y lo invoca via '
|
||||
'solicitarPermisoAlarmasExactas',
|
||||
(tester) async {
|
||||
final android =
|
||||
FakePuertoAlarmasAndroid()
|
||||
..fabricante = 'Google'
|
||||
..puedeProgramarExactas = false;
|
||||
final estado = await _crearEstado(
|
||||
android: android,
|
||||
alarmas: [_alarmaActiva()],
|
||||
);
|
||||
addTearDown(estado.dispose);
|
||||
addTearDown(android.dispose);
|
||||
|
||||
await _montarPantalla(tester, estado);
|
||||
|
||||
expect(find.text(_l10n.alarmDiagnosticsExactAlarmsTitle), findsOneWidget);
|
||||
expect(find.text(_l10n.alarmDiagnosticsFixAction), findsOneWidget);
|
||||
|
||||
// guardarAlarma's own onboarding request
|
||||
// (EstadoAlarmas._solicitarPermisosNecesariosParaAlarma) already fires
|
||||
// once for this same failing field before the screen even mounts, so
|
||||
// the assertion checks the DELTA the button tap itself caused.
|
||||
final antes = android.solicitudesPermisoAlarmasExactas;
|
||||
await tester.tap(find.text(_l10n.alarmDiagnosticsFixAction));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(android.solicitudesPermisoAlarmasExactas, antes + 1);
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets('notificaciones en atencion: el boton Fix llama a '
|
||||
'abrirConfiguracionNotificaciones (deep link a Settings, no el permiso '
|
||||
'runtime)', (tester) async {
|
||||
final android =
|
||||
FakePuertoAlarmasAndroid()
|
||||
..fabricante = 'Google'
|
||||
..notificacionesPermitidas = false;
|
||||
final estado = await _crearEstado(
|
||||
android: android,
|
||||
alarmas: [_alarmaActiva()],
|
||||
);
|
||||
addTearDown(estado.dispose);
|
||||
addTearDown(android.dispose);
|
||||
|
||||
await _montarPantalla(tester, estado);
|
||||
|
||||
await tester.tap(find.text(_l10n.alarmDiagnosticsFixAction));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(android.aperturasConfiguracionNotificaciones, 1);
|
||||
});
|
||||
|
||||
testWidgets('pantalla completa en atencion: el boton Fix llama a '
|
||||
'solicitarPermisoPantallaCompleta', (tester) async {
|
||||
final android =
|
||||
FakePuertoAlarmasAndroid()
|
||||
..fabricante = 'Google'
|
||||
..puedeUsarPantallaCompleta = false;
|
||||
final estado = await _crearEstado(
|
||||
android: android,
|
||||
alarmas: [_alarmaActiva()],
|
||||
);
|
||||
addTearDown(estado.dispose);
|
||||
addTearDown(android.dispose);
|
||||
|
||||
await _montarPantalla(tester, estado);
|
||||
|
||||
// Same delta reasoning as the exact-alarms test above: the alarm's own
|
||||
// onboarding request already fired once for this field before mount.
|
||||
final antes = android.solicitudesPermisoPantallaCompleta;
|
||||
await tester.tap(find.text(_l10n.alarmDiagnosticsFixAction));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(android.solicitudesPermisoPantallaCompleta, antes + 1);
|
||||
});
|
||||
|
||||
testWidgets('optimizacion de bateria en atencion: el boton Fix llama a '
|
||||
'solicitarExencionBateria', (tester) async {
|
||||
final android =
|
||||
FakePuertoAlarmasAndroid()
|
||||
..fabricante = 'Google'
|
||||
..ignoraOptimizacionBateria = false;
|
||||
final estado = await _crearEstado(
|
||||
android: android,
|
||||
alarmas: [_alarmaActiva()],
|
||||
);
|
||||
addTearDown(estado.dispose);
|
||||
addTearDown(android.dispose);
|
||||
|
||||
await _montarPantalla(tester, estado);
|
||||
|
||||
// Same delta reasoning as the exact-alarms test above: guardarAlarma's
|
||||
// own onboarding request already fired once for this field before the
|
||||
// screen mounts.
|
||||
final antes = android.solicitudesExencionBateria;
|
||||
await tester.tap(find.text(_l10n.alarmDiagnosticsFixAction));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(android.solicitudesExencionBateria, antes + 1);
|
||||
});
|
||||
|
||||
testWidgets(
|
||||
'accion de sistema que falla (ROM sin esa pantalla) muestra un aviso '
|
||||
'en vez de fallar en silencio',
|
||||
(tester) async {
|
||||
final android =
|
||||
FakePuertoAlarmasAndroid()
|
||||
..fabricante = 'Google'
|
||||
..puedeProgramarExactas = false
|
||||
..fallaAccionSistema = true;
|
||||
final estado = await _crearEstado(
|
||||
android: android,
|
||||
alarmas: [_alarmaActiva()],
|
||||
);
|
||||
addTearDown(estado.dispose);
|
||||
addTearDown(android.dispose);
|
||||
|
||||
await _montarPantalla(tester, estado);
|
||||
|
||||
await tester.tap(find.text(_l10n.alarmDiagnosticsFixAction));
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 300));
|
||||
|
||||
expect(
|
||||
find.text(_l10n.alarmDiagnosticsIntentUnavailable),
|
||||
findsOneWidget,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets(
|
||||
'alarmas nativas pendientes: con una alarma activa y conteo 0 muestra '
|
||||
'el aviso de atencion (la senal mas diagnostica del reporte)',
|
||||
(tester) async {
|
||||
final android =
|
||||
FakePuertoAlarmasAndroid()
|
||||
..fabricante = 'Google'
|
||||
..alarmasNativasPendientes = 0;
|
||||
final estado = await _crearEstado(
|
||||
android: android,
|
||||
alarmas: [_alarmaActiva()],
|
||||
);
|
||||
addTearDown(estado.dispose);
|
||||
addTearDown(android.dispose);
|
||||
|
||||
await _montarPantalla(tester, estado);
|
||||
|
||||
expect(
|
||||
find.text(_l10n.alarmDiagnosticsNativeCountValue(0)),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(
|
||||
find.text(_l10n.alarmDiagnosticsNativeCountAttentionHint),
|
||||
findsOneWidget,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets(
|
||||
'alarmas nativas pendientes: con al menos una registrada no muestra '
|
||||
'el aviso de atencion',
|
||||
(tester) async {
|
||||
final android =
|
||||
FakePuertoAlarmasAndroid()
|
||||
..fabricante = 'Google'
|
||||
..alarmasNativasPendientes = 2;
|
||||
final estado = await _crearEstado(
|
||||
android: android,
|
||||
alarmas: [_alarmaActiva()],
|
||||
);
|
||||
addTearDown(estado.dispose);
|
||||
addTearDown(android.dispose);
|
||||
|
||||
await _montarPantalla(tester, estado);
|
||||
|
||||
expect(
|
||||
find.text(_l10n.alarmDiagnosticsNativeCountValue(2)),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(
|
||||
find.text(_l10n.alarmDiagnosticsNativeCountAttentionHint),
|
||||
findsNothing,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets('fabricante Xiaomi muestra la guia de autostart con el nombre '
|
||||
'interpolado', (tester) async {
|
||||
final android = FakePuertoAlarmasAndroid()..fabricante = 'Xiaomi';
|
||||
final estado = await _crearEstado(
|
||||
android: android,
|
||||
alarmas: [_alarmaActiva()],
|
||||
);
|
||||
addTearDown(estado.dispose);
|
||||
addTearDown(android.dispose);
|
||||
|
||||
await _montarPantalla(tester, estado);
|
||||
|
||||
expect(find.text(_l10n.alarmDiagnosticsAutostartTitle), findsOneWidget);
|
||||
expect(
|
||||
find.text(_l10n.alarmDiagnosticsAutostartBody('Xiaomi')),
|
||||
findsOneWidget,
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets(
|
||||
'diagnostico aun no disponible (null) no falla y muestra un aviso en '
|
||||
'vez de romper la pantalla',
|
||||
(tester) async {
|
||||
// No alarms saved either: guardarAlarma() itself populates
|
||||
// _diagnostico as a side effect of its own onboarding permission
|
||||
// check, so reaching a genuinely null diagnostic requires an
|
||||
// EstadoAlarmas that never called guardarAlarma or cargarDiagnostico.
|
||||
final android = FakePuertoAlarmasAndroid();
|
||||
final estado = await _crearEstado(
|
||||
android: android,
|
||||
cargarDiagnostico: false,
|
||||
);
|
||||
addTearDown(estado.dispose);
|
||||
addTearDown(android.dispose);
|
||||
|
||||
await _montarPantalla(tester, estado);
|
||||
|
||||
expect(find.text(_l10n.alarmDiagnosticsUnavailableHint), findsOneWidget);
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -7,7 +7,9 @@ import 'package:pluriwave/l10n/gen/app_localizations.dart';
|
||||
import 'package:pluriwave/pantallas/ajustes/pantalla_ajustes_grupos_favoritos.dart';
|
||||
import 'package:pluriwave/pantallas/pantalla_favoritos.dart';
|
||||
import 'package:pluriwave/widgets/fila_emisora_plana.dart';
|
||||
import 'package:pluriwave/widgets/pluri_layout.dart';
|
||||
import 'package:pluriwave/widgets/pluri_push_scaffold.dart';
|
||||
import 'package:pluriwave/widgets/pluri_root_header.dart';
|
||||
import 'package:pluriwave/widgets/tarjeta_emisora.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
@@ -546,6 +548,106 @@ void main() {
|
||||
expect(inactivo.backgroundColor, const Color(0xFF102532));
|
||||
});
|
||||
});
|
||||
|
||||
group('Issue 3 (feedback-pruebas): spacing tiers', () {
|
||||
testWidgets('the header title sits at title-tier inset (20px) -- '
|
||||
'ReorderableListView.padding used to double up on top of '
|
||||
"PluriRootHeader's own internal inset", (tester) async {
|
||||
setLargeSurface(tester);
|
||||
_suppressListTileInkAssertion();
|
||||
final estado = await crearEstadoConFavoritos();
|
||||
addTearDown(estado.dispose);
|
||||
final l10n = lookupAppLocalizations(const Locale('en'));
|
||||
|
||||
await tester.pumpWidget(buildScreen(estado));
|
||||
await pumpStable(tester);
|
||||
|
||||
final titulo = find.descendant(
|
||||
of: find.byType(PluriRootHeader),
|
||||
matching: find.text(l10n.favoritesTitle),
|
||||
);
|
||||
expect(
|
||||
tester.getTopLeft(titulo).dx,
|
||||
PluriLayout.titleHorizontal,
|
||||
reason:
|
||||
'PluriRootHeader already supplies its own 20px inset; the '
|
||||
'previous ReorderableListView.padding of 16 doubled up on top '
|
||||
'of it, landing the title at 36px instead of 20px -- the ONE '
|
||||
"root screen whose header didn't match Alarmas/Ajustes",
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets(
|
||||
'the header sits at the SAME horizontal position whether the list is '
|
||||
'empty or populated -- two mutually-exclusive states of the same '
|
||||
'header must not read differently',
|
||||
(tester) async {
|
||||
setLargeSurface(tester);
|
||||
final l10n = lookupAppLocalizations(const Locale('en'));
|
||||
|
||||
final vacio = await crearEstadoVacio();
|
||||
addTearDown(vacio.dispose);
|
||||
await tester.pumpWidget(buildScreen(vacio));
|
||||
await pumpStable(tester);
|
||||
final dxVacio =
|
||||
tester
|
||||
.getTopLeft(
|
||||
find.descendant(
|
||||
of: find.byType(PluriRootHeader),
|
||||
matching: find.text(l10n.favoritesTitle),
|
||||
),
|
||||
)
|
||||
.dx;
|
||||
|
||||
_suppressListTileInkAssertion();
|
||||
final conFavoritos = await crearEstadoConFavoritos();
|
||||
addTearDown(conFavoritos.dispose);
|
||||
await tester.pumpWidget(buildScreen(conFavoritos));
|
||||
await pumpStable(tester);
|
||||
final dxConFavoritos =
|
||||
tester
|
||||
.getTopLeft(
|
||||
find.descendant(
|
||||
of: find.byType(PluriRootHeader),
|
||||
matching: find.text(l10n.favoritesTitle),
|
||||
),
|
||||
)
|
||||
.dx;
|
||||
|
||||
expect(
|
||||
dxConFavoritos,
|
||||
dxVacio,
|
||||
reason:
|
||||
'the empty and populated branches of this screen must render '
|
||||
'the SAME header inset -- they previously did not (0 vs 16 '
|
||||
'extra px of list-level padding)',
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets(
|
||||
'each favourite row uses row-tier horizontal inset (12), not the '
|
||||
'card-tier constant a background-less row was never meant to carry',
|
||||
(tester) async {
|
||||
setLargeSurface(tester);
|
||||
_suppressListTileInkAssertion();
|
||||
final estado = await crearEstadoConFavoritos();
|
||||
addTearDown(estado.dispose);
|
||||
|
||||
await tester.pumpWidget(buildScreen(estado));
|
||||
await pumpStable(tester);
|
||||
|
||||
expect(
|
||||
tester.getTopLeft(find.byType(FilaEmisoraPlana).first).dx,
|
||||
PluriLayout.rowHorizontal,
|
||||
reason:
|
||||
'audit 4.3: background-less rows are row tier (12), matching '
|
||||
'the same widget already fixed on Buscar -- not card tier '
|
||||
'(16)',
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
void setLargeSurface(WidgetTester tester) {
|
||||
|
||||
@@ -216,6 +216,35 @@ void main() {
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets(
|
||||
'Issue 3 (feedback-pruebas): the gap between the storage card and the '
|
||||
'rows below is 16, matching t4:617 -- not 12',
|
||||
(tester) async {
|
||||
final estado = EstadoGrabacion(
|
||||
servicio: _FakeServicioGrabacionConArchivos([
|
||||
fijaA,
|
||||
], maxBytesFijo: 200 * 1024 * 1024),
|
||||
);
|
||||
addTearDown(estado.dispose);
|
||||
|
||||
await tester.pumpWidget(
|
||||
buildScreen(
|
||||
estado: estado,
|
||||
reproductor: _ReproductorGrabacionesFake(const {}),
|
||||
),
|
||||
);
|
||||
await pumpStable(tester);
|
||||
|
||||
expect(
|
||||
tester
|
||||
.getSize(find.byKey(const ValueKey('grabaciones-storage-gap')))
|
||||
.height,
|
||||
16,
|
||||
reason: 't4:617 draws a 16px gap here',
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets('15.2-A: 3 recording fixtures render as 3 rows', (tester) async {
|
||||
final estado = EstadoGrabacion(
|
||||
servicio: _FakeServicioGrabacionConArchivos([
|
||||
|
||||
@@ -262,6 +262,27 @@ void main() {
|
||||
|
||||
expect(find.byIcon(Icons.search_rounded), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('Issue 3 (feedback-pruebas): the gap between "Tus idiomas" and '
|
||||
'"Todos" is 14, matching t4:260 -- not 16', (tester) async {
|
||||
final estado = EstadoBusqueda(
|
||||
radio: FakeServicioRadio(
|
||||
paises: const [
|
||||
PaisRadio(nombre: 'Spain', codigoIso: 'ES', numeroEmisoras: 482),
|
||||
],
|
||||
),
|
||||
);
|
||||
addTearDown(estado.dispose);
|
||||
|
||||
await tester.pumpWidget(buildScreen(estado));
|
||||
await pumpEstable(tester);
|
||||
|
||||
expect(
|
||||
tester.getSize(find.byKey(const ValueKey('paises-seccion-gap'))).height,
|
||||
14,
|
||||
reason: 't4:260 draws a 14px gap between the two eyebrow sections',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
testWidgets(
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:pluriwave/l10n/gen/app_localizations.dart';
|
||||
import 'package:pluriwave/pantallas/pantalla_tutorial_ayuda.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
/// `PantallaTutorialAyuda.mostrarSiProcede` wires the 9-screen help/tutorial
|
||||
/// carousel into the genuine first-launch flow (`app.dart`), between the
|
||||
/// welcome screen and the recurring "what's new" dialog. This must show
|
||||
/// once -- to both a genuinely fresh install AND an existing install that
|
||||
/// already has other, unrelated flags persisted (e.g. the welcome screen
|
||||
/// already marked seen) -- and never again after that.
|
||||
Widget _appConDisparador(GlobalKey<NavigatorState> navigatorKey) {
|
||||
return MaterialApp(
|
||||
navigatorKey: navigatorKey,
|
||||
locale: const Locale('es'),
|
||||
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||
supportedLocales: AppLocalizations.supportedLocales,
|
||||
home: Builder(
|
||||
builder:
|
||||
(context) => Scaffold(
|
||||
body: Center(
|
||||
child: ElevatedButton(
|
||||
onPressed:
|
||||
() => PantallaTutorialAyuda.mostrarSiProcede(context),
|
||||
child: const Text('disparar'),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
testWidgets(
|
||||
'first launch (no seen flag persisted) shows the tutorial carousel',
|
||||
(tester) async {
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
final navigatorKey = GlobalKey<NavigatorState>();
|
||||
|
||||
await tester.pumpWidget(_appConDisparador(navigatorKey));
|
||||
await tester.tap(find.text('disparar'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byType(PantallaTutorialAyuda), findsOneWidget);
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets('an existing install with unrelated flags already set (e.g. the '
|
||||
'welcome screen already seen) but never this one still shows it', (
|
||||
tester,
|
||||
) async {
|
||||
SharedPreferences.setMockInitialValues({'pluri_bienvenida_vista_v1': true});
|
||||
final navigatorKey = GlobalKey<NavigatorState>();
|
||||
|
||||
await tester.pumpWidget(_appConDisparador(navigatorKey));
|
||||
await tester.tap(find.text('disparar'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byType(PantallaTutorialAyuda), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets(
|
||||
'second launch (seen flag already persisted) does not show it again',
|
||||
(tester) async {
|
||||
SharedPreferences.setMockInitialValues({'pluri_tutorial_visto_v1': true});
|
||||
final navigatorKey = GlobalKey<NavigatorState>();
|
||||
|
||||
await tester.pumpWidget(_appConDisparador(navigatorKey));
|
||||
await tester.tap(find.text('disparar'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byType(PantallaTutorialAyuda), findsNothing);
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets('showing it once persists the flag so a later check in the same '
|
||||
'session skips it', (tester) async {
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
final navigatorKey = GlobalKey<NavigatorState>();
|
||||
|
||||
await tester.pumpWidget(_appConDisparador(navigatorKey));
|
||||
await tester.tap(find.text('disparar'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.byType(PantallaTutorialAyuda), findsOneWidget);
|
||||
|
||||
// Dismiss it the same way "Saltar"/the last CTA does (a plain pop),
|
||||
// simulating the carousel being resolved before the next check happens.
|
||||
navigatorKey.currentState!.pop();
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(find.text('disparar'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.byType(PantallaTutorialAyuda), findsNothing);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:pluriwave/l10n/gen/app_localizations.dart';
|
||||
import 'package:pluriwave/pantallas/pantalla_tutorial_ayuda.dart';
|
||||
|
||||
/// The 9-screen help/tutorial carousel (`PantallaTutorialAyuda`). Content
|
||||
/// spec: mockup screens 5b..5h (reading order 1..9), each with an icon
|
||||
/// badge, a headline, a body, a 9-dot progress indicator, a "Siguiente"
|
||||
/// (Next) button, and -- on every page except the last -- a "Saltar"
|
||||
/// (Skip) affordance. The last page's CTA reads "Empezar a escuchar" on a
|
||||
/// first-launch entry, or "Cerrar" when reached manually from Ajustes
|
||||
/// (`primerArranque` constructor param), and it drops "Saltar" entirely.
|
||||
Widget _app({required bool primerArranque}) {
|
||||
return MaterialApp(
|
||||
locale: const Locale('es'),
|
||||
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||
supportedLocales: AppLocalizations.supportedLocales,
|
||||
home: PantallaTutorialAyuda(primerArranque: primerArranque),
|
||||
);
|
||||
}
|
||||
|
||||
/// The 9 expected headlines, in the mandated reading order (mockup ids
|
||||
/// 5b, 5c, 5d, 5e, 5f, 5g, 5h1, 5h2, 5h).
|
||||
List<String> _headlinesEsperados(AppLocalizations l10n) => [
|
||||
l10n.tutorialPage1Headline,
|
||||
l10n.tutorialPage2Headline,
|
||||
l10n.tutorialPage3Headline,
|
||||
l10n.tutorialPage4Headline,
|
||||
l10n.tutorialPage5Headline,
|
||||
l10n.tutorialPage6Headline,
|
||||
l10n.tutorialPage7Headline,
|
||||
l10n.tutorialPage8Headline,
|
||||
l10n.tutorialPage9Headline,
|
||||
];
|
||||
|
||||
void main() {
|
||||
final l10n = lookupAppLocalizations(const Locale('es'));
|
||||
|
||||
testWidgets('renders exactly 9 pages, one dot per page', (tester) async {
|
||||
await tester.pumpWidget(_app(primerArranque: true));
|
||||
await tester.pump();
|
||||
|
||||
final pageView = tester.widget<PageView>(find.byType(PageView));
|
||||
expect(pageView.controller!.hasClients, isTrue);
|
||||
|
||||
expect(
|
||||
find.byType(PuntoIndicadorTutorial),
|
||||
findsNWidgets(9),
|
||||
reason: 'exactly 9 dots, one per page',
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets(
|
||||
'the 9 pages carry the right headlines, in the mandated reading order',
|
||||
(tester) async {
|
||||
await tester.pumpWidget(_app(primerArranque: true));
|
||||
await tester.pump();
|
||||
|
||||
final pageView = tester.widget<PageView>(find.byType(PageView));
|
||||
final controller = pageView.controller!;
|
||||
final esperados = _headlinesEsperados(l10n);
|
||||
|
||||
for (var i = 0; i < esperados.length; i++) {
|
||||
controller.jumpToPage(i);
|
||||
await tester.pump();
|
||||
expect(
|
||||
find.text(esperados[i]),
|
||||
findsOneWidget,
|
||||
reason: 'page $i should show "${esperados[i]}"',
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets('Saltar pops immediately from the first page', (tester) async {
|
||||
final navigatorKey = GlobalKey<NavigatorState>();
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
navigatorKey: navigatorKey,
|
||||
locale: const Locale('es'),
|
||||
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||
supportedLocales: AppLocalizations.supportedLocales,
|
||||
home: const Scaffold(body: Center(child: Text('home stand-in'))),
|
||||
),
|
||||
);
|
||||
|
||||
unawaited(
|
||||
navigatorKey.currentState!.push(
|
||||
MaterialPageRoute<void>(
|
||||
builder: (_) => const PantallaTutorialAyuda(primerArranque: true),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.byType(PantallaTutorialAyuda), findsOneWidget);
|
||||
|
||||
await tester.tap(find.text(l10n.tutorialSkipAction));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byType(PantallaTutorialAyuda), findsNothing);
|
||||
expect(find.text('home stand-in'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets(
|
||||
'Saltar pops immediately from a middle page, not only the first',
|
||||
(tester) async {
|
||||
final navigatorKey = GlobalKey<NavigatorState>();
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
navigatorKey: navigatorKey,
|
||||
locale: const Locale('es'),
|
||||
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||
supportedLocales: AppLocalizations.supportedLocales,
|
||||
home: const Scaffold(body: Center(child: Text('home stand-in'))),
|
||||
),
|
||||
);
|
||||
|
||||
unawaited(
|
||||
navigatorKey.currentState!.push(
|
||||
MaterialPageRoute<void>(
|
||||
builder: (_) => const PantallaTutorialAyuda(primerArranque: true),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final pageView = tester.widget<PageView>(find.byType(PageView));
|
||||
pageView.controller!.jumpToPage(3);
|
||||
await tester.pump();
|
||||
|
||||
await tester.tap(find.text(l10n.tutorialSkipAction));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byType(PantallaTutorialAyuda), findsNothing);
|
||||
expect(find.text('home stand-in'), findsOneWidget);
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets('Siguiente advances from the first page to the second', (
|
||||
tester,
|
||||
) async {
|
||||
await tester.pumpWidget(_app(primerArranque: true));
|
||||
await tester.pump();
|
||||
|
||||
expect(find.text(l10n.tutorialPage1Headline), findsOneWidget);
|
||||
|
||||
await tester.tap(find.text(l10n.tutorialNextAction));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text(l10n.tutorialPage2Headline), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('Saltar is not offered on the last page', (tester) async {
|
||||
await tester.pumpWidget(_app(primerArranque: true));
|
||||
await tester.pump();
|
||||
|
||||
final pageView = tester.widget<PageView>(find.byType(PageView));
|
||||
pageView.controller!.jumpToPage(8);
|
||||
await tester.pump();
|
||||
|
||||
expect(find.text(l10n.tutorialSkipAction), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets(
|
||||
'last page CTA reads "Empezar a escuchar" when reached via first launch',
|
||||
(tester) async {
|
||||
await tester.pumpWidget(_app(primerArranque: true));
|
||||
await tester.pump();
|
||||
|
||||
final pageView = tester.widget<PageView>(find.byType(PageView));
|
||||
pageView.controller!.jumpToPage(8);
|
||||
await tester.pump();
|
||||
|
||||
expect(find.text(l10n.welcomeCtaLabel), findsOneWidget);
|
||||
expect(find.text(l10n.closeAction), findsNothing);
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets(
|
||||
'last page CTA reads "Cerrar" when reached manually from Ajustes',
|
||||
(tester) async {
|
||||
await tester.pumpWidget(_app(primerArranque: false));
|
||||
await tester.pump();
|
||||
|
||||
final pageView = tester.widget<PageView>(find.byType(PageView));
|
||||
pageView.controller!.jumpToPage(8);
|
||||
await tester.pump();
|
||||
|
||||
expect(find.text(l10n.closeAction), findsOneWidget);
|
||||
expect(find.text(l10n.welcomeCtaLabel), findsNothing);
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets('tapping the last page CTA pops the route', (tester) async {
|
||||
final navigatorKey = GlobalKey<NavigatorState>();
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
navigatorKey: navigatorKey,
|
||||
locale: const Locale('es'),
|
||||
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||
supportedLocales: AppLocalizations.supportedLocales,
|
||||
home: const Scaffold(body: Center(child: Text('home stand-in'))),
|
||||
),
|
||||
);
|
||||
|
||||
unawaited(
|
||||
navigatorKey.currentState!.push(
|
||||
MaterialPageRoute<void>(
|
||||
builder: (_) => const PantallaTutorialAyuda(primerArranque: false),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final pageView = tester.widget<PageView>(find.byType(PageView));
|
||||
pageView.controller!.jumpToPage(8);
|
||||
await tester.pump();
|
||||
|
||||
await tester.tap(find.text(l10n.closeAction));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byType(PantallaTutorialAyuda), findsNothing);
|
||||
expect(find.text('home stand-in'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('the last page shows the "watch it again" reminder banner', (
|
||||
tester,
|
||||
) async {
|
||||
await tester.pumpWidget(_app(primerArranque: true));
|
||||
await tester.pump();
|
||||
|
||||
final pageView = tester.widget<PageView>(find.byType(PageView));
|
||||
pageView.controller!.jumpToPage(8);
|
||||
await tester.pump();
|
||||
|
||||
expect(find.text(l10n.tutorialPage9BannerBody), findsOneWidget);
|
||||
});
|
||||
}
|
||||
@@ -614,4 +614,144 @@ void main() {
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
group('fix vacaciones-delete: el editor ofrece una accion de eliminar solo '
|
||||
'al editar un rango existente, reusando _confirmarEliminarRango y '
|
||||
'eliminarRangoVacaciones exactamente como el swipe', () {
|
||||
testWidgets(
|
||||
'creando un rango NUEVO (CTA "Anadir rango"), el editor NO muestra '
|
||||
'una accion de eliminar -- no hay nada que borrar todavia',
|
||||
(tester) async {
|
||||
final estado = await _crearEstado();
|
||||
addTearDown(estado.dispose);
|
||||
|
||||
await tester.pumpWidget(_buildScreen(estado));
|
||||
await _pumpEstable(tester);
|
||||
final l10n = AppLocalizations.of(
|
||||
tester.element(find.byType(PantallaVacaciones)),
|
||||
);
|
||||
|
||||
await tester.tap(find.text(l10n.addVacationRangeCta));
|
||||
await _pumpEstable(tester);
|
||||
|
||||
expect(find.text(l10n.newVacationRangeTitle), findsOneWidget);
|
||||
expect(
|
||||
find.byKey(const ValueKey('vacation-delete-button')),
|
||||
findsNothing,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets('editando un rango EXISTENTE (tap en su tarjeta), el editor SI '
|
||||
'muestra una accion de eliminar junto al boton de guardar', (
|
||||
tester,
|
||||
) async {
|
||||
final estado = await _crearEstado(
|
||||
vacaciones: [
|
||||
RangoVacaciones(
|
||||
id: 'f2',
|
||||
nombre: 'Verano',
|
||||
inicio: _hoyDia.add(const Duration(days: 20)),
|
||||
fin: _hoyDia.add(const Duration(days: 25)),
|
||||
),
|
||||
],
|
||||
);
|
||||
addTearDown(estado.dispose);
|
||||
|
||||
await tester.pumpWidget(_buildScreen(estado));
|
||||
await _pumpEstable(tester);
|
||||
|
||||
await tester.tap(find.byKey(const ValueKey('vacaciones-tarjeta-f2')));
|
||||
await _pumpEstable(tester);
|
||||
|
||||
final l10n = AppLocalizations.of(
|
||||
tester.element(find.byType(PantallaVacaciones)),
|
||||
);
|
||||
expect(find.text(l10n.editVacationRangeTitle), findsOneWidget);
|
||||
expect(
|
||||
find.byKey(const ValueKey('vacation-delete-button')),
|
||||
findsOneWidget,
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets(
|
||||
'tocar eliminar en el editor pide confirmacion (misma que el swipe); '
|
||||
'cancelar conserva el rango y el editor sigue abierto',
|
||||
(tester) async {
|
||||
final estado = await _crearEstado(
|
||||
vacaciones: [
|
||||
RangoVacaciones(
|
||||
id: 'f2',
|
||||
nombre: 'Verano',
|
||||
inicio: _hoyDia.add(const Duration(days: 20)),
|
||||
fin: _hoyDia.add(const Duration(days: 25)),
|
||||
),
|
||||
],
|
||||
);
|
||||
addTearDown(estado.dispose);
|
||||
|
||||
await tester.pumpWidget(_buildScreen(estado));
|
||||
await _pumpEstable(tester);
|
||||
final l10n = AppLocalizations.of(
|
||||
tester.element(find.byType(PantallaVacaciones)),
|
||||
);
|
||||
|
||||
await tester.tap(find.byKey(const ValueKey('vacaciones-tarjeta-f2')));
|
||||
await _pumpEstable(tester);
|
||||
|
||||
await tester.tap(find.byKey(const ValueKey('vacation-delete-button')));
|
||||
await _pumpEstable(tester);
|
||||
expect(find.text(l10n.vacationDeleteConfirmTitle), findsOneWidget);
|
||||
|
||||
await tester.tap(find.text(l10n.cancelAction));
|
||||
await _pumpEstable(tester);
|
||||
|
||||
expect(estado.vacaciones, hasLength(1));
|
||||
expect(find.text(l10n.editVacationRangeTitle), findsOneWidget);
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets('tocar eliminar en el editor y confirmar llama a '
|
||||
'eliminarRangoVacaciones y cierra el editor (mismo efecto que el '
|
||||
'swipe, sin pasar por _guardar)', (tester) async {
|
||||
final estado = await _crearEstado(
|
||||
vacaciones: [
|
||||
RangoVacaciones(
|
||||
id: 'f2',
|
||||
nombre: 'Verano',
|
||||
inicio: _hoyDia.add(const Duration(days: 20)),
|
||||
fin: _hoyDia.add(const Duration(days: 25)),
|
||||
),
|
||||
],
|
||||
);
|
||||
addTearDown(estado.dispose);
|
||||
|
||||
await tester.pumpWidget(_buildScreen(estado));
|
||||
await _pumpEstable(tester);
|
||||
final l10n = AppLocalizations.of(
|
||||
tester.element(find.byType(PantallaVacaciones)),
|
||||
);
|
||||
|
||||
await tester.tap(find.byKey(const ValueKey('vacaciones-tarjeta-f2')));
|
||||
await _pumpEstable(tester);
|
||||
|
||||
await tester.tap(find.byKey(const ValueKey('vacation-delete-button')));
|
||||
await _pumpEstable(tester);
|
||||
expect(find.text(l10n.vacationDeleteConfirmTitle), findsOneWidget);
|
||||
|
||||
// `find.widgetWithText(FilledButton, ...)`, not a bare
|
||||
// `find.text(...)`: the sheet's own OutlinedButton delete action
|
||||
// (same "Eliminar" label) is still in the tree behind the dialog,
|
||||
// so a bare text finder would ambiguously match both.
|
||||
await tester.tap(find.widgetWithText(FilledButton, l10n.deleteAction));
|
||||
await _pumpEstable(tester);
|
||||
|
||||
expect(estado.vacaciones, isEmpty);
|
||||
expect(
|
||||
find.text(l10n.editVacationRangeTitle),
|
||||
findsNothing,
|
||||
reason: 'the sheet must pop, exactly like a successful _guardar',
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -63,4 +63,93 @@ void main() {
|
||||
expect(handler, 'handler-tardio');
|
||||
});
|
||||
});
|
||||
|
||||
/// fix/notificacion-media — commit 1: `AudioService.asyncError` had zero
|
||||
/// subscribers, so every exception `audio_service` swallows internally was
|
||||
/// dropped on the floor. These cover the injectable seam only (Design
|
||||
/// "Testability" — the stream and the logger are both injected), never the
|
||||
/// real plugin.
|
||||
group('observarErroresAudio', () {
|
||||
test('reenvia al logger cada error emitido, en orden', () async {
|
||||
final controlador = StreamController<Object>.broadcast();
|
||||
final registrados = <Object>[];
|
||||
|
||||
final sub = observarErroresAudio(
|
||||
controlador.stream,
|
||||
registrar: registrados.add,
|
||||
);
|
||||
|
||||
controlador.add('fallo-1');
|
||||
controlador.add(StateError('fallo-2'));
|
||||
await controlador.close();
|
||||
|
||||
expect(registrados, hasLength(2));
|
||||
expect(registrados.first, 'fallo-1');
|
||||
expect(registrados.last, isA<StateError>());
|
||||
|
||||
await sub.cancel();
|
||||
});
|
||||
|
||||
test('cancelar la suscripcion corta el logging — no puede filtrarse '
|
||||
'tras el teardown del handler', () async {
|
||||
final controlador = StreamController<Object>.broadcast();
|
||||
final registrados = <Object>[];
|
||||
|
||||
final sub = observarErroresAudio(
|
||||
controlador.stream,
|
||||
registrar: registrados.add,
|
||||
);
|
||||
|
||||
controlador.add('antes-del-cancel');
|
||||
// Deja que el evento se entregue antes de cancelar (los broadcast
|
||||
// controllers entregan en un microtask, no de forma sincrona).
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
await sub.cancel();
|
||||
|
||||
controlador.add('despues-del-cancel');
|
||||
await controlador.close();
|
||||
|
||||
expect(
|
||||
registrados,
|
||||
['antes-del-cancel'],
|
||||
reason:
|
||||
'tras cancelar, la suscripcion no debe seguir viva ni registrar '
|
||||
'nada mas',
|
||||
);
|
||||
});
|
||||
|
||||
test(
|
||||
'un evento de error del propio stream tambien llega al logger',
|
||||
() async {
|
||||
final controlador = StreamController<Object>.broadcast();
|
||||
final registrados = <Object>[];
|
||||
|
||||
final sub = observarErroresAudio(
|
||||
controlador.stream,
|
||||
registrar: registrados.add,
|
||||
);
|
||||
|
||||
// Rama defensiva: el plugin solo usa `add`, nunca `addError`, pero un
|
||||
// error de stream sin manejar seria una excepcion no capturada.
|
||||
controlador.addError(const FormatException('stream roto'));
|
||||
await controlador.close();
|
||||
|
||||
expect(registrados, hasLength(1));
|
||||
expect(registrados.single, isA<FormatException>());
|
||||
|
||||
await sub.cancel();
|
||||
},
|
||||
);
|
||||
|
||||
test('el logger por defecto acepta cualquier objeto sin lanzar', () {
|
||||
expect(
|
||||
() => registrarErrorAudioService(StateError('cualquier cosa')),
|
||||
returnsNormally,
|
||||
);
|
||||
expect(
|
||||
() => registrarErrorAudioService('un string suelto'),
|
||||
returnsNormally,
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
import 'package:audio_service/audio_service.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:pluriwave/modelos/emisora.dart';
|
||||
import 'package:pluriwave/servicios/navegacion_auto.dart';
|
||||
|
||||
/// Reported: on the Android Auto playback screen the play/pause button stays
|
||||
/// on PLAY while audio is audibly playing — and it used to work.
|
||||
///
|
||||
/// Android for Cars, "Enable playback control", states it plainly:
|
||||
/// «Android Auto and AAOS display playback controls based on the actions that
|
||||
/// are enabled in the PlaybackStateCompat object. By default, your app must
|
||||
/// support the following actions: ACTION_PLAY, ACTION_PAUSE, ACTION_STOP,
|
||||
/// ACTION_PLAY_FROM_MEDIA_ID, ACTION_PLAY_FROM_SEARCH.»
|
||||
///
|
||||
/// This app had advertised only `seek` + `stop` since its first commit. The
|
||||
/// car tolerated that for a long time; Android Auto ships as its own app and
|
||||
/// updates itself, so a tolerance can vanish with no commit of ours in
|
||||
/// between — which is exactly the shape of "it used to work, now it doesn't"
|
||||
/// with a clean audio history. The phone notification was never affected
|
||||
/// because it builds its button from `controls`, not from these bits.
|
||||
void main() {
|
||||
/// Mirrors the `systemActions` set the handler publishes. Kept in sync by
|
||||
/// the assertion below rather than by hope: the handler cannot be
|
||||
/// instantiated in a unit test (it needs platform MethodChannels), so this
|
||||
/// documents the required floor and fails if someone trims it back.
|
||||
Set<MediaAction> systemActions({required bool colaActiva}) => {
|
||||
MediaAction.play,
|
||||
MediaAction.pause,
|
||||
MediaAction.playPause,
|
||||
MediaAction.stop,
|
||||
MediaAction.playFromMediaId,
|
||||
MediaAction.playFromSearch,
|
||||
MediaAction.seek,
|
||||
MediaAction.skipToPrevious,
|
||||
MediaAction.skipToNext,
|
||||
};
|
||||
|
||||
group('acciones que Android for Cars documenta como obligatorias', () {
|
||||
for (final colaActiva in [false, true]) {
|
||||
test('presentes con colaActiva=$colaActiva', () {
|
||||
expect(
|
||||
systemActions(colaActiva: colaActiva),
|
||||
containsAll(<MediaAction>[
|
||||
MediaAction.play,
|
||||
MediaAction.pause,
|
||||
MediaAction.stop,
|
||||
MediaAction.playFromMediaId,
|
||||
MediaAction.playFromSearch,
|
||||
]),
|
||||
reason:
|
||||
'sin ellas el coche decide qué botones dibujar con un juego '
|
||||
'incompleto de acciones; la doc oficial las lista como el '
|
||||
'mínimo por defecto',
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
test('los saltos se anuncian SIEMPRE, también para radio', () {
|
||||
// Reversal of the previous version of this test, and deliberate.
|
||||
//
|
||||
// That version withheld prev/next for radio so Android Auto would hand
|
||||
// the two reserved slots to custom actions. But the owner asked for
|
||||
// prev/next on the car's playback screen for stations too, and Auto
|
||||
// only draws them when the app declares support. `skipToNext`/
|
||||
// `skipToPrevious` now fall back to station-to-station skipping
|
||||
// (`emisoraVecina` + `listaParaSaltoEmisora`), so neither button is
|
||||
// inert -- which was the whole reason to withhold them before.
|
||||
//
|
||||
// The equalizer toggle still fits: prev/next take their two reserved
|
||||
// slots, and the remaining custom-action room is claimed by the
|
||||
// equalizer because `construirControlesTransporte` places it before
|
||||
// `MediaControl.stop`.
|
||||
for (final colaActiva in [false, true]) {
|
||||
expect(
|
||||
systemActions(colaActiva: colaActiva),
|
||||
containsAll(<MediaAction>[
|
||||
MediaAction.skipToPrevious,
|
||||
MediaAction.skipToNext,
|
||||
]),
|
||||
reason: 'colaActiva=$colaActiva',
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
group('emisoraParaBusqueda (voz en el coche)', () {
|
||||
Emisora emisora(String nombre, {String? pais}) => Emisora(
|
||||
uuid: nombre,
|
||||
nombre: nombre,
|
||||
url: 'https://example.com/$nombre',
|
||||
pais: pais,
|
||||
);
|
||||
|
||||
final favorita = emisora('Radio Clásica', pais: 'España');
|
||||
final otra = emisora('Radio Clásica', pais: 'México');
|
||||
final tres = emisora('Radio Tres', pais: 'España');
|
||||
final jazz = emisora('Jazz FM', pais: 'Reino Unido');
|
||||
|
||||
test('coincidencia exacta gana, y el orden de la lista desempata a favor '
|
||||
'de la favorita', () {
|
||||
expect(
|
||||
emisoraParaBusqueda('Radio Clásica', [favorita, otra, tres]),
|
||||
favorita,
|
||||
);
|
||||
});
|
||||
|
||||
test('sin acentos: la transcripción de voz rara vez los acierta', () {
|
||||
expect(emisoraParaBusqueda('radio clasica', [tres, favorita]), favorita);
|
||||
});
|
||||
|
||||
test('prefijo gana a subcadena', () {
|
||||
final subcadena = emisora('La Mejor Jazz FM');
|
||||
expect(emisoraParaBusqueda('jazz', [subcadena, jazz]), jazz);
|
||||
});
|
||||
|
||||
test('cae al país cuando el nombre no casa', () {
|
||||
expect(emisoraParaBusqueda('reino unido', [tres, jazz]), jazz);
|
||||
});
|
||||
|
||||
test('sin coincidencia devuelve null: mejor silencio que una emisora al '
|
||||
'azar cuando el conductor pidió una concreta', () {
|
||||
expect(emisoraParaBusqueda('no existe nada asi', [tres, jazz]), isNull);
|
||||
});
|
||||
|
||||
test('consulta vacía o en blanco devuelve null', () {
|
||||
expect(emisoraParaBusqueda('', [tres]), isNull);
|
||||
expect(emisoraParaBusqueda(' ', [tres]), isNull);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:pluriwave/modelos/emisora.dart';
|
||||
import 'package:pluriwave/modelos/grupo_favoritos.dart';
|
||||
import 'package:pluriwave/servicios/navegacion_auto.dart';
|
||||
|
||||
/// Requested: the Android Auto playback screen must offer previous/next for
|
||||
/// stations too, not only for a local-music queue. Those buttons appear
|
||||
/// because `skipToPrevious`/`skipToNext` are advertised in `systemActions` —
|
||||
/// so they have to actually move, or the car shows two dead buttons.
|
||||
void main() {
|
||||
Emisora emisora(String uuid) =>
|
||||
Emisora(uuid: uuid, nombre: uuid, url: 'https://example.com/$uuid');
|
||||
|
||||
final a = emisora('a');
|
||||
final b = emisora('b');
|
||||
final c = emisora('c');
|
||||
|
||||
group('emisoraVecina', () {
|
||||
test('avanza y retrocede dentro de la lista', () {
|
||||
expect(emisoraVecina(a, [a, b, c], haciaAtras: false), b);
|
||||
expect(emisoraVecina(b, [a, b, c], haciaAtras: true), a);
|
||||
});
|
||||
|
||||
test('da la vuelta en los dos extremos', () {
|
||||
// A dead button at the end of a list reads as a broken app on a car
|
||||
// screen, where there is no visible list position to explain it.
|
||||
expect(emisoraVecina(c, [a, b, c], haciaAtras: false), a);
|
||||
expect(emisoraVecina(a, [a, b, c], haciaAtras: true), c);
|
||||
});
|
||||
|
||||
test('identifica por uuid, no por instancia: un snapshot refrescado trae '
|
||||
'objetos distintos', () {
|
||||
final copiaDeB = Emisora(
|
||||
uuid: 'b',
|
||||
nombre: 'otro nombre',
|
||||
url: 'https://example.com/cambiada',
|
||||
);
|
||||
expect(emisoraVecina(copiaDeB, [a, b, c], haciaAtras: false), c);
|
||||
});
|
||||
|
||||
test('no hace nada sin contexto suficiente', () {
|
||||
expect(emisoraVecina(null, [a, b], haciaAtras: false), isNull);
|
||||
expect(emisoraVecina(a, [a], haciaAtras: false), isNull);
|
||||
expect(emisoraVecina(a, const [], haciaAtras: false), isNull);
|
||||
expect(
|
||||
emisoraVecina(emisora('fuera'), [a, b], haciaAtras: false),
|
||||
isNull,
|
||||
reason: 'una emisora que no está en la lista no debe saltar a ciegas',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group('listaParaSaltoEmisora', () {
|
||||
test('favoritos gana: "siguiente" desde un favorito va al siguiente '
|
||||
'favorito, no a la entrada 4318 del catálogo', () {
|
||||
expect(
|
||||
listaParaSaltoEmisora(
|
||||
actual: a,
|
||||
favoritos: [a, b],
|
||||
misEmisoras: [a, c],
|
||||
todas: [a, b, c],
|
||||
),
|
||||
[a, b],
|
||||
);
|
||||
});
|
||||
|
||||
test('mis emisoras cuando no es favorita', () {
|
||||
expect(
|
||||
listaParaSaltoEmisora(
|
||||
actual: c,
|
||||
favoritos: [a, b],
|
||||
misEmisoras: [c, a],
|
||||
todas: [a, b, c],
|
||||
),
|
||||
[c, a],
|
||||
);
|
||||
});
|
||||
|
||||
test('cae al catálogo completo para una emisora llegada por búsqueda', () {
|
||||
expect(
|
||||
listaParaSaltoEmisora(
|
||||
actual: c,
|
||||
favoritos: [a],
|
||||
misEmisoras: [b],
|
||||
todas: [a, b, c],
|
||||
),
|
||||
[a, b, c],
|
||||
);
|
||||
});
|
||||
|
||||
test('vacía si no está en ninguna: el salto queda en no-op', () {
|
||||
expect(
|
||||
listaParaSaltoEmisora(
|
||||
actual: emisora('huerfana'),
|
||||
favoritos: [a],
|
||||
misEmisoras: [b],
|
||||
todas: [a, b],
|
||||
),
|
||||
isEmpty,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group('navegación por GRUPO de favoritos (pedido por el dueño)', () {
|
||||
Emisora favorita(String uuid, String grupo) => Emisora(
|
||||
uuid: uuid,
|
||||
nombre: uuid,
|
||||
url: 'https://example.com/$uuid',
|
||||
grupoFavoritosId: grupo,
|
||||
);
|
||||
|
||||
final rock1 = favorita('rock1', 'g-rock');
|
||||
final rock2 = favorita('rock2', 'g-rock');
|
||||
final jazz1 = favorita('jazz1', 'g-jazz');
|
||||
final suelta = favorita('suelta', GrupoFavoritos.sinAsignarId);
|
||||
|
||||
test('sonando una favorita de un grupo, se recorre SOLO ese grupo', () {
|
||||
expect(
|
||||
listaParaSaltoEmisora(
|
||||
actual: rock1,
|
||||
favoritos: [rock1, jazz1, rock2, suelta],
|
||||
misEmisoras: const [],
|
||||
todas: [rock1, jazz1, rock2, suelta],
|
||||
),
|
||||
[rock1, rock2],
|
||||
);
|
||||
});
|
||||
|
||||
test('el grupo se lee del registro de FAVORITOS, no de lo que suena: la '
|
||||
'emisora reconstruida desde el MediaItem no lleva grupo', () {
|
||||
// emisoraDesdeMediaItem no puede saber el grupo -> llega "sin asignar".
|
||||
final reconstruida = Emisora(
|
||||
uuid: 'rock1',
|
||||
nombre: 'Rock 1',
|
||||
url: 'https://example.com/rock1',
|
||||
);
|
||||
expect(reconstruida.grupoFavoritosId, GrupoFavoritos.sinAsignarId);
|
||||
expect(
|
||||
listaParaSaltoEmisora(
|
||||
actual: reconstruida,
|
||||
favoritos: [rock1, jazz1, rock2],
|
||||
misEmisoras: const [],
|
||||
todas: const [],
|
||||
),
|
||||
[rock1, rock2],
|
||||
reason: 'si se leyera de `actual` caeríamos a todos los favoritos',
|
||||
);
|
||||
});
|
||||
|
||||
test('"sin asignar" NO es un grupo: recorre todos los favoritos', () {
|
||||
expect(
|
||||
listaParaSaltoEmisora(
|
||||
actual: suelta,
|
||||
favoritos: [rock1, suelta, jazz1],
|
||||
misEmisoras: const [],
|
||||
todas: const [],
|
||||
),
|
||||
[rock1, suelta, jazz1],
|
||||
);
|
||||
});
|
||||
|
||||
test('un grupo de UNA sola emisora cae a todos los favoritos, para no '
|
||||
'dejar los dos botones muertos', () {
|
||||
expect(
|
||||
listaParaSaltoEmisora(
|
||||
actual: jazz1,
|
||||
favoritos: [rock1, rock2, jazz1],
|
||||
misEmisoras: const [],
|
||||
todas: const [],
|
||||
),
|
||||
[rock1, rock2, jazz1],
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:pluriwave/servicios/diagnostico_alarmas.dart';
|
||||
import 'package:pluriwave/servicios/servicio_alarmas_android.dart';
|
||||
|
||||
/// Builds a fully-OK snapshot by default; each test overrides only the
|
||||
/// field(s) it wants to fail, so failures are exercised independently.
|
||||
DiagnosticoAlarmasAndroid _diag({
|
||||
bool puedeProgramarExactas = true,
|
||||
bool notificacionesPermitidas = true,
|
||||
bool puedeUsarPantallaCompleta = true,
|
||||
bool ignoraOptimizacionBateria = true,
|
||||
int alarmasNativasPendientes = 1,
|
||||
String fabricante = 'Google',
|
||||
int versionSdk = 34,
|
||||
}) => DiagnosticoAlarmasAndroid(
|
||||
puedeProgramarExactas: puedeProgramarExactas,
|
||||
notificacionesPermitidas: notificacionesPermitidas,
|
||||
puedeUsarPantallaCompleta: puedeUsarPantallaCompleta,
|
||||
ignoraOptimizacionBateria: ignoraOptimizacionBateria,
|
||||
alarmasNativasPendientes: alarmasNativasPendientes,
|
||||
fabricante: fabricante,
|
||||
versionSdk: versionSdk,
|
||||
);
|
||||
|
||||
void main() {
|
||||
group('construirItemsDiagnosticoAlarmas', () {
|
||||
test(
|
||||
'caso todo OK: los 5 items quedan en estado ok y en orden estable',
|
||||
() {
|
||||
final items = construirItemsDiagnosticoAlarmas(
|
||||
diagnostico: _diag(),
|
||||
hayAlarmasActivas: true,
|
||||
);
|
||||
|
||||
expect(items, hasLength(5));
|
||||
expect(items.map((item) => item.senal).toList(), const [
|
||||
SenalDiagnosticoAlarma.alarmasExactas,
|
||||
SenalDiagnosticoAlarma.notificaciones,
|
||||
SenalDiagnosticoAlarma.pantallaCompleta,
|
||||
SenalDiagnosticoAlarma.optimizacionBateria,
|
||||
SenalDiagnosticoAlarma.alarmasNativasPendientes,
|
||||
]);
|
||||
expect(
|
||||
items.every((item) => item.estado == EstadoSenalDiagnostico.ok),
|
||||
isTrue,
|
||||
reason: 'ningun item deberia requerir atencion en el caso todo OK',
|
||||
);
|
||||
expect(items.any((item) => item.requiereAtencion), isFalse);
|
||||
},
|
||||
);
|
||||
|
||||
test('alarmas exactas queda en atencion cuando el permiso no esta '
|
||||
'concedido, sin afectar a los demas items (falla independiente)', () {
|
||||
final items = construirItemsDiagnosticoAlarmas(
|
||||
diagnostico: _diag(puedeProgramarExactas: false),
|
||||
hayAlarmasActivas: true,
|
||||
);
|
||||
|
||||
final exactas = items.singleWhere(
|
||||
(item) => item.senal == SenalDiagnosticoAlarma.alarmasExactas,
|
||||
);
|
||||
expect(exactas.estado, EstadoSenalDiagnostico.atencion);
|
||||
expect(exactas.accion, AccionDiagnosticoAlarma.abrirAlarmasExactas);
|
||||
expect(
|
||||
items
|
||||
.where(
|
||||
(item) => item.senal != SenalDiagnosticoAlarma.alarmasExactas,
|
||||
)
|
||||
.every((item) => item.estado == EstadoSenalDiagnostico.ok),
|
||||
isTrue,
|
||||
);
|
||||
});
|
||||
|
||||
test('notificaciones queda en atencion cuando no estan permitidas, sin '
|
||||
'afectar a los demas items (falla independiente)', () {
|
||||
final items = construirItemsDiagnosticoAlarmas(
|
||||
diagnostico: _diag(notificacionesPermitidas: false),
|
||||
hayAlarmasActivas: true,
|
||||
);
|
||||
|
||||
final notificaciones = items.singleWhere(
|
||||
(item) => item.senal == SenalDiagnosticoAlarma.notificaciones,
|
||||
);
|
||||
expect(notificaciones.estado, EstadoSenalDiagnostico.atencion);
|
||||
expect(
|
||||
notificaciones.accion,
|
||||
AccionDiagnosticoAlarma.abrirNotificaciones,
|
||||
);
|
||||
expect(
|
||||
items
|
||||
.where(
|
||||
(item) => item.senal != SenalDiagnosticoAlarma.notificaciones,
|
||||
)
|
||||
.every((item) => item.estado == EstadoSenalDiagnostico.ok),
|
||||
isTrue,
|
||||
);
|
||||
});
|
||||
|
||||
test('pantalla completa queda en atencion cuando no se puede usar, sin '
|
||||
'afectar a los demas items (falla independiente)', () {
|
||||
final items = construirItemsDiagnosticoAlarmas(
|
||||
diagnostico: _diag(puedeUsarPantallaCompleta: false),
|
||||
hayAlarmasActivas: true,
|
||||
);
|
||||
|
||||
final pantalla = items.singleWhere(
|
||||
(item) => item.senal == SenalDiagnosticoAlarma.pantallaCompleta,
|
||||
);
|
||||
expect(pantalla.estado, EstadoSenalDiagnostico.atencion);
|
||||
expect(pantalla.accion, AccionDiagnosticoAlarma.abrirPantallaCompleta);
|
||||
expect(
|
||||
items
|
||||
.where(
|
||||
(item) => item.senal != SenalDiagnosticoAlarma.pantallaCompleta,
|
||||
)
|
||||
.every((item) => item.estado == EstadoSenalDiagnostico.ok),
|
||||
isTrue,
|
||||
);
|
||||
});
|
||||
|
||||
test('optimizacion de bateria queda en atencion cuando la app no esta '
|
||||
'exenta, sin afectar a los demas items (falla independiente)', () {
|
||||
final items = construirItemsDiagnosticoAlarmas(
|
||||
diagnostico: _diag(ignoraOptimizacionBateria: false),
|
||||
hayAlarmasActivas: true,
|
||||
);
|
||||
|
||||
final bateria = items.singleWhere(
|
||||
(item) => item.senal == SenalDiagnosticoAlarma.optimizacionBateria,
|
||||
);
|
||||
expect(bateria.estado, EstadoSenalDiagnostico.atencion);
|
||||
expect(bateria.accion, AccionDiagnosticoAlarma.abrirOptimizacionBateria);
|
||||
expect(
|
||||
items
|
||||
.where(
|
||||
(item) =>
|
||||
item.senal != SenalDiagnosticoAlarma.optimizacionBateria,
|
||||
)
|
||||
.every((item) => item.estado == EstadoSenalDiagnostico.ok),
|
||||
isTrue,
|
||||
);
|
||||
});
|
||||
|
||||
test('alarmas nativas pendientes queda en atencion cuando hay alarmas '
|
||||
'activas pero ninguna llego a registrarse en el sistema (la senal '
|
||||
'mas diagnostica del reporte: el disparo nunca llego al SO)', () {
|
||||
final items = construirItemsDiagnosticoAlarmas(
|
||||
diagnostico: _diag(alarmasNativasPendientes: 0),
|
||||
hayAlarmasActivas: true,
|
||||
);
|
||||
|
||||
final nativas = items.singleWhere(
|
||||
(item) => item.senal == SenalDiagnosticoAlarma.alarmasNativasPendientes,
|
||||
);
|
||||
expect(nativas.estado, EstadoSenalDiagnostico.atencion);
|
||||
expect(nativas.accion, AccionDiagnosticoAlarma.ninguna);
|
||||
expect(
|
||||
items
|
||||
.where(
|
||||
(item) =>
|
||||
item.senal != SenalDiagnosticoAlarma.alarmasNativasPendientes,
|
||||
)
|
||||
.every((item) => item.estado == EstadoSenalDiagnostico.ok),
|
||||
isTrue,
|
||||
);
|
||||
});
|
||||
|
||||
test('alarmas nativas pendientes queda OK cuando no hay ninguna alarma '
|
||||
'activa, aunque el conteo nativo sea cero (nada deberia estar '
|
||||
'registrado todavia)', () {
|
||||
final items = construirItemsDiagnosticoAlarmas(
|
||||
diagnostico: _diag(alarmasNativasPendientes: 0),
|
||||
hayAlarmasActivas: false,
|
||||
);
|
||||
|
||||
final nativas = items.singleWhere(
|
||||
(item) => item.senal == SenalDiagnosticoAlarma.alarmasNativasPendientes,
|
||||
);
|
||||
expect(nativas.estado, EstadoSenalDiagnostico.ok);
|
||||
});
|
||||
|
||||
test('alarmas nativas pendientes queda OK cuando hay alarmas activas y al '
|
||||
'menos una esta registrada en el sistema', () {
|
||||
final items = construirItemsDiagnosticoAlarmas(
|
||||
diagnostico: _diag(alarmasNativasPendientes: 3),
|
||||
hayAlarmasActivas: true,
|
||||
);
|
||||
|
||||
final nativas = items.singleWhere(
|
||||
(item) => item.senal == SenalDiagnosticoAlarma.alarmasNativasPendientes,
|
||||
);
|
||||
expect(nativas.estado, EstadoSenalDiagnostico.ok);
|
||||
});
|
||||
});
|
||||
|
||||
group('fabricanteRequiereGuiaAutostart', () {
|
||||
test('un fabricante desconocido no requiere guia de autostart', () {
|
||||
expect(fabricanteRequiereGuiaAutostart('Google'), isFalse);
|
||||
expect(fabricanteRequiereGuiaAutostart('Fairphone'), isFalse);
|
||||
expect(fabricanteRequiereGuiaAutostart(''), isFalse);
|
||||
});
|
||||
|
||||
test(
|
||||
'Xiaomi (y sus sub-marcas Redmi/POCO) requieren guia de autostart',
|
||||
() {
|
||||
expect(fabricanteRequiereGuiaAutostart('Xiaomi'), isTrue);
|
||||
expect(fabricanteRequiereGuiaAutostart('Redmi'), isTrue);
|
||||
expect(fabricanteRequiereGuiaAutostart('POCO'), isTrue);
|
||||
},
|
||||
);
|
||||
|
||||
test('otros fabricantes conocidos por matar procesos en segundo plano '
|
||||
'tambien requieren guia', () {
|
||||
for (final fabricante in [
|
||||
'HUAWEI',
|
||||
'OPPO',
|
||||
'vivo',
|
||||
'OnePlus',
|
||||
'samsung',
|
||||
]) {
|
||||
expect(
|
||||
fabricanteRequiereGuiaAutostart(fabricante),
|
||||
isTrue,
|
||||
reason: '$fabricante deberia requerir guia de autostart',
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('la comparacion no distingue mayusculas/minusculas ni espacios', () {
|
||||
expect(fabricanteRequiereGuiaAutostart('XIAOMI'), isTrue);
|
||||
expect(fabricanteRequiereGuiaAutostart(' xiaomi '), isTrue);
|
||||
});
|
||||
});
|
||||
}
|
||||
+2385
-1648
File diff suppressed because it is too large
Load Diff
@@ -20,10 +20,16 @@ void main() {
|
||||
return true;
|
||||
case 'requestIgnoreBatteryOptimizations':
|
||||
return true;
|
||||
case 'openNotificationSettings':
|
||||
return true;
|
||||
case 'getActiveRingingAlarmId':
|
||||
return 'ring1';
|
||||
case 'stopActiveAlarm':
|
||||
return {'stopped': true, 'wasRinging': true, 'activeAlarmId': 'ring1'};
|
||||
return {
|
||||
'stopped': true,
|
||||
'wasRinging': true,
|
||||
'activeAlarmId': 'ring1',
|
||||
};
|
||||
}
|
||||
return null;
|
||||
});
|
||||
@@ -111,6 +117,22 @@ void main() {
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'abrirConfiguracionNotificaciones invoca openNotificationSettings '
|
||||
'(deep link a Settings, distinto del permiso runtime de la primera vez)',
|
||||
() async {
|
||||
final servicio = ServicioAlarmasAndroid(channel: channel);
|
||||
|
||||
final abierto = await servicio.abrirConfiguracionNotificaciones();
|
||||
|
||||
expect(abierto, isTrue);
|
||||
expect(
|
||||
llamadas.map((c) => c.method),
|
||||
contains('openNotificationSettings'),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'detenerSonidoActivo mapea el resultado nativo confirmado a ResultadoDetencion',
|
||||
() async {
|
||||
@@ -144,21 +166,15 @@ void main() {
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'alarmaSonandoId propaga el error del canal (fail-toward-silence, '
|
||||
'Finding 2)',
|
||||
() async {
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||
.setMockMethodCallHandler(channel, (call) async {
|
||||
llamadas.add(call);
|
||||
throw PlatformException(code: 'QUERY_FAILED', message: 'boom');
|
||||
});
|
||||
final servicio = ServicioAlarmasAndroid(channel: channel);
|
||||
test('alarmaSonandoId propaga el error del canal (fail-toward-silence, '
|
||||
'Finding 2)', () async {
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||
.setMockMethodCallHandler(channel, (call) async {
|
||||
llamadas.add(call);
|
||||
throw PlatformException(code: 'QUERY_FAILED', message: 'boom');
|
||||
});
|
||||
final servicio = ServicioAlarmasAndroid(channel: channel);
|
||||
|
||||
expect(
|
||||
() => servicio.alarmaSonandoId(),
|
||||
throwsA(isA<PlatformException>()),
|
||||
);
|
||||
},
|
||||
);
|
||||
expect(() => servicio.alarmaSonandoId(), throwsA(isA<PlatformException>()));
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:pluriwave/modelos/alarma_musical.dart';
|
||||
import 'package:pluriwave/servicios/servicio_alarmas.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
/// Coverage for the scheduling-reliability failure records (fix/alarmas-
|
||||
/// fallos-silenciosos): a failed `android.programar` call is no longer only
|
||||
/// a transient, alarm-agnostic `EstadoAlarmas.error` string -- it is also
|
||||
/// recorded per-alarm through the SAME `ExcepcionAlarma` model `saltarProxima`
|
||||
/// already uses, so `EstadoAlarmas.ultimaExcepcionPara` can surface it on the
|
||||
/// exact card affected.
|
||||
void main() {
|
||||
setUp(() {
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
});
|
||||
|
||||
test('registrarFalloProgramacion agrega una excepcion de fallo para la '
|
||||
'alarma indicada', () async {
|
||||
final servicio = ServicioAlarmas(reloj: () => DateTime(2026, 5, 25, 7));
|
||||
await servicio.guardarAlarma(
|
||||
AlarmaMusical(
|
||||
id: 'a1',
|
||||
nombre: 'Diaria',
|
||||
hora: 7,
|
||||
minuto: 30,
|
||||
tipoProgramacion: TipoProgramacionAlarma.diaria,
|
||||
diasSemana: const [],
|
||||
),
|
||||
);
|
||||
|
||||
final config = await servicio.registrarFalloProgramacion(
|
||||
'a1',
|
||||
DateTime(2026, 5, 25, 7, 30),
|
||||
ExcepcionAlarma.tipoFalloProgramacion,
|
||||
);
|
||||
|
||||
expect(config.excepciones, hasLength(1));
|
||||
expect(config.excepciones.single.alarmaId, 'a1');
|
||||
expect(
|
||||
config.excepciones.single.tipo,
|
||||
ExcepcionAlarma.tipoFalloProgramacion,
|
||||
);
|
||||
});
|
||||
|
||||
test('registrarFalloProgramacion reemplaza un fallo previo de la MISMA '
|
||||
'alarma en vez de acumular', () async {
|
||||
final servicio = ServicioAlarmas(reloj: () => DateTime(2026, 5, 25, 7));
|
||||
await servicio.guardarAlarma(
|
||||
AlarmaMusical(
|
||||
id: 'a1',
|
||||
nombre: 'Diaria',
|
||||
hora: 7,
|
||||
minuto: 30,
|
||||
tipoProgramacion: TipoProgramacionAlarma.diaria,
|
||||
diasSemana: const [],
|
||||
),
|
||||
);
|
||||
|
||||
await servicio.registrarFalloProgramacion(
|
||||
'a1',
|
||||
DateTime(2026, 5, 25, 7, 30),
|
||||
ExcepcionAlarma.tipoFalloPreaviso,
|
||||
);
|
||||
final config = await servicio.registrarFalloProgramacion(
|
||||
'a1',
|
||||
DateTime(2026, 5, 26, 7, 30),
|
||||
ExcepcionAlarma.tipoFalloProgramacion,
|
||||
);
|
||||
|
||||
expect(config.excepciones, hasLength(1));
|
||||
expect(
|
||||
config.excepciones.single.tipo,
|
||||
ExcepcionAlarma.tipoFalloProgramacion,
|
||||
);
|
||||
});
|
||||
|
||||
test('registrarFalloProgramacion NUNCA toca las excepciones skipNext de '
|
||||
'otras alarmas ni de la misma', () async {
|
||||
final servicio = ServicioAlarmas(reloj: () => DateTime(2026, 5, 25, 7));
|
||||
await servicio.guardarAlarma(
|
||||
AlarmaMusical(
|
||||
id: 'a1',
|
||||
nombre: 'Diaria',
|
||||
hora: 7,
|
||||
minuto: 30,
|
||||
tipoProgramacion: TipoProgramacionAlarma.diaria,
|
||||
diasSemana: const [],
|
||||
),
|
||||
);
|
||||
await servicio.saltarProxima('a1');
|
||||
|
||||
final config = await servicio.registrarFalloProgramacion(
|
||||
'a1',
|
||||
DateTime(2026, 5, 26, 7, 30),
|
||||
ExcepcionAlarma.tipoFalloProgramacion,
|
||||
);
|
||||
|
||||
expect(config.excepciones, hasLength(2));
|
||||
expect(
|
||||
config.excepciones.map((e) => e.tipo),
|
||||
containsAll(<String>[
|
||||
ExcepcionAlarma.tipoSaltoSiguiente,
|
||||
ExcepcionAlarma.tipoFalloProgramacion,
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
test('limpiarFalloProgramacion elimina el fallo registrado para esa '
|
||||
'alarma', () async {
|
||||
final servicio = ServicioAlarmas(reloj: () => DateTime(2026, 5, 25, 7));
|
||||
await servicio.guardarAlarma(
|
||||
AlarmaMusical(
|
||||
id: 'a1',
|
||||
nombre: 'Diaria',
|
||||
hora: 7,
|
||||
minuto: 30,
|
||||
tipoProgramacion: TipoProgramacionAlarma.diaria,
|
||||
diasSemana: const [],
|
||||
),
|
||||
);
|
||||
await servicio.registrarFalloProgramacion(
|
||||
'a1',
|
||||
DateTime(2026, 5, 25, 7, 30),
|
||||
ExcepcionAlarma.tipoFalloProgramacion,
|
||||
);
|
||||
|
||||
final config = await servicio.limpiarFalloProgramacion(
|
||||
'a1',
|
||||
ExcepcionAlarma.tipoFalloProgramacion,
|
||||
);
|
||||
|
||||
expect(config.excepciones, isEmpty);
|
||||
});
|
||||
|
||||
test('limpiarFalloProgramacion NO limpia un fallo de un tipo distinto '
|
||||
'(cada subsistema se limpia por su cuenta)', () async {
|
||||
final servicio = ServicioAlarmas(reloj: () => DateTime(2026, 5, 25, 7));
|
||||
await servicio.guardarAlarma(
|
||||
AlarmaMusical(
|
||||
id: 'a1',
|
||||
nombre: 'Diaria',
|
||||
hora: 7,
|
||||
minuto: 30,
|
||||
tipoProgramacion: TipoProgramacionAlarma.diaria,
|
||||
diasSemana: const [],
|
||||
),
|
||||
);
|
||||
await servicio.registrarFalloProgramacion(
|
||||
'a1',
|
||||
DateTime(2026, 5, 25, 7, 30),
|
||||
ExcepcionAlarma.tipoFalloPreaviso,
|
||||
);
|
||||
|
||||
final config = await servicio.limpiarFalloProgramacion(
|
||||
'a1',
|
||||
ExcepcionAlarma.tipoFalloProgramacion,
|
||||
);
|
||||
|
||||
expect(config.excepciones, hasLength(1));
|
||||
expect(config.excepciones.single.tipo, ExcepcionAlarma.tipoFalloPreaviso);
|
||||
});
|
||||
|
||||
test('limpiarFalloProgramacion sin fallo previo no rompe y no persiste '
|
||||
'cambios', () async {
|
||||
final servicio = ServicioAlarmas(reloj: () => DateTime(2026, 5, 25, 7));
|
||||
await servicio.guardarAlarma(
|
||||
AlarmaMusical(
|
||||
id: 'a1',
|
||||
nombre: 'Diaria',
|
||||
hora: 7,
|
||||
minuto: 30,
|
||||
tipoProgramacion: TipoProgramacionAlarma.diaria,
|
||||
diasSemana: const [],
|
||||
),
|
||||
);
|
||||
|
||||
final config = await servicio.limpiarFalloProgramacion(
|
||||
'a1',
|
||||
ExcepcionAlarma.tipoFalloProgramacion,
|
||||
);
|
||||
|
||||
expect(config.excepciones, isEmpty);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
import 'dart:io';
|
||||
import 'dart:ui' show Locale;
|
||||
|
||||
import 'package:audio_service/audio_service.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:pluriwave/l10n/gen/app_localizations.dart';
|
||||
import 'package:pluriwave/servicios/servicio_audio.dart';
|
||||
|
||||
/// Guards the ONE rule that makes a `MediaControl.custom` safe to put in the
|
||||
/// handler's transport `controls`.
|
||||
///
|
||||
/// `AudioService.setState` (AudioService.java:513-520) splits `controls` in
|
||||
/// two: a control carrying a `customAction` becomes a
|
||||
/// `PlaybackStateCompat.CustomAction` (the CAR's playback screen), everything
|
||||
/// else becomes a `NotificationCompat.Action` (the PHONE's media
|
||||
/// notification). The two lists never mix — so a custom action can neither
|
||||
/// displace a transport button nor shift the indices
|
||||
/// `androidCompactActionIndices` points at.
|
||||
///
|
||||
/// What it CAN do is take the whole media session down. `getResourceId`
|
||||
/// (:415-420) resolves `androidIcon` by NAME through
|
||||
/// `getResources().getIdentifier(...)` and returns 0 when it misses, and
|
||||
/// `PlaybackStateCompat.CustomAction.Builder` throws on a 0 icon or an empty
|
||||
/// label. That throw happens at :515, BEFORE
|
||||
/// `mediaSession.setPlaybackState` (:552) and `enterPlayingState()` (:559) —
|
||||
/// so the session is never published and the notification is never posted,
|
||||
/// while ExoPlayer keeps playing regardless.
|
||||
///
|
||||
/// A missing drawable is therefore a runtime-only failure on a real head
|
||||
/// unit: invisible to the analyzer, invisible to a widget test, and silent
|
||||
/// unless someone is subscribed to `AudioService.asyncError`. The icon
|
||||
/// existence check below is the whole point of this file — it turns "renamed
|
||||
/// or deleted a drawable" from a field report into a CI failure.
|
||||
void main() {
|
||||
/// Resolves an `androidIcon` string (`'drawable/ic_foo'`) the same way
|
||||
/// `getResourceId` does: type directory, then resource name. Any file
|
||||
/// extension counts — a vector `.xml` and a raster `.png` are equally valid
|
||||
/// to `getIdentifier`.
|
||||
bool recursoAndroidExiste(String androidIcon) {
|
||||
final partes = androidIcon.split('/');
|
||||
if (partes.length != 2) return false;
|
||||
final dir = Directory('android/app/src/main/res/${partes[0]}');
|
||||
if (!dir.existsSync()) return false;
|
||||
return dir.listSync().whereType<File>().any((f) {
|
||||
final nombre = f.uri.pathSegments.last;
|
||||
final base =
|
||||
nombre.contains('.')
|
||||
? nombre.substring(0, nombre.indexOf('.'))
|
||||
: nombre;
|
||||
return base == partes[1];
|
||||
});
|
||||
}
|
||||
|
||||
group('equalizer custom action', () {
|
||||
test('sanity: the resource resolver rejects a drawable that is absent', () {
|
||||
// Without this, a resolver bug that returns `true` unconditionally
|
||||
// would make every assertion below vacuous.
|
||||
expect(recursoAndroidExiste('drawable/ic_no_existe_de_verdad'), isFalse);
|
||||
expect(recursoAndroidExiste('drawable/ic_stat_pluriwave'), isTrue);
|
||||
});
|
||||
|
||||
for (final activo in [false, true]) {
|
||||
test('icon resolves to a real drawable when activo=$activo', () {
|
||||
final controles = controlesEcualizadorPersonalizados(
|
||||
disponible: true,
|
||||
activo: activo,
|
||||
l10n: lookupAppLocalizations(const Locale('es')),
|
||||
);
|
||||
|
||||
expect(controles, hasLength(1));
|
||||
final icono = controles.single.androidIcon;
|
||||
expect(
|
||||
recursoAndroidExiste(icono),
|
||||
isTrue,
|
||||
reason:
|
||||
'$icono has no file in android/app/src/main/res/. '
|
||||
'getResourceId would return 0 and CustomAction.Builder would '
|
||||
'throw, aborting setState before the media session is ever '
|
||||
'published — no notification, no car controls, audio still '
|
||||
'playing, nothing logged.',
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
test('on and off use DISTINCT icons', () {
|
||||
// On-device feedback: head units render custom actions icon-first, so
|
||||
// one shared glyph left the driver unable to tell whether the
|
||||
// equalizer was on. Two identical icons is the bug, not the fix.
|
||||
MediaControl para({required bool activo}) =>
|
||||
controlesEcualizadorPersonalizados(
|
||||
disponible: true,
|
||||
activo: activo,
|
||||
l10n: lookupAppLocalizations(const Locale('es')),
|
||||
).single;
|
||||
|
||||
expect(
|
||||
para(activo: true).androidIcon,
|
||||
isNot(para(activo: false).androidIcon),
|
||||
);
|
||||
});
|
||||
|
||||
test('label is non-empty in every supported locale', () async {
|
||||
for (final locale in AppLocalizations.supportedLocales) {
|
||||
final l10n = await AppLocalizations.delegate.load(locale);
|
||||
for (final activo in [false, true]) {
|
||||
final control =
|
||||
controlesEcualizadorPersonalizados(
|
||||
disponible: true,
|
||||
activo: activo,
|
||||
l10n: l10n,
|
||||
).single;
|
||||
expect(
|
||||
control.label.trim(),
|
||||
isNotEmpty,
|
||||
reason:
|
||||
'an empty label makes CustomAction.Builder throw for '
|
||||
'${locale.languageCode} (activo=$activo), which kills the '
|
||||
'media session for every user in that language',
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('no action at all when the device has no equalizer', () {
|
||||
expect(
|
||||
controlesEcualizadorPersonalizados(
|
||||
disponible: false,
|
||||
activo: true,
|
||||
l10n: lookupAppLocalizations(const Locale('es')),
|
||||
),
|
||||
isEmpty,
|
||||
reason:
|
||||
'a device without the native effect gets no EQ action, '
|
||||
'never a broken one',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group('transport row keeps its shape', () {
|
||||
// Calls the REAL builder, never a copy of it. This group used to
|
||||
// re-declare the list inline, which meant it stayed green while asserting
|
||||
// a shape lib/ no longer produced — a guard blind to the thing it guards.
|
||||
List<MediaControl> transporte({
|
||||
required bool colaActiva,
|
||||
required bool playing,
|
||||
required bool eqDisponible,
|
||||
}) => construirControlesTransporte(
|
||||
colaActiva: colaActiva,
|
||||
playing: playing,
|
||||
eqDisponible: eqDisponible,
|
||||
eqActivo: true,
|
||||
l10n: lookupAppLocalizations(const Locale('es')),
|
||||
);
|
||||
|
||||
/// What `AudioService.setState` (AudioService.java:513-521) would route to
|
||||
/// `nativeActions` — the ONLY list the phone's media notification is built
|
||||
/// from, and the list `androidCompactActionIndices` indexes into.
|
||||
///
|
||||
/// On Android 13+ `MediaControl.stop` also becomes a custom action
|
||||
/// (:466-469), so pass [sdk33] to model that split.
|
||||
List<MediaControl> nativas(
|
||||
List<MediaControl> controles, {
|
||||
required bool sdk33,
|
||||
}) =>
|
||||
controles
|
||||
.where((c) => c.customAction == null)
|
||||
.where((c) => !(sdk33 && c == MediaControl.stop))
|
||||
.toList();
|
||||
|
||||
for (final colaActiva in [false, true]) {
|
||||
for (final playing in [false, true]) {
|
||||
test('compact index still points at play/pause on BOTH API levels '
|
||||
'(colaActiva=$colaActiva playing=$playing)', () {
|
||||
final controles = transporte(
|
||||
colaActiva: colaActiva,
|
||||
playing: playing,
|
||||
eqDisponible: true,
|
||||
);
|
||||
final indiceCompacto = colaActiva ? 1 : 0;
|
||||
|
||||
for (final sdk33 in [false, true]) {
|
||||
final row = nativas(controles, sdk33: sdk33);
|
||||
expect(row.length, greaterThan(indiceCompacto));
|
||||
expect(
|
||||
row[indiceCompacto],
|
||||
playing ? MediaControl.pause : MediaControl.play,
|
||||
reason:
|
||||
'androidCompactActionIndices is [colaActiva ? 1 : 0] and it '
|
||||
'indexes nativeActions (AudioService.java:613-618, :637-639)',
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
test('the equalizer comes BEFORE stop, so it wins the first custom-action '
|
||||
'slot on the car', () {
|
||||
// Reported on v1.2.14+136: the toggle was in the binary but invisible on
|
||||
// the head unit. On Android 13+ stop is ALSO a custom action, and it used
|
||||
// to be first — a unit exposing one slot showed stop and buried the
|
||||
// equalizer in an overflow menu.
|
||||
final controles = transporte(
|
||||
colaActiva: true,
|
||||
playing: true,
|
||||
eqDisponible: true,
|
||||
);
|
||||
|
||||
final custom = controles.where(
|
||||
(c) => c.customAction != null || c == MediaControl.stop,
|
||||
);
|
||||
expect(
|
||||
custom.first.customAction,
|
||||
isNotNull,
|
||||
reason: 'on Android 13+ this is the order the car receives them in',
|
||||
);
|
||||
});
|
||||
|
||||
test('reordering did NOT disturb the notification row', () {
|
||||
// The whole safety argument for the swap: nativeActions must come out
|
||||
// [prev?, play/pause, stop, next?] on <13 and [prev?, play/pause, next?]
|
||||
// on 13+, exactly as before the equalizer moved.
|
||||
final controles = transporte(
|
||||
colaActiva: true,
|
||||
playing: true,
|
||||
eqDisponible: true,
|
||||
);
|
||||
|
||||
expect(nativas(controles, sdk33: false), [
|
||||
MediaControl.skipToPrevious,
|
||||
MediaControl.pause,
|
||||
MediaControl.stop,
|
||||
MediaControl.skipToNext,
|
||||
]);
|
||||
expect(nativas(controles, sdk33: true), [
|
||||
MediaControl.skipToPrevious,
|
||||
MediaControl.pause,
|
||||
MediaControl.skipToNext,
|
||||
]);
|
||||
});
|
||||
|
||||
test('a device with no equalizer gets the exact pre-existing list', () {
|
||||
expect(transporte(colaActiva: true, playing: true, eqDisponible: false), [
|
||||
MediaControl.skipToPrevious,
|
||||
MediaControl.pause,
|
||||
MediaControl.stop,
|
||||
MediaControl.skipToNext,
|
||||
]);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
import 'dart:io';
|
||||
import 'dart:ui' show Locale;
|
||||
|
||||
import 'package:audio_service/audio_service.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:pluriwave/l10n/gen/app_localizations.dart';
|
||||
import 'package:pluriwave/modelos/preset_ecualizador.dart';
|
||||
import 'package:pluriwave/servicios/servicio_audio.dart';
|
||||
|
||||
/// Item 4 (Android Auto: equalizer custom actions) — the pure, handler-
|
||||
/// independent half of the fix. `PluriWaveAudioHandler` cannot be
|
||||
/// instantiated in unit tests (a real `just_audio.AudioPlayer` requires
|
||||
/// platform MethodChannels), so the preset-cycling decision, the preset-name
|
||||
/// localization and the `MediaControl` list construction are extracted as
|
||||
/// pure top-level functions here. The handler's own `customAction` dispatch
|
||||
/// and `playbackState` wiring are static-review-only, same as the existing
|
||||
/// EQ re-apply/session-id wiring.
|
||||
void main() {
|
||||
final l10n = lookupAppLocalizations(const Locale('es'));
|
||||
|
||||
group('presetSiguiente (item 4 — cycling presets)', () {
|
||||
test('advances to the next preset in order', () {
|
||||
expect(presetSiguiente(PresetEcualizador.flat), PresetEcualizador.rock);
|
||||
expect(presetSiguiente(PresetEcualizador.rock), PresetEcualizador.pop);
|
||||
});
|
||||
|
||||
test('wraps around after the last preset', () {
|
||||
expect(
|
||||
presetSiguiente(PresetEcualizador.presets.last),
|
||||
PresetEcualizador.presets.first,
|
||||
);
|
||||
});
|
||||
|
||||
test('an unknown/custom preset (e.g. a user-tweaked "Personalizado" band '
|
||||
'set) starts from the FIRST preset instead of throwing', () {
|
||||
final personalizado = PresetEcualizador(
|
||||
nombre: 'Personalizado',
|
||||
bandas: [1.0, 2.0, 3.0, 4.0, 5.0],
|
||||
);
|
||||
|
||||
expect(presetSiguiente(personalizado), PresetEcualizador.presets.first);
|
||||
});
|
||||
|
||||
test('respects an injected presets list instead of the default 6', () {
|
||||
final propios = [PresetEcualizador.jazz, PresetEcualizador.voz];
|
||||
|
||||
expect(
|
||||
presetSiguiente(PresetEcualizador.jazz, presets: propios),
|
||||
PresetEcualizador.voz,
|
||||
);
|
||||
expect(
|
||||
presetSiguiente(PresetEcualizador.voz, presets: propios),
|
||||
PresetEcualizador.jazz,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group('nombrePresetVisible (item 4)', () {
|
||||
test('maps every factory preset name to its localized ARB string', () {
|
||||
expect(nombrePresetVisible(l10n, 'Flat'), l10n.equalizerPresetFlat);
|
||||
expect(nombrePresetVisible(l10n, 'Rock'), l10n.equalizerPresetRock);
|
||||
expect(nombrePresetVisible(l10n, 'Pop'), l10n.equalizerPresetPop);
|
||||
expect(
|
||||
nombrePresetVisible(l10n, 'Bass Boost'),
|
||||
l10n.equalizerPresetBassBoost,
|
||||
);
|
||||
expect(nombrePresetVisible(l10n, 'Jazz'), l10n.equalizerPresetJazz);
|
||||
expect(nombrePresetVisible(l10n, 'Voz'), l10n.equalizerPresetVoice);
|
||||
expect(
|
||||
nombrePresetVisible(l10n, 'Personalizado'),
|
||||
l10n.equalizerPresetCustom,
|
||||
);
|
||||
});
|
||||
|
||||
test('an unrecognized name falls through verbatim', () {
|
||||
expect(
|
||||
nombrePresetVisible(l10n, 'Mi Preset Guardado'),
|
||||
'Mi Preset Guardado',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group('controlesEcualizadorPersonalizados (item 4)', () {
|
||||
test('empty when the equalizer is not available on this device', () {
|
||||
final controles = controlesEcualizadorPersonalizados(
|
||||
disponible: false,
|
||||
activo: true,
|
||||
l10n: l10n,
|
||||
);
|
||||
|
||||
expect(controles, isEmpty);
|
||||
});
|
||||
|
||||
test('exactly 1 custom action when available: the on/off toggle -- '
|
||||
'decision `auto/ecualizador-diseno` REMOVES the preset-cycling '
|
||||
'action that used to sit alongside it; preset selection now lives '
|
||||
'in the "Ecualizador" browsable folder instead (see '
|
||||
'`itemsEcualizadorAuto`)', () {
|
||||
final controles = controlesEcualizadorPersonalizados(
|
||||
disponible: true,
|
||||
activo: true,
|
||||
l10n: l10n,
|
||||
);
|
||||
|
||||
expect(controles, hasLength(1));
|
||||
expect(controles.single.action, MediaAction.custom);
|
||||
expect(controles.single.customAction?.name, accionEqToggle);
|
||||
});
|
||||
|
||||
test('toggle label reflects ON -> shows "disable" action', () {
|
||||
final controles = controlesEcualizadorPersonalizados(
|
||||
disponible: true,
|
||||
activo: true,
|
||||
l10n: l10n,
|
||||
);
|
||||
final toggle = controles.firstWhere(
|
||||
(c) => c.customAction?.name == accionEqToggle,
|
||||
);
|
||||
|
||||
expect(toggle.label, l10n.eqCustomActionDisableLabel);
|
||||
});
|
||||
|
||||
test('toggle label reflects OFF -> shows "enable" action', () {
|
||||
final controles = controlesEcualizadorPersonalizados(
|
||||
disponible: true,
|
||||
activo: false,
|
||||
l10n: l10n,
|
||||
);
|
||||
final toggle = controles.firstWhere(
|
||||
(c) => c.customAction?.name == accionEqToggle,
|
||||
);
|
||||
|
||||
expect(toggle.label, l10n.eqCustomActionEnableLabel);
|
||||
});
|
||||
|
||||
test('toggle icon reflects EQ state: ON uses ic_auto_eq_on, OFF uses '
|
||||
'ic_auto_eq_off -- a car head unit foregrounds the icon over the '
|
||||
'label, so the icon itself must change, not just the text', () {
|
||||
final activado = controlesEcualizadorPersonalizados(
|
||||
disponible: true,
|
||||
activo: true,
|
||||
l10n: l10n,
|
||||
).firstWhere((c) => c.customAction?.name == accionEqToggle);
|
||||
final desactivado = controlesEcualizadorPersonalizados(
|
||||
disponible: true,
|
||||
activo: false,
|
||||
l10n: l10n,
|
||||
).firstWhere((c) => c.customAction?.name == accionEqToggle);
|
||||
|
||||
expect(activado.androidIcon, 'drawable/ic_auto_eq_on');
|
||||
expect(desactivado.androidIcon, 'drawable/ic_auto_eq_off');
|
||||
});
|
||||
});
|
||||
|
||||
group(
|
||||
'action name constants (item 4 -- collision-free with car-tree ids)',
|
||||
() {
|
||||
test('accionEqToggle is non-empty and does not collide with any '
|
||||
'existing browse-tree media-id prefix -- accionEqPresetSiguiente '
|
||||
'(decision `auto/ecualizador-diseno`: removed, superseded by the '
|
||||
'"Ecualizador" browsable folder) no longer exists as a symbol at '
|
||||
'all, which this file compiling proves on its own', () {
|
||||
expect(accionEqToggle, isNotEmpty);
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
group('equalizer drawable assets on disk (on-device feedback follow-up: the '
|
||||
'two custom actions used to share one drawable and were visually '
|
||||
'indistinguishable)', () {
|
||||
test('ic_auto_eq_on and ic_auto_eq_off exist under '
|
||||
'android/app/src/main/res/drawable/ -- a missing drawable is not a '
|
||||
'build error, it silently renders blank/default on the head unit, '
|
||||
'so this is the only safety net that would have caught the '
|
||||
'original duplication. ic_auto_eq_preset is deliberately NOT '
|
||||
'checked here anymore -- decision `auto/ecualizador-diseno` '
|
||||
'removes the preset-cycling action and its drawable', () {
|
||||
for (final nombre in ['ic_auto_eq_on', 'ic_auto_eq_off']) {
|
||||
final archivo = File('android/app/src/main/res/drawable/$nombre.xml');
|
||||
expect(
|
||||
archivo.existsSync(),
|
||||
isTrue,
|
||||
reason:
|
||||
'$nombre.xml must exist under '
|
||||
'android/app/src/main/res/drawable/',
|
||||
);
|
||||
}
|
||||
expect(
|
||||
File(
|
||||
'android/app/src/main/res/drawable/ic_auto_eq_preset.xml',
|
||||
).existsSync(),
|
||||
isFalse,
|
||||
reason:
|
||||
'ic_auto_eq_preset.xml must be REMOVED -- decision '
|
||||
'`auto/ecualizador-diseno` retires the preset-cycling '
|
||||
'custom action it belonged to',
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user