Compare commits
56
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
344af83e16 | ||
|
|
257f1bbc68 | ||
|
|
f4f9e87970 | ||
|
|
597701f497 | ||
|
|
4d54908be6 | ||
|
|
cfd8bc9e6a | ||
|
|
445e4518f7 | ||
|
|
d945e1a313 | ||
|
|
c507218462 | ||
|
|
f2528c930b | ||
|
|
a8dca83cd9 | ||
|
|
7722f204ca | ||
|
|
c107c0e18a | ||
|
|
fd1b91fe9e | ||
|
|
47d0b8a053 | ||
|
|
88bd251eba | ||
|
|
cd7f73056e | ||
|
|
3f80291e78 | ||
|
|
88818cf88c | ||
|
|
f19666508d | ||
|
|
8423ccdd0c | ||
|
|
049ab78acb | ||
|
|
ef9705a30e | ||
|
|
25405564ee | ||
|
|
dd463cf2bb | ||
|
|
c8b2c4d2d6 | ||
|
|
eba4eba397 | ||
|
|
4168dc5019 | ||
|
|
491585ad12 | ||
|
|
9eff760462 | ||
|
|
1b0bea5492 | ||
|
|
6822432a51 | ||
|
|
eea8ec31e6 | ||
|
|
55636f7c74 | ||
|
|
4b89c9af07 | ||
|
|
f2b02c3ce2 | ||
|
|
db6f4a3a11 | ||
|
|
3bb92c0536 | ||
|
|
9a75027d57 | ||
|
|
93b7ec2af9 | ||
|
|
5bbf750b63 | ||
|
|
7faf56900f | ||
|
|
ec93e45310 | ||
|
|
fdb7eb1d51 | ||
|
|
431f13063d | ||
|
|
fc866d7ec9 | ||
|
|
727e18737a | ||
|
|
c6ab295c54 | ||
|
|
e75f010b98 | ||
|
|
c7e1a212ca | ||
|
|
d1a911e587 | ||
|
|
4be2156e58 | ||
|
|
b365035e10 | ||
|
|
f24be19e4f | ||
|
|
7343071fca | ||
|
|
34388d364b |
@@ -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(
|
||||
@@ -846,8 +860,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 +883,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 +1305,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,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>
|
||||
@@ -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));
|
||||
}
|
||||
@@ -267,10 +354,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();
|
||||
}
|
||||
@@ -298,10 +387,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();
|
||||
}
|
||||
@@ -354,6 +445,19 @@ class EstadoAlarmas extends ChangeNotifier {
|
||||
await guardarVacaciones(nuevos);
|
||||
}
|
||||
|
||||
/// Issue 1 (feedback-pruebas): replaces the range with the same [id] in
|
||||
/// place -- the counterpart `crearRangoVacaciones`/`eliminarRangoVacaciones`
|
||||
/// were missing before this fix, leaving no way to fix a mistake in an
|
||||
/// already-saved range (including the currently ACTIVE one, since a
|
||||
/// freshly created range starts active immediately).
|
||||
Future<void> editarRangoVacaciones(RangoVacaciones rango) async {
|
||||
final nuevos = [
|
||||
for (final actual in _vacaciones)
|
||||
if (actual.id == rango.id) rango else actual,
|
||||
];
|
||||
await guardarVacaciones(nuevos);
|
||||
}
|
||||
|
||||
// ── Vacation queries (design ADR-6, WU9) ──────────────────────────────
|
||||
// Four PURE queries: none writes, none reschedules, none touches the
|
||||
// native bridge. Read-only over `_alarmas`/`_vacaciones`. Every method
|
||||
@@ -434,6 +538,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.
|
||||
@@ -533,6 +667,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
|
||||
@@ -609,8 +797,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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -173,6 +173,10 @@ class EstadoRadio extends ChangeNotifier {
|
||||
static const _keyEmisoraPreferida = 'emisora_preferida_uuid_v1';
|
||||
static const _keyOrdenListas = 'orden_listas_emisoras_v1';
|
||||
static const _keyTimerSuenoPresets = 'timer_sueno_presets_segundos_v1';
|
||||
// Issue 4 (feedback-pruebas): last-played station, so the Escuchar hero
|
||||
// keeps showing "what I was listening to" (stopped, not playing) after a
|
||||
// full app restart instead of going empty.
|
||||
static const _keyUltimaEmisora = 'ultima_emisora_v1';
|
||||
static const _timerSuenoPresetsDefecto = <int>[
|
||||
180,
|
||||
300,
|
||||
@@ -300,6 +304,50 @@ class EstadoRadio extends ChangeNotifier {
|
||||
_cargarEmisorasCustom(),
|
||||
]);
|
||||
await _normalizarEmisoraPreferida();
|
||||
await _restaurarUltimaEmisora();
|
||||
}
|
||||
|
||||
/// Issue 4 (feedback-pruebas): restores the last-played station as a
|
||||
/// STOPPED `emisoraActual` on a cold start. Only fills the gap — if
|
||||
/// something is ALREADY selected (a real play already ran concurrently),
|
||||
/// this is a no-op. Never touches `audio`: no playback starts, no network
|
||||
/// request is made, `estadoStream`/`estaSonando` stay at their fresh
|
||||
/// "detenido" default, exactly like every other consumer of
|
||||
/// `emisoraActual` already expects (they gate "is it playing" on the
|
||||
/// separate playback-status stream, never on `emisoraActual != null`).
|
||||
Future<void> _restaurarUltimaEmisora() async {
|
||||
if (_emisoraSeleccionada != null || audio.emisoraActual != null) return;
|
||||
try {
|
||||
final prefs = await _resolverPrefs();
|
||||
final raw = prefs.getString(_keyUltimaEmisora);
|
||||
if (raw == null) return;
|
||||
final mapa = jsonDecode(raw) as Map<String, dynamic>;
|
||||
_emisoraSeleccionada = Emisora.fromMap(mapa);
|
||||
} catch (e) {
|
||||
registrarSaltoPersistencia(
|
||||
subsistema: 'ultima_emisora',
|
||||
detalle: 'restaurar',
|
||||
razon: e.toString(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Best-effort remembers [emisora] as the last used station (issue 4) so
|
||||
/// [_restaurarUltimaEmisora] can bring it back after a restart. Fire-and-
|
||||
/// forget, same treatment [reproducir] already gives other non-critical
|
||||
/// side effects (e.g. `radio.registrarClick`) — a failed write here must
|
||||
/// never block or fail actual playback.
|
||||
Future<void> _persistirUltimaEmisora(Emisora emisora) async {
|
||||
try {
|
||||
final prefs = await _resolverPrefs();
|
||||
await prefs.setString(_keyUltimaEmisora, jsonEncode(emisora.toMap()));
|
||||
} catch (e) {
|
||||
registrarSaltoPersistencia(
|
||||
subsistema: 'ultima_emisora',
|
||||
detalle: 'persistir ${emisora.uuid}',
|
||||
razon: e.toString(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Escucha el stream de estado del audio y gestiona errores de reproducción.
|
||||
@@ -321,6 +369,9 @@ class EstadoRadio extends ChangeNotifier {
|
||||
final actual = audio.emisoraActual;
|
||||
if (actual != null && actual.uuid != _emisoraSeleccionada?.uuid) {
|
||||
_emisoraSeleccionada = actual;
|
||||
// Issue 4: an Android-Auto-initiated selection is a real station
|
||||
// change too — remember it the same way `reproducir` does.
|
||||
unawaited(_persistirUltimaEmisora(actual));
|
||||
}
|
||||
notifyListeners();
|
||||
});
|
||||
@@ -343,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();
|
||||
}
|
||||
}
|
||||
@@ -351,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();
|
||||
}
|
||||
|
||||
@@ -469,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();
|
||||
}
|
||||
|
||||
@@ -508,6 +582,10 @@ class EstadoRadio extends ChangeNotifier {
|
||||
}
|
||||
_emisoraSeleccionada = emisora;
|
||||
notifyListeners();
|
||||
// Issue 4: remembers the station the user just picked so it survives a
|
||||
// restart — fire-and-forget, same treatment as `radio.registrarClick`
|
||||
// below (a persistence failure here must never block playback).
|
||||
unawaited(_persistirUltimaEmisora(emisora));
|
||||
try {
|
||||
await audio.reproducir(emisora);
|
||||
if (revision != _revisionReproduccion) return;
|
||||
@@ -604,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;
|
||||
}
|
||||
@@ -630,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();
|
||||
}
|
||||
|
||||
@@ -646,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;
|
||||
}
|
||||
@@ -659,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;
|
||||
}
|
||||
|
||||
+51
-1
@@ -468,6 +468,9 @@
|
||||
"deleteRangeTooltip": "حذف النطاق",
|
||||
"vacationsDefaultName": "إجازات",
|
||||
"newVacationRangeTitle": "نطاق إجازة جديد",
|
||||
"editVacationRangeTitle": "تعديل نطاق الإجازة",
|
||||
"vacationDeleteConfirmTitle": "هل تريد حذف نطاق الإجازة؟",
|
||||
"vacationDeleteConfirmMessage": "لا يمكن التراجع عن هذا الإجراء.",
|
||||
"startField": "البداية",
|
||||
"endField": "النهاية",
|
||||
"saveRangeAction": "حفظ النطاق",
|
||||
@@ -827,5 +830,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": "تعطيل"
|
||||
}
|
||||
|
||||
+51
-1
@@ -468,6 +468,9 @@
|
||||
"deleteRangeTooltip": "পরিসর মুছুন",
|
||||
"vacationsDefaultName": "ছুটি",
|
||||
"newVacationRangeTitle": "নতুন ছুটির পরিসর",
|
||||
"editVacationRangeTitle": "ছুটির পরিসর সম্পাদনা করুন",
|
||||
"vacationDeleteConfirmTitle": "ছুটির পরিসর মুছবেন?",
|
||||
"vacationDeleteConfirmMessage": "এই পদক্ষেপ ফিরিয়ে নেওয়া যাবে না।",
|
||||
"startField": "শুরু",
|
||||
"endField": "শেষ",
|
||||
"saveRangeAction": "পরিসর সংরক্ষণ করুন",
|
||||
@@ -827,5 +830,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": "বন্ধ করুন"
|
||||
}
|
||||
|
||||
+51
-1
@@ -468,6 +468,9 @@
|
||||
"deleteRangeTooltip": "Zeitraum löschen",
|
||||
"vacationsDefaultName": "Ferien",
|
||||
"newVacationRangeTitle": "Neuer Ferienzeitraum",
|
||||
"editVacationRangeTitle": "Ferienzeitraum bearbeiten",
|
||||
"vacationDeleteConfirmTitle": "Ferienzeitraum löschen?",
|
||||
"vacationDeleteConfirmMessage": "Dies kann nicht rückgängig gemacht werden.",
|
||||
"startField": "Beginn",
|
||||
"endField": "Ende",
|
||||
"saveRangeAction": "Zeitraum speichern",
|
||||
@@ -827,5 +830,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"
|
||||
}
|
||||
|
||||
+51
-1
@@ -580,6 +580,9 @@
|
||||
"vacationNoActiveRangeHint": "No active vacation range right now.",
|
||||
"vacationsDefaultName": "Vacation",
|
||||
"newVacationRangeTitle": "New vacation range",
|
||||
"editVacationRangeTitle": "Edit vacation range",
|
||||
"vacationDeleteConfirmTitle": "Delete vacation range?",
|
||||
"vacationDeleteConfirmMessage": "This can't be undone.",
|
||||
"startField": "Start",
|
||||
"endField": "End",
|
||||
"saveRangeAction": "Save range",
|
||||
@@ -827,5 +830,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"
|
||||
}
|
||||
|
||||
+51
-1
@@ -580,6 +580,9 @@
|
||||
"vacationNoActiveRangeHint": "No hay un rango de vacaciones activo ahora mismo.",
|
||||
"vacationsDefaultName": "Vacaciones",
|
||||
"newVacationRangeTitle": "Nuevo rango de vacaciones",
|
||||
"editVacationRangeTitle": "Editar rango de vacaciones",
|
||||
"vacationDeleteConfirmTitle": "¿Eliminar rango de vacaciones?",
|
||||
"vacationDeleteConfirmMessage": "Esta acción no se puede deshacer.",
|
||||
"startField": "Inicio",
|
||||
"endField": "Fin",
|
||||
"saveRangeAction": "Guardar rango",
|
||||
@@ -786,5 +789,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"
|
||||
}
|
||||
|
||||
+51
-1
@@ -468,6 +468,9 @@
|
||||
"deleteRangeTooltip": "Supprimer la période",
|
||||
"vacationsDefaultName": "Vacances",
|
||||
"newVacationRangeTitle": "Nouvelle période de vacances",
|
||||
"editVacationRangeTitle": "Modifier la période de vacances",
|
||||
"vacationDeleteConfirmTitle": "Supprimer la période de vacances ?",
|
||||
"vacationDeleteConfirmMessage": "Cette action est irréversible.",
|
||||
"startField": "Début",
|
||||
"endField": "Fin",
|
||||
"saveRangeAction": "Enregistrer la période",
|
||||
@@ -827,5 +830,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"
|
||||
}
|
||||
|
||||
+51
-1
@@ -468,6 +468,9 @@
|
||||
"deleteRangeTooltip": "अवधि हटाएँ",
|
||||
"vacationsDefaultName": "छुट्टियाँ",
|
||||
"newVacationRangeTitle": "नई छुट्टी अवधि",
|
||||
"editVacationRangeTitle": "छुट्टी अवधि संपादित करें",
|
||||
"vacationDeleteConfirmTitle": "छुट्टी अवधि हटाएं?",
|
||||
"vacationDeleteConfirmMessage": "इसे वापस नहीं लिया जा सकता।",
|
||||
"startField": "शुरुआत",
|
||||
"endField": "समाप्ति",
|
||||
"saveRangeAction": "अवधि सहेजें",
|
||||
@@ -827,5 +830,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": "बंद करें"
|
||||
}
|
||||
|
||||
+51
-1
@@ -468,6 +468,9 @@
|
||||
"deleteRangeTooltip": "Hapus rentang",
|
||||
"vacationsDefaultName": "Liburan",
|
||||
"newVacationRangeTitle": "Rentang liburan baru",
|
||||
"editVacationRangeTitle": "Edit rentang liburan",
|
||||
"vacationDeleteConfirmTitle": "Hapus rentang liburan?",
|
||||
"vacationDeleteConfirmMessage": "Tindakan ini tidak dapat dibatalkan.",
|
||||
"startField": "Mulai",
|
||||
"endField": "Akhir",
|
||||
"saveRangeAction": "Simpan rentang",
|
||||
@@ -827,5 +830,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"
|
||||
}
|
||||
|
||||
+51
-1
@@ -468,6 +468,9 @@
|
||||
"deleteRangeTooltip": "Elimina periodo",
|
||||
"vacationsDefaultName": "Vacanze",
|
||||
"newVacationRangeTitle": "Nuovo periodo di vacanza",
|
||||
"editVacationRangeTitle": "Modifica periodo di vacanza",
|
||||
"vacationDeleteConfirmTitle": "Eliminare il periodo di vacanza?",
|
||||
"vacationDeleteConfirmMessage": "Questa azione non può essere annullata.",
|
||||
"startField": "Inizio",
|
||||
"endField": "Fine",
|
||||
"saveRangeAction": "Salva periodo",
|
||||
@@ -827,5 +830,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"
|
||||
}
|
||||
|
||||
+51
-1
@@ -468,6 +468,9 @@
|
||||
"deleteRangeTooltip": "期間を削除",
|
||||
"vacationsDefaultName": "休暇",
|
||||
"newVacationRangeTitle": "新しい休暇期間",
|
||||
"editVacationRangeTitle": "休暇期間を編集",
|
||||
"vacationDeleteConfirmTitle": "休暇期間を削除しますか?",
|
||||
"vacationDeleteConfirmMessage": "この操作は元に戻せません。",
|
||||
"startField": "開始",
|
||||
"endField": "終了",
|
||||
"saveRangeAction": "期間を保存",
|
||||
@@ -827,5 +830,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": "無効化"
|
||||
}
|
||||
|
||||
+51
-1
@@ -468,6 +468,9 @@
|
||||
"deleteRangeTooltip": "Excluir período",
|
||||
"vacationsDefaultName": "Férias",
|
||||
"newVacationRangeTitle": "Novo período de férias",
|
||||
"editVacationRangeTitle": "Editar período de férias",
|
||||
"vacationDeleteConfirmTitle": "Excluir período de férias?",
|
||||
"vacationDeleteConfirmMessage": "Esta ação não pode ser desfeita.",
|
||||
"startField": "Início",
|
||||
"endField": "Fim",
|
||||
"saveRangeAction": "Salvar período",
|
||||
@@ -827,5 +830,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"
|
||||
}
|
||||
|
||||
+51
-1
@@ -468,6 +468,9 @@
|
||||
"deleteRangeTooltip": "Удалить период",
|
||||
"vacationsDefaultName": "Отпуск",
|
||||
"newVacationRangeTitle": "Новый период отпуска",
|
||||
"editVacationRangeTitle": "Изменить период отпуска",
|
||||
"vacationDeleteConfirmTitle": "Удалить период отпуска?",
|
||||
"vacationDeleteConfirmMessage": "Это действие нельзя отменить.",
|
||||
"startField": "Начало",
|
||||
"endField": "Конец",
|
||||
"saveRangeAction": "Сохранить период",
|
||||
@@ -827,5 +830,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": "Отключить"
|
||||
}
|
||||
|
||||
+51
-1
@@ -468,6 +468,9 @@
|
||||
"deleteRangeTooltip": "删除范围",
|
||||
"vacationsDefaultName": "假期",
|
||||
"newVacationRangeTitle": "新的假期范围",
|
||||
"editVacationRangeTitle": "编辑假期范围",
|
||||
"vacationDeleteConfirmTitle": "删除假期范围?",
|
||||
"vacationDeleteConfirmMessage": "此操作无法撤销。",
|
||||
"startField": "开始",
|
||||
"endField": "结束",
|
||||
"saveRangeAction": "保存范围",
|
||||
@@ -827,5 +830,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": "关闭"
|
||||
}
|
||||
|
||||
@@ -2078,6 +2078,24 @@ abstract class AppLocalizations {
|
||||
/// **'Nuevo rango de vacaciones'**
|
||||
String get newVacationRangeTitle;
|
||||
|
||||
/// No description provided for @editVacationRangeTitle.
|
||||
///
|
||||
/// In es, this message translates to:
|
||||
/// **'Editar rango de vacaciones'**
|
||||
String get editVacationRangeTitle;
|
||||
|
||||
/// No description provided for @vacationDeleteConfirmTitle.
|
||||
///
|
||||
/// In es, this message translates to:
|
||||
/// **'¿Eliminar rango de vacaciones?'**
|
||||
String get vacationDeleteConfirmTitle;
|
||||
|
||||
/// No description provided for @vacationDeleteConfirmMessage.
|
||||
///
|
||||
/// In es, this message translates to:
|
||||
/// **'Esta acción no se puede deshacer.'**
|
||||
String get vacationDeleteConfirmMessage;
|
||||
|
||||
/// No description provided for @startField.
|
||||
///
|
||||
/// In es, this message translates to:
|
||||
@@ -3031,6 +3049,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
|
||||
|
||||
@@ -1133,6 +1133,15 @@ class AppLocalizationsAr extends AppLocalizations {
|
||||
@override
|
||||
String get newVacationRangeTitle => 'نطاق إجازة جديد';
|
||||
|
||||
@override
|
||||
String get editVacationRangeTitle => 'تعديل نطاق الإجازة';
|
||||
|
||||
@override
|
||||
String get vacationDeleteConfirmTitle => 'هل تريد حذف نطاق الإجازة؟';
|
||||
|
||||
@override
|
||||
String get vacationDeleteConfirmMessage => 'لا يمكن التراجع عن هذا الإجراء.';
|
||||
|
||||
@override
|
||||
String get startField => 'البداية';
|
||||
|
||||
@@ -1667,4 +1676,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 => 'تعطيل';
|
||||
}
|
||||
|
||||
@@ -1140,6 +1140,16 @@ class AppLocalizationsBn extends AppLocalizations {
|
||||
@override
|
||||
String get newVacationRangeTitle => 'নতুন ছুটির পরিসর';
|
||||
|
||||
@override
|
||||
String get editVacationRangeTitle => 'ছুটির পরিসর সম্পাদনা করুন';
|
||||
|
||||
@override
|
||||
String get vacationDeleteConfirmTitle => 'ছুটির পরিসর মুছবেন?';
|
||||
|
||||
@override
|
||||
String get vacationDeleteConfirmMessage =>
|
||||
'এই পদক্ষেপ ফিরিয়ে নেওয়া যাবে না।';
|
||||
|
||||
@override
|
||||
String get startField => 'শুরু';
|
||||
|
||||
@@ -1675,4 +1685,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 => 'বন্ধ করুন';
|
||||
}
|
||||
|
||||
@@ -1142,6 +1142,16 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
@override
|
||||
String get newVacationRangeTitle => 'Neuer Ferienzeitraum';
|
||||
|
||||
@override
|
||||
String get editVacationRangeTitle => 'Ferienzeitraum bearbeiten';
|
||||
|
||||
@override
|
||||
String get vacationDeleteConfirmTitle => 'Ferienzeitraum löschen?';
|
||||
|
||||
@override
|
||||
String get vacationDeleteConfirmMessage =>
|
||||
'Dies kann nicht rückgängig gemacht werden.';
|
||||
|
||||
@override
|
||||
String get startField => 'Beginn';
|
||||
|
||||
@@ -1688,4 +1698,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';
|
||||
}
|
||||
|
||||
@@ -1133,6 +1133,15 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
@override
|
||||
String get newVacationRangeTitle => 'New vacation range';
|
||||
|
||||
@override
|
||||
String get editVacationRangeTitle => 'Edit vacation range';
|
||||
|
||||
@override
|
||||
String get vacationDeleteConfirmTitle => 'Delete vacation range?';
|
||||
|
||||
@override
|
||||
String get vacationDeleteConfirmMessage => 'This can\'t be undone.';
|
||||
|
||||
@override
|
||||
String get startField => 'Start';
|
||||
|
||||
@@ -1669,4 +1678,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';
|
||||
}
|
||||
|
||||
@@ -1139,6 +1139,16 @@ class AppLocalizationsEs extends AppLocalizations {
|
||||
@override
|
||||
String get newVacationRangeTitle => 'Nuevo rango de vacaciones';
|
||||
|
||||
@override
|
||||
String get editVacationRangeTitle => 'Editar rango de vacaciones';
|
||||
|
||||
@override
|
||||
String get vacationDeleteConfirmTitle => '¿Eliminar rango de vacaciones?';
|
||||
|
||||
@override
|
||||
String get vacationDeleteConfirmMessage =>
|
||||
'Esta acción no se puede deshacer.';
|
||||
|
||||
@override
|
||||
String get startField => 'Inicio';
|
||||
|
||||
@@ -1682,4 +1692,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';
|
||||
}
|
||||
|
||||
@@ -1145,6 +1145,15 @@ class AppLocalizationsFr extends AppLocalizations {
|
||||
@override
|
||||
String get newVacationRangeTitle => 'Nouvelle période de vacances';
|
||||
|
||||
@override
|
||||
String get editVacationRangeTitle => 'Modifier la période de vacances';
|
||||
|
||||
@override
|
||||
String get vacationDeleteConfirmTitle => 'Supprimer la période de vacances ?';
|
||||
|
||||
@override
|
||||
String get vacationDeleteConfirmMessage => 'Cette action est irréversible.';
|
||||
|
||||
@override
|
||||
String get startField => 'Début';
|
||||
|
||||
@@ -1692,4 +1701,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';
|
||||
}
|
||||
|
||||
@@ -1134,6 +1134,15 @@ class AppLocalizationsHi extends AppLocalizations {
|
||||
@override
|
||||
String get newVacationRangeTitle => 'नई छुट्टी अवधि';
|
||||
|
||||
@override
|
||||
String get editVacationRangeTitle => 'छुट्टी अवधि संपादित करें';
|
||||
|
||||
@override
|
||||
String get vacationDeleteConfirmTitle => 'छुट्टी अवधि हटाएं?';
|
||||
|
||||
@override
|
||||
String get vacationDeleteConfirmMessage => 'इसे वापस नहीं लिया जा सकता।';
|
||||
|
||||
@override
|
||||
String get startField => 'शुरुआत';
|
||||
|
||||
@@ -1671,4 +1680,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 => 'बंद करें';
|
||||
}
|
||||
|
||||
@@ -1139,6 +1139,16 @@ class AppLocalizationsId extends AppLocalizations {
|
||||
@override
|
||||
String get newVacationRangeTitle => 'Rentang liburan baru';
|
||||
|
||||
@override
|
||||
String get editVacationRangeTitle => 'Edit rentang liburan';
|
||||
|
||||
@override
|
||||
String get vacationDeleteConfirmTitle => 'Hapus rentang liburan?';
|
||||
|
||||
@override
|
||||
String get vacationDeleteConfirmMessage =>
|
||||
'Tindakan ini tidak dapat dibatalkan.';
|
||||
|
||||
@override
|
||||
String get startField => 'Mulai';
|
||||
|
||||
@@ -1678,4 +1688,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';
|
||||
}
|
||||
|
||||
@@ -1144,6 +1144,16 @@ class AppLocalizationsIt extends AppLocalizations {
|
||||
@override
|
||||
String get newVacationRangeTitle => 'Nuovo periodo di vacanza';
|
||||
|
||||
@override
|
||||
String get editVacationRangeTitle => 'Modifica periodo di vacanza';
|
||||
|
||||
@override
|
||||
String get vacationDeleteConfirmTitle => 'Eliminare il periodo di vacanza?';
|
||||
|
||||
@override
|
||||
String get vacationDeleteConfirmMessage =>
|
||||
'Questa azione non può essere annullata.';
|
||||
|
||||
@override
|
||||
String get startField => 'Inizio';
|
||||
|
||||
@@ -1690,4 +1700,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';
|
||||
}
|
||||
|
||||
@@ -1101,6 +1101,15 @@ class AppLocalizationsJa extends AppLocalizations {
|
||||
@override
|
||||
String get newVacationRangeTitle => '新しい休暇期間';
|
||||
|
||||
@override
|
||||
String get editVacationRangeTitle => '休暇期間を編集';
|
||||
|
||||
@override
|
||||
String get vacationDeleteConfirmTitle => '休暇期間を削除しますか?';
|
||||
|
||||
@override
|
||||
String get vacationDeleteConfirmMessage => 'この操作は元に戻せません。';
|
||||
|
||||
@override
|
||||
String get startField => '開始';
|
||||
|
||||
@@ -1622,4 +1631,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 => '無効化';
|
||||
}
|
||||
|
||||
@@ -1138,6 +1138,15 @@ class AppLocalizationsPt extends AppLocalizations {
|
||||
@override
|
||||
String get newVacationRangeTitle => 'Novo período de férias';
|
||||
|
||||
@override
|
||||
String get editVacationRangeTitle => 'Editar período de férias';
|
||||
|
||||
@override
|
||||
String get vacationDeleteConfirmTitle => 'Excluir período de férias?';
|
||||
|
||||
@override
|
||||
String get vacationDeleteConfirmMessage => 'Esta ação não pode ser desfeita.';
|
||||
|
||||
@override
|
||||
String get startField => 'Início';
|
||||
|
||||
@@ -1679,4 +1688,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';
|
||||
}
|
||||
|
||||
@@ -1140,6 +1140,15 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
@override
|
||||
String get newVacationRangeTitle => 'Новый период отпуска';
|
||||
|
||||
@override
|
||||
String get editVacationRangeTitle => 'Изменить период отпуска';
|
||||
|
||||
@override
|
||||
String get vacationDeleteConfirmTitle => 'Удалить период отпуска?';
|
||||
|
||||
@override
|
||||
String get vacationDeleteConfirmMessage => 'Это действие нельзя отменить.';
|
||||
|
||||
@override
|
||||
String get startField => 'Начало';
|
||||
|
||||
@@ -1683,4 +1692,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 => 'Отключить';
|
||||
}
|
||||
|
||||
@@ -1097,6 +1097,15 @@ class AppLocalizationsZh extends AppLocalizations {
|
||||
@override
|
||||
String get newVacationRangeTitle => '新的假期范围';
|
||||
|
||||
@override
|
||||
String get editVacationRangeTitle => '编辑假期范围';
|
||||
|
||||
@override
|
||||
String get vacationDeleteConfirmTitle => '删除假期范围?';
|
||||
|
||||
@override
|
||||
String get vacationDeleteConfirmMessage => '此操作无法撤销。';
|
||||
|
||||
@override
|
||||
String get startField => '开始';
|
||||
|
||||
@@ -1614,4 +1623,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 => '关闭';
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -93,6 +93,14 @@ class FilaAjuste extends StatelessWidget {
|
||||
/// "no accent" — the icon renders exactly as before.
|
||||
final Color? iconColor;
|
||||
|
||||
/// Issue 5 (feedback-pruebas): caps how much width the trailing current
|
||||
/// value can claim. `ListTile` gives `trailing` as much width as it wants
|
||||
/// before handing the title whatever is left — an unbounded value (e.g. a
|
||||
/// real, arbitrarily long station name for "Emisora preferida") could
|
||||
/// squeeze the title down to almost nothing, forcing it to wrap across
|
||||
/// several lines that then get cut short by the row's fixed height.
|
||||
static const _anchoMaximoValor = 108.0;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final type = context.pluriType;
|
||||
@@ -106,17 +114,32 @@ class FilaAjuste extends StatelessWidget {
|
||||
// 14px/w700 (t4 line 514); cardTitle is 14.5/w700 — a one-off
|
||||
// override, not a new PluriWaveTypography style (mirrors the
|
||||
// precedent set for the ringing screen's station name, audit 9.7).
|
||||
title: Text(titulo, style: type.cardTitle.copyWith(fontSize: 14)),
|
||||
// Issue 5: constrained to one line, ellipsizing instead of wrapping —
|
||||
// labels must wrap as little as possible and never render visibly
|
||||
// truncated (a multi-line wrap inside this fixed-height row cuts the
|
||||
// last line short, which reads as broken, not as intentional).
|
||||
title: Text(
|
||||
titulo,
|
||||
style: type.cardTitle.copyWith(fontSize: 14),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
trailing: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (valorActual != null) ...[
|
||||
Text(
|
||||
valorActual,
|
||||
// bodyStrong is already 13/w600, matching the prototype's row
|
||||
// value spec exactly — only the colour needs overriding.
|
||||
style: type.bodyStrong.copyWith(
|
||||
color: const Color(0xFFF2F7FA).withValues(alpha: 0.55),
|
||||
ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: _anchoMaximoValor),
|
||||
child: Text(
|
||||
valorActual,
|
||||
// bodyStrong is already 13/w600, matching the prototype's row
|
||||
// value spec exactly — only the colour needs overriding.
|
||||
style: type.bodyStrong.copyWith(
|
||||
color: const Color(0xFFF2F7FA).withValues(alpha: 0.55),
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
textAlign: TextAlign.end,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
|
||||
@@ -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(', ');
|
||||
}
|
||||
|
||||
@@ -543,8 +543,17 @@ class _PantallaBuscarState extends State<PantallaBuscar> {
|
||||
if (estado.cargando) {
|
||||
// S5-R6: shimmer placeholders instead of a bare spinner, consistent
|
||||
// with the loading pattern used by the home grid.
|
||||
// Issue 3 (feedback-pruebas): row tier (12), not card tier (16) --
|
||||
// these are background-less row placeholders, same tier as the real
|
||||
// results below; the top inset is the standard section gap rather
|
||||
// than the horizontal constant reused for a vertical axis.
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(PluriLayout.horizontal),
|
||||
padding: const EdgeInsets.fromLTRB(
|
||||
PluriLayout.rowHorizontal,
|
||||
PluriLayout.sectionGap,
|
||||
PluriLayout.rowHorizontal,
|
||||
PluriLayout.rowHorizontal,
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
@@ -579,8 +588,17 @@ class _PantallaBuscarState extends State<PantallaBuscar> {
|
||||
// across the app -- favorites, the discovery grid -- which this
|
||||
// item does not touch).
|
||||
final query = _controller.text.trim();
|
||||
// Issue 3 (feedback-pruebas): this card-tier state had NO top gap at
|
||||
// all against the filter row above it -- the standard section gap
|
||||
// now matches the other two mutually-exclusive results-area states
|
||||
// (loading, populated) above.
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: PluriLayout.horizontal),
|
||||
padding: const EdgeInsets.fromLTRB(
|
||||
PluriLayout.horizontal,
|
||||
PluriLayout.sectionGap,
|
||||
PluriLayout.horizontal,
|
||||
0,
|
||||
),
|
||||
child: _TarjetaSinResultados(
|
||||
titulo:
|
||||
sinFiltros
|
||||
@@ -616,7 +634,17 @@ class _PantallaBuscarState extends State<PantallaBuscar> {
|
||||
return ListView.builder(
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
padding: const EdgeInsets.all(PluriLayout.horizontal),
|
||||
// Issue 3 (feedback-pruebas): row tier (12), not card tier (16) --
|
||||
// `FilaEmisoraPlana` rows are documented as "flat, background-less"
|
||||
// (audit 6.5) but this padding never got updated to match when Tier 1
|
||||
// introduced the 3-tier scale. The top inset is the standard section
|
||||
// gap, not the horizontal constant reused for a vertical axis.
|
||||
padding: const EdgeInsets.fromLTRB(
|
||||
PluriLayout.rowHorizontal,
|
||||
PluriLayout.sectionGap,
|
||||
PluriLayout.rowHorizontal,
|
||||
PluriLayout.rowHorizontal,
|
||||
),
|
||||
itemCount: total,
|
||||
itemBuilder: (context, i) {
|
||||
if (i >= resultados.length) {
|
||||
|
||||
@@ -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,
|
||||
@@ -465,7 +502,13 @@ class _FilaFavorito extends StatelessWidget {
|
||||
context,
|
||||
).colorScheme.onSurface.withValues(alpha: 0.45),
|
||||
),
|
||||
constraints: const BoxConstraints.tightFor(width: 38, height: 42),
|
||||
// NO `constraints:` here. That property sizes the POPUP MENU, not
|
||||
// the button — a tightFor(38x42) clipped every menu item down to
|
||||
// its first letter ("M" for "Mover a lista", "E" for "Eliminar de
|
||||
// favoritos"), which is what users actually saw. Constrain the
|
||||
// tap target instead.
|
||||
padding: EdgeInsets.zero,
|
||||
iconSize: 20,
|
||||
onSelected: (accion) {
|
||||
if (accion == 'assign') _asignar(context);
|
||||
if (accion == 'remove') _eliminar(context);
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -15,6 +15,7 @@ import '../widgets/pluri_glass_surface.dart';
|
||||
import '../widgets/pluri_layout.dart';
|
||||
import '../widgets/pluri_premium_widgets.dart';
|
||||
import '../widgets/pluri_sleep_timer_sheet.dart';
|
||||
import '../widgets/pluri_station_art_fallback.dart';
|
||||
import '../widgets/visualizador_audio.dart';
|
||||
|
||||
import 'pantalla_reproductor.dart';
|
||||
@@ -568,9 +569,9 @@ class _ArteEscuchar extends StatelessWidget {
|
||||
imageUrl: emisora.favicon!,
|
||||
fit: BoxFit.cover,
|
||||
placeholder: (_, __) => _shimmerCuadrado(theme),
|
||||
errorWidget: (_, __, ___) => _iconoFallback(theme),
|
||||
errorWidget: (_, __, ___) => _iconoFallback(),
|
||||
)
|
||||
: _iconoFallback(theme),
|
||||
: _iconoFallback(),
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -582,14 +583,11 @@ class _ArteEscuchar extends StatelessWidget {
|
||||
child: Container(color: theme.colorScheme.surfaceContainerHighest),
|
||||
);
|
||||
|
||||
Widget _iconoFallback(ThemeData theme) => Container(
|
||||
color: theme.colorScheme.primaryContainer,
|
||||
child: Icon(
|
||||
Icons.radio_rounded,
|
||||
size: 36,
|
||||
color: theme.colorScheme.onPrimaryContainer,
|
||||
),
|
||||
);
|
||||
// Issue 6 (feedback-pruebas): was a flat `primaryContainer` square with a
|
||||
// bare `radio_rounded` icon — now the same shared fallback every other
|
||||
// surface uses.
|
||||
Widget _iconoFallback() =>
|
||||
PluriStationArtFallback(seed: emisora.uuid, iconSize: 36);
|
||||
}
|
||||
|
||||
/// The hero's transport row — favorite / EQ toggle / stop / play-pause
|
||||
@@ -909,10 +907,10 @@ class _CeldaTusEmisoras extends StatelessWidget {
|
||||
imageUrl: emisora.favicon!,
|
||||
fit: BoxFit.cover,
|
||||
placeholder: (_, __) => _shimmer(theme),
|
||||
errorWidget: (_, __, ___) => _iconoFallback(theme),
|
||||
errorWidget: (_, __, ___) => _iconoFallback(),
|
||||
);
|
||||
}
|
||||
return _iconoFallback(theme);
|
||||
return _iconoFallback();
|
||||
}
|
||||
|
||||
Widget _shimmer(ThemeData theme) => shimmer.Shimmer.fromColors(
|
||||
@@ -921,12 +919,9 @@ class _CeldaTusEmisoras extends StatelessWidget {
|
||||
child: Container(color: theme.colorScheme.surfaceContainerHighest),
|
||||
);
|
||||
|
||||
Widget _iconoFallback(ThemeData theme) => Container(
|
||||
color: theme.colorScheme.primaryContainer,
|
||||
child: Icon(
|
||||
Icons.radio_rounded,
|
||||
size: 20,
|
||||
color: theme.colorScheme.onPrimaryContainer,
|
||||
),
|
||||
);
|
||||
// Issue 6 (feedback-pruebas): was a flat `primaryContainer` square with a
|
||||
// bare `radio_rounded` icon — now the same shared fallback every other
|
||||
// surface uses.
|
||||
Widget _iconoFallback() =>
|
||||
PluriStationArtFallback(seed: emisora.uuid, iconSize: 20);
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -20,6 +20,7 @@ import '../widgets/ecualizador_widget.dart';
|
||||
import '../widgets/pluri_glass_surface.dart';
|
||||
import '../widgets/pluri_premium_widgets.dart';
|
||||
import '../widgets/pluri_push_scaffold.dart';
|
||||
import '../widgets/pluri_station_art_fallback.dart';
|
||||
import '../widgets/visualizador_audio.dart';
|
||||
|
||||
/// WU14: restructured onto [PluriPushScaffold] (design ADR-2) — this screen
|
||||
@@ -291,10 +292,10 @@ class _ArteReproductor extends StatelessWidget {
|
||||
imageUrl: emisora.favicon!,
|
||||
fit: BoxFit.cover,
|
||||
placeholder: (_, __) => _shimmer(theme),
|
||||
errorWidget: (_, __, ___) => _iconoFallback(theme),
|
||||
errorWidget: (_, __, ___) => _iconoFallback(),
|
||||
)
|
||||
else
|
||||
_iconoFallback(theme),
|
||||
_iconoFallback(),
|
||||
if (cargando)
|
||||
Container(
|
||||
color: Colors.black45,
|
||||
@@ -333,14 +334,11 @@ class _ArteReproductor extends StatelessWidget {
|
||||
child: Container(color: theme.colorScheme.surfaceContainerHighest),
|
||||
);
|
||||
|
||||
Widget _iconoFallback(ThemeData theme) => Container(
|
||||
color: theme.colorScheme.primaryContainer,
|
||||
child: Icon(
|
||||
Icons.radio_rounded,
|
||||
size: 80,
|
||||
color: theme.colorScheme.onPrimaryContainer,
|
||||
),
|
||||
);
|
||||
// Issue 6 (feedback-pruebas): was a flat `primaryContainer` square with a
|
||||
// bare `radio_rounded` icon — now the same shared fallback every other
|
||||
// surface uses.
|
||||
Widget _iconoFallback() =>
|
||||
PluriStationArtFallback(seed: emisora.uuid, iconSize: 80);
|
||||
}
|
||||
|
||||
/// Audit 2.2 (t4 lines 108-109): a full-bleed blurred backdrop of the
|
||||
|
||||
@@ -82,13 +82,78 @@ class PantallaVacaciones extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _abrirAlta(BuildContext context) async {
|
||||
await showModalBottomSheet<void>(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
useSafeArea: true,
|
||||
backgroundColor: Colors.transparent,
|
||||
builder: (_) => const _EditorVacacionesSheet(),
|
||||
Future<void> _abrirAlta(BuildContext context) =>
|
||||
_abrirEditorVacaciones(context);
|
||||
}
|
||||
|
||||
/// Issue 1 (feedback-pruebas): the ONE sheet-opener both the header/CTA
|
||||
/// "create" entry points and every range's own "tap to edit" affordance call
|
||||
/// -- passing [rango] switches the sheet from create to edit mode (mirrors
|
||||
/// `pantalla_alarmas.dart`'s `_abrirEditor`/`_EditorAlarmaSheet` split).
|
||||
Future<void> _abrirEditorVacaciones(
|
||||
BuildContext context, {
|
||||
RangoVacaciones? rango,
|
||||
}) async {
|
||||
await showModalBottomSheet<void>(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
useSafeArea: true,
|
||||
backgroundColor: Colors.transparent,
|
||||
builder: (_) => _EditorVacacionesSheet(rango: rango),
|
||||
);
|
||||
}
|
||||
|
||||
/// Issue 1 (feedback-pruebas): mirrors `pantalla_alarmas.dart`'s
|
||||
/// `_confirmarEliminarAlarma` exactly -- same AlertDialog shape, same
|
||||
/// generic delete/cancel actions, only the copy is vacation-specific.
|
||||
Future<bool> _confirmarEliminarRango(
|
||||
BuildContext context,
|
||||
AppLocalizations l10n,
|
||||
) async {
|
||||
final confirmado = await showDialog<bool>(
|
||||
context: context,
|
||||
builder:
|
||||
(ctx) => AlertDialog(
|
||||
title: Text(l10n.vacationDeleteConfirmTitle),
|
||||
content: Text(l10n.vacationDeleteConfirmMessage),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx, false),
|
||||
child: Text(l10n.cancelAction),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.pop(ctx, true),
|
||||
child: Text(l10n.deleteAction),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
return confirmado ?? false;
|
||||
}
|
||||
|
||||
/// Swipe-to-delete reveal shown on both sides, mirroring
|
||||
/// `pantalla_alarmas.dart`'s `_FondoSwipeEliminarAlarma` -- duplicated
|
||||
/// rather than shared, matching this codebase's own precedent for tiny
|
||||
/// per-screen chrome (see this file's `_DashedBorderPainter` doc comment).
|
||||
class _FondoSwipeEliminarRango extends StatelessWidget {
|
||||
const _FondoSwipeEliminarRango({required this.alignment});
|
||||
|
||||
final Alignment alignment;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tokens = context.pluriTokens;
|
||||
return Container(
|
||||
alignment: alignment,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.error,
|
||||
borderRadius: BorderRadius.circular(tokens.radiusMd),
|
||||
),
|
||||
child: Icon(
|
||||
Icons.delete_outline_rounded,
|
||||
color: Theme.of(context).colorScheme.onError,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -242,46 +307,81 @@ class _HeroRangoActivo extends StatelessWidget {
|
||||
final diasRestantes = rango.finDia.difference(hoyDia).inDays;
|
||||
final impacto = estado.impactoDeRango(rango);
|
||||
final type = context.pluriType;
|
||||
final tokens = context.pluriTokens;
|
||||
|
||||
return PluriGlassSurface(
|
||||
glowColor: context.pluriTokens.electricMagenta.withValues(alpha: 0.24),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
localizedVacationName(l10n, rango.nombre),
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.w900),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
// Item 21 / audit 9b.4 (t4:454): the "active now" caption is a
|
||||
// teal eyebrow, not default body text.
|
||||
Text(
|
||||
l10n.vacationSummaryActiveCountdown(diasRestantes),
|
||||
style: type.eyebrowLabel.copyWith(color: PluriWaveTokens.brand),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
// Item 21 / audit 9b.4 (t4:451-462): the screen's signature
|
||||
// element is a start/end date pair joined by a gradient rule —
|
||||
// the prototype never draws a determinate progress bar here.
|
||||
_ParFechasVacaciones(
|
||||
inicio: rango.inicioDia,
|
||||
fin: rango.finDia,
|
||||
destacado: true,
|
||||
reglaKey: const ValueKey('vacaciones-regla-activo'),
|
||||
),
|
||||
if (impacto.pausadas.isNotEmpty) ...[
|
||||
const SizedBox(height: 12),
|
||||
Text(l10n.vacationImpactPausedLabel(_horas(impacto.pausadas))),
|
||||
],
|
||||
if (impacto.noAfectadas.isNotEmpty) ...[
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
l10n.vacationImpactContinuesLabel(_horas(impacto.noAfectadas)),
|
||||
// Issue 1 (feedback-pruebas): a range starts ACTIVE the instant it's
|
||||
// created (today .. today+2), so this hero is the ONLY place a
|
||||
// brand-new range ever renders until it either becomes "programado" in
|
||||
// the future or "pasado" once it ends. Without tap/swipe here, the
|
||||
// very first range a user creates could never be fixed or removed.
|
||||
return Dismissible(
|
||||
key: ValueKey('vacaciones-tarjeta-${rango.id}'),
|
||||
direction: DismissDirection.horizontal,
|
||||
background: const _FondoSwipeEliminarRango(
|
||||
alignment: Alignment.centerLeft,
|
||||
),
|
||||
secondaryBackground: const _FondoSwipeEliminarRango(
|
||||
alignment: Alignment.centerRight,
|
||||
),
|
||||
confirmDismiss: (_) => _confirmarEliminarRango(context, l10n),
|
||||
onDismissed: (_) => estado.eliminarRangoVacaciones(rango.id),
|
||||
child: PluriGlassSurface(
|
||||
glowColor: tokens.electricMagenta.withValues(alpha: 0.24),
|
||||
padding: EdgeInsets.zero,
|
||||
child: Material(
|
||||
type: MaterialType.transparency,
|
||||
child: InkWell(
|
||||
borderRadius: BorderRadius.circular(tokens.radiusMd),
|
||||
onTap: () => _abrirEditorVacaciones(context, rango: rango),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
localizedVacationName(l10n, rango.nombre),
|
||||
style: Theme.of(context).textTheme.titleLarge?.copyWith(
|
||||
fontWeight: FontWeight.w900,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
// Item 21 / audit 9b.4 (t4:454): the "active now" caption is a
|
||||
// teal eyebrow, not default body text.
|
||||
Text(
|
||||
l10n.vacationSummaryActiveCountdown(diasRestantes),
|
||||
style: type.eyebrowLabel.copyWith(
|
||||
color: PluriWaveTokens.brand,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
// Item 21 / audit 9b.4 (t4:451-462): the screen's signature
|
||||
// element is a start/end date pair joined by a gradient rule —
|
||||
// the prototype never draws a determinate progress bar here.
|
||||
_ParFechasVacaciones(
|
||||
inicio: rango.inicioDia,
|
||||
fin: rango.finDia,
|
||||
destacado: true,
|
||||
reglaKey: const ValueKey('vacaciones-regla-activo'),
|
||||
),
|
||||
if (impacto.pausadas.isNotEmpty) ...[
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
l10n.vacationImpactPausedLabel(_horas(impacto.pausadas)),
|
||||
),
|
||||
],
|
||||
if (impacto.noAfectadas.isNotEmpty) ...[
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
l10n.vacationImpactContinuesLabel(
|
||||
_horas(impacto.noAfectadas),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -452,61 +552,87 @@ class _TarjetaRangoVacaciones extends StatelessWidget {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final t = context.pluriTokens;
|
||||
final type = context.pluriType;
|
||||
return DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
color: t.listSurface,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
border: Border.all(color: Colors.white.withValues(alpha: 0.08)),
|
||||
final estado = context.read<EstadoAlarmas>();
|
||||
// Issue 1 (feedback-pruebas): tap = edit, swipe = delete (with
|
||||
// confirmation) — same interaction `pantalla_alarmas.dart`'s
|
||||
// `_TarjetaAlarma` already uses for the same concept, applied here to
|
||||
// BOTH the "programados" and "pasados" sections (this card backs both).
|
||||
return Dismissible(
|
||||
key: ValueKey('vacaciones-tarjeta-${rango.id}'),
|
||||
direction: DismissDirection.horizontal,
|
||||
background: const _FondoSwipeEliminarRango(
|
||||
alignment: Alignment.centerLeft,
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 15),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (encabezado != null) ...[
|
||||
Text(
|
||||
encabezado!,
|
||||
style: type.eyebrowLabel.copyWith(
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.onSurface.withValues(alpha: 0.5),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 11),
|
||||
],
|
||||
_ParFechasVacaciones(
|
||||
inicio: rango.inicioDia,
|
||||
fin: rango.finDia,
|
||||
destacado: false,
|
||||
reglaKey: ValueKey('vacaciones-regla-${rango.id}'),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.label_outline_rounded,
|
||||
size: 17,
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.onSurface.withValues(alpha: 0.6),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
localizedVacationName(l10n, rango.nombre),
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.onSurface.withValues(alpha: 0.6),
|
||||
secondaryBackground: const _FondoSwipeEliminarRango(
|
||||
alignment: Alignment.centerRight,
|
||||
),
|
||||
confirmDismiss: (_) => _confirmarEliminarRango(context, l10n),
|
||||
onDismissed: (_) => estado.eliminarRangoVacaciones(rango.id),
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
color: t.listSurface,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
border: Border.all(color: Colors.white.withValues(alpha: 0.08)),
|
||||
),
|
||||
child: Material(
|
||||
type: MaterialType.transparency,
|
||||
child: InkWell(
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
onTap: () => _abrirEditorVacaciones(context, rango: rango),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 15),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (encabezado != null) ...[
|
||||
Text(
|
||||
encabezado!,
|
||||
style: type.eyebrowLabel.copyWith(
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.onSurface.withValues(alpha: 0.5),
|
||||
),
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
const SizedBox(height: 11),
|
||||
],
|
||||
_ParFechasVacaciones(
|
||||
inicio: rango.inicioDia,
|
||||
fin: rango.finDia,
|
||||
destacado: false,
|
||||
reglaKey: ValueKey('vacaciones-regla-${rango.id}'),
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.label_outline_rounded,
|
||||
size: 17,
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.onSurface.withValues(alpha: 0.6),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
localizedVacationName(l10n, rango.nombre),
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.bodyMedium?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.onSurface.withValues(alpha: 0.6),
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -609,11 +735,17 @@ class _BloqueFecha extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
/// Add-range form. Moved verbatim from `pantalla_alarmas.dart` (WU8's
|
||||
/// Add/edit-range form. Moved verbatim from `pantalla_alarmas.dart` (WU8's
|
||||
/// `_PantallaVacacionesTemporal` used it as a placeholder push target; now
|
||||
/// this screen is the one real consumer). Behaviour unchanged.
|
||||
/// this screen is the one real consumer). Create behaviour unchanged; issue
|
||||
/// 1 (feedback-pruebas) adds the edit half via the optional [rango] — the
|
||||
/// SAME sheet, mirroring `pantalla_alarmas.dart`'s `_EditorAlarmaSheet`
|
||||
/// (`alarma == null` -> create, non-null -> edit; one shared save button
|
||||
/// either way).
|
||||
class _EditorVacacionesSheet extends StatefulWidget {
|
||||
const _EditorVacacionesSheet();
|
||||
const _EditorVacacionesSheet({this.rango});
|
||||
|
||||
final RangoVacaciones? rango;
|
||||
|
||||
@override
|
||||
State<_EditorVacacionesSheet> createState() => _EditorVacacionesSheetState();
|
||||
@@ -629,16 +761,29 @@ class _EditorVacacionesSheetState extends State<_EditorVacacionesSheet> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
final hoy = DateTime.now();
|
||||
_inicio = DateTime(hoy.year, hoy.month, hoy.day);
|
||||
_fin = _inicio.add(const Duration(days: 2));
|
||||
final rango = widget.rango;
|
||||
if (rango != null) {
|
||||
_inicio = rango.inicioDia;
|
||||
_fin = rango.finDia;
|
||||
} else {
|
||||
final hoy = DateTime.now();
|
||||
_inicio = DateTime(hoy.year, hoy.month, hoy.day);
|
||||
_fin = _inicio.add(const Duration(days: 2));
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
final rango = widget.rango;
|
||||
_nombreController ??= TextEditingController(
|
||||
text: AppLocalizations.of(context).vacationsDefaultName,
|
||||
text:
|
||||
rango != null
|
||||
? localizedVacationName(
|
||||
AppLocalizations.of(context),
|
||||
rango.nombre,
|
||||
)
|
||||
: AppLocalizations.of(context).vacationsDefaultName,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -662,7 +807,9 @@ class _EditorVacacionesSheetState extends State<_EditorVacacionesSheet> {
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
l10n.newVacationRangeTitle,
|
||||
widget.rango != null
|
||||
? l10n.editVacationRangeTitle
|
||||
: l10n.newVacationRangeTitle,
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.w900),
|
||||
@@ -695,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),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -709,10 +884,16 @@ class _EditorVacacionesSheetState extends State<_EditorVacacionesSheet> {
|
||||
Future<void> _elegirFecha({required bool esInicio}) async {
|
||||
final actual = esInicio ? _inicio : _fin;
|
||||
final hoy = DateTime.now();
|
||||
final hoyDia = DateTime(hoy.year, hoy.month, hoy.day);
|
||||
// Issue 1 (feedback-pruebas): editing a PAST range (reachable from the
|
||||
// "Rangos pasados" section) must not force its dates into the future —
|
||||
// `firstDate` only floors at today for a range that starts there or
|
||||
// later; an already-past range keeps its own start as the floor.
|
||||
final primerDiaPermitido = _inicio.isBefore(hoyDia) ? _inicio : hoyDia;
|
||||
final seleccion = await showDatePicker(
|
||||
context: context,
|
||||
initialDate: actual,
|
||||
firstDate: DateTime(hoy.year, hoy.month, hoy.day),
|
||||
firstDate: primerDiaPermitido,
|
||||
lastDate: hoy.add(const Duration(days: 1460)),
|
||||
);
|
||||
if (seleccion == null) return;
|
||||
@@ -728,12 +909,41 @@ class _EditorVacacionesSheetState extends State<_EditorVacacionesSheet> {
|
||||
|
||||
Future<void> _guardar() async {
|
||||
final estado = context.read<EstadoAlarmas>();
|
||||
final rango = estado.servicio.crearRangoVacaciones(
|
||||
inicio: _inicio,
|
||||
fin: _fin,
|
||||
nombre: _nombreController?.text.trim() ?? '',
|
||||
);
|
||||
await estado.crearRangoVacaciones(rango);
|
||||
final nombre = _nombreController?.text.trim() ?? '';
|
||||
final existente = widget.rango;
|
||||
if (existente != null) {
|
||||
await estado.editarRangoVacaciones(
|
||||
RangoVacaciones(
|
||||
id: existente.id,
|
||||
nombre: nombre,
|
||||
inicio: _inicio,
|
||||
fin: _fin,
|
||||
activo: existente.activo,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
final rango = estado.servicio.crearRangoVacaciones(
|
||||
inicio: _inicio,
|
||||
fin: _fin,
|
||||
nombre: nombre,
|
||||
);
|
||||
await estado.crearRangoVacaciones(rango);
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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,23 @@ 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
|
||||
/// folder" rule that used to live in this doc comment (commit `2403da3`,
|
||||
/// mirroring the redesign mockup's "sin carpeta de ecualizador", turn t4
|
||||
/// line 40). That rule was sound when written, but predated on-device
|
||||
/// feedback showing that Android Auto custom actions don't surface
|
||||
/// enough state for choosing among six presets: a monochrome icon cannot
|
||||
/// legibly encode "which preset", and many head units render a custom
|
||||
/// action icon-first, hiding its label. `Ecualizador` is a real
|
||||
/// browsable folder again: "Desactivar" first, then the six factory
|
||||
/// presets, the active one marked (children built by
|
||||
/// `itemsEcualizadorAuto` in `servicio_audio.dart` -- this class stays
|
||||
/// free of any `AppLocalizations` dependency, unlike that builder).
|
||||
/// Always present, and LAST in the list (after Música Local, when
|
||||
/// included) -- unlike [idMusicaLocal] it is never conditionally hidden.
|
||||
/// Do not "restore" the no-folder rule without re-reading that decision.
|
||||
///
|
||||
/// `Música Local` is OMITTED entirely (not just empty) unless
|
||||
/// [incluirMusicaLocal] is `true` (Design "Local root hidden until a folder
|
||||
@@ -296,6 +339,7 @@ class ConstructorArbolAuto {
|
||||
_carpeta(idTodas, 'Todas las emisoras'),
|
||||
_carpeta(idMisEmisoras, 'Mis emisoras'),
|
||||
if (incluirMusicaLocal) _carpeta(idMusicaLocal, 'Música Local'),
|
||||
_carpeta(idEcualizador, 'Ecualizador'),
|
||||
];
|
||||
|
||||
MediaItem _carpeta(String id, String titulo) => MediaItem(
|
||||
@@ -305,15 +349,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 +484,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 +506,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 +536,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 +762,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 +781,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 +816,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 +844,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 +927,54 @@ 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);
|
||||
}
|
||||
|
||||
/// 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.
|
||||
Future<void> seleccionarPresetEqPorMediaId(
|
||||
String id, {
|
||||
required bool activo,
|
||||
required Future<void> Function(PresetEcualizador) aplicarPreset,
|
||||
required Future<void> Function(bool) activarEcualizador,
|
||||
}) async {
|
||||
final constructor = ConstructorArbolAuto();
|
||||
if (!constructor.esPresetEqMediaId(id)) return;
|
||||
|
||||
if (constructor.esDesactivarEqMediaId(id)) {
|
||||
await activarEcualizador(false);
|
||||
return;
|
||||
}
|
||||
|
||||
final preset = constructor.resolverPresetEq(id);
|
||||
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 +1032,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 +1074,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 +1119,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 +1245,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 +1273,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 +1400,7 @@ Future<List<MediaItem>?> hijosMusicaLocal(
|
||||
documentIdPadre: documentId,
|
||||
pagina: pagina,
|
||||
metadatosDe: (ids) => _metadatosDeConCache(ids, fuente: fuente),
|
||||
fuente: fuente,
|
||||
);
|
||||
} catch (_) {
|
||||
return const [];
|
||||
@@ -1170,6 +1457,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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -60,6 +60,221 @@ void registrarFuenteMusicaLocal(FuenteMusicaLocalAuto fuente) {
|
||||
_fuenteMusicaLocalGlobal = fuente;
|
||||
}
|
||||
|
||||
/// Builds the phone-initiated "play a station" `MediaItem` (item 3, Android
|
||||
/// Auto fallback artwork): reuses [artUriPara] (`navegacion_auto.dart`) so a
|
||||
/// station with no usable favicon gets the SAME on-brand rotating fallback
|
||||
/// the browse tree and the car-tap path already show, instead of a blank
|
||||
/// tile on the car/lockscreen/notification. Pure — no [PluriWaveAudioHandler]
|
||||
/// dependency — so it is unit-testable without instantiating the handler.
|
||||
MediaItem mediaItemParaEmisora(
|
||||
Emisora emisora, {
|
||||
required AppLocalizations l10n,
|
||||
}) {
|
||||
return MediaItem(
|
||||
id: emisora.url,
|
||||
title: localizedStationName(l10n, emisora.nombre),
|
||||
artist: emisora.pais ?? '',
|
||||
album: 'PluriWave',
|
||||
artUri: Uri.parse(artUriPara(emisora)),
|
||||
extras: {'uuid': emisora.uuid},
|
||||
);
|
||||
}
|
||||
|
||||
/// Reconstructs the phone-side [Emisora] from the handler's current
|
||||
/// [MediaItem] (item 3): gates `favicon` through [faviconUsable]
|
||||
/// (`navegacion_auto.dart`) so a car/car-tap "now playing" item's on-brand
|
||||
/// FALLBACK `artUri` (an `android.resource://` drawable, never a real
|
||||
/// favicon) is never misread as a genuine station favicon — the phone UI's
|
||||
/// `CachedNetworkImage` widgets gate only on `favicon != null && isNotEmpty`
|
||||
/// (not on `faviconUsable`'s scheme check), so without this guard they would
|
||||
/// attempt a doomed network fetch of the fallback's non-http URI before
|
||||
/// falling back to [PluriStationArtFallback] themselves. A genuine http(s)
|
||||
/// favicon still round-trips exactly as before. Pure — no handler
|
||||
/// dependency — unit-testable directly.
|
||||
Emisora emisoraDesdeMediaItem(MediaItem mediaItem) {
|
||||
final uuid = mediaItem.extras?['uuid'] as String? ?? mediaItem.id;
|
||||
final artUriTexto = mediaItem.artUri?.toString();
|
||||
return Emisora(
|
||||
uuid: uuid,
|
||||
nombre: mediaItem.title,
|
||||
url: mediaItem.id,
|
||||
pais: (mediaItem.artist?.isNotEmpty ?? false) ? mediaItem.artist : null,
|
||||
favicon: faviconUsable(artUriTexto) ? artUriTexto : null,
|
||||
);
|
||||
}
|
||||
|
||||
/// Custom-action names for the equalizer's `PlaybackStateCompat` custom
|
||||
/// actions on the now-playing screen (Design "EQ custom actions", item 4).
|
||||
/// Public consts so tests and this file's own `customAction` dispatch share
|
||||
/// the exact same literals; distinct from every browse-tree media-id prefix
|
||||
/// in `navegacion_auto.dart` (they live in a completely different
|
||||
/// `MediaControl`/`customAction` namespace, never compared against a
|
||||
/// media id).
|
||||
const accionEqToggle = 'eq_toggle';
|
||||
|
||||
/// Advances to the NEXT factory preset after [actual] in [presets] order
|
||||
/// (Design "EQ custom actions — cycling presets", item 4): wraps around
|
||||
/// after the last one. When [actual] is not found in [presets] (e.g. a
|
||||
/// user-tweaked "Personalizado" preset from `EstadoEcualizador.cambiarBanda`),
|
||||
/// starts from the FIRST preset rather than throwing — cycling from an
|
||||
/// unknown state always lands somewhere sane. Pure, no I/O.
|
||||
///
|
||||
/// [presets] defaults to [PresetEcualizador.presets] — not a literal default
|
||||
/// value, since that field is `static final` (not `const`) and Dart default
|
||||
/// parameter values must be compile-time constants.
|
||||
PresetEcualizador presetSiguiente(
|
||||
PresetEcualizador actual, {
|
||||
List<PresetEcualizador>? presets,
|
||||
}) {
|
||||
final lista = presets ?? PresetEcualizador.presets;
|
||||
final indice = lista.indexWhere((p) => p == actual);
|
||||
if (indice == -1) return lista.first;
|
||||
return lista[(indice + 1) % lista.length];
|
||||
}
|
||||
|
||||
/// Localizes a preset's raw `nombre` for the equalizer custom action's
|
||||
/// label (Design "EQ custom actions", item 4) — mirrors
|
||||
/// `ecualizador_widget.dart`'s private `_nombrePreset` mapping (duplicated
|
||||
/// rather than shared: that file is UI-widget layer, this one is the
|
||||
/// service/handler layer, and the mapping is a single small switch, not
|
||||
/// worth a cross-layer import for). An unrecognized name (e.g. a future
|
||||
/// user-named custom preset) falls through to the raw name verbatim.
|
||||
String nombrePresetVisible(AppLocalizations l10n, String nombre) {
|
||||
return switch (nombre) {
|
||||
'Flat' => l10n.equalizerPresetFlat,
|
||||
'Rock' => l10n.equalizerPresetRock,
|
||||
'Pop' => l10n.equalizerPresetPop,
|
||||
'Bass Boost' => l10n.equalizerPresetBassBoost,
|
||||
'Jazz' => l10n.equalizerPresetJazz,
|
||||
'Voz' => l10n.equalizerPresetVoice,
|
||||
'Personalizado' => l10n.equalizerPresetCustom,
|
||||
_ => nombre,
|
||||
};
|
||||
}
|
||||
|
||||
/// Builds the equalizer's custom-action `MediaControl`s for the now-playing
|
||||
/// screen (decision `auto/ecualizador-diseno`) — exactly 1: an on/off
|
||||
/// toggle. The previous design paired this with a SECOND action that cycled
|
||||
/// through the six factory presets; that action is REMOVED. On-device
|
||||
/// feedback: many head units render custom actions icon-first, so two
|
||||
/// static, non-parametrized glyphs sitting side by side looked identical/
|
||||
/// dead even though the toggle's own icon DID change and the cycle action
|
||||
/// DID work — a monochrome icon simply cannot legibly encode "which of six
|
||||
/// presets" the way a browsable list's text rows can. Preset selection now
|
||||
/// lives in the "Ecualizador" browsable folder instead (see
|
||||
/// [itemsEcualizadorAuto]), which also frees this scarce custom-action
|
||||
/// slot. Do NOT re-add a preset-cycling custom action; extend the folder
|
||||
/// instead.
|
||||
/// Empty when [disponible] is false (gate on EQ availability, mirrors the
|
||||
/// existing `debeReaplicarEcualizador`/`_eqDisponible` gate) — a device
|
||||
/// without the native Equalizer effect gets no EQ actions at all, not
|
||||
/// broken ones.
|
||||
///
|
||||
/// On-device feedback follow-up: this action used to reuse the SAME
|
||||
/// `ic_stat_pluriwave` drawable as everything else and was visually
|
||||
/// indistinguishable on a car head unit, which foregrounds the icon over
|
||||
/// the label. It now gets its own dedicated drawables
|
||||
/// (`ic_auto_eq_on`/`ic_auto_eq_off`), and the icon itself reflects
|
||||
/// [activo] (not just its label) so on/off is legible at a glance. Pure, no
|
||||
/// handler dependency.
|
||||
List<MediaControl> controlesEcualizadorPersonalizados({
|
||||
required bool disponible,
|
||||
required bool activo,
|
||||
required AppLocalizations l10n,
|
||||
}) {
|
||||
if (!disponible) return const [];
|
||||
return [
|
||||
MediaControl.custom(
|
||||
androidIcon:
|
||||
activo ? 'drawable/ic_auto_eq_on' : 'drawable/ic_auto_eq_off',
|
||||
label:
|
||||
activo
|
||||
? l10n.eqCustomActionDisableLabel
|
||||
: l10n.eqCustomActionEnableLabel,
|
||||
name: accionEqToggle,
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
/// Content-style extras for the Ecualizador folder's items (decision
|
||||
/// `auto/ecualizador-diseno`), mirrors `ConstructorArbolAuto
|
||||
/// ._contentStyleLista` in `navegacion_auto.dart` — duplicated rather than
|
||||
/// exposed publicly (see [nombrePresetVisible]'s doc for why small pieces
|
||||
/// are deliberately duplicated across this handler/service layer and the
|
||||
/// pure browse-tree builder layer rather than cross-layer-shared). List
|
||||
/// style, not grid: these items are short text options with no artwork of
|
||||
/// their own, unlike a station or local-track tile.
|
||||
const _contentStyleListaEq = {
|
||||
'android.media.browse.CONTENT_STYLE_BROWSABLE_HINT': 1,
|
||||
'android.media.browse.CONTENT_STYLE_PLAYABLE_HINT': 1,
|
||||
};
|
||||
|
||||
/// Marks the active Ecualizador-folder item by prefixing [titulo] with a
|
||||
/// checkmark glyph (decision `auto/ecualizador-diseno`, spec "the active
|
||||
/// preset must be visibly marked").
|
||||
///
|
||||
/// A `MediaItem.extras` completion-status flag (`androidx.media.utils.
|
||||
/// MediaConstants.DESCRIPTION_EXTRAS_KEY_COMPLETION_STATUS`) was considered
|
||||
/// and REJECTED as the marking mechanism: this project's `audio_service`
|
||||
/// version (0.18.18) has no Dart wrapper for it — only `AndroidContentStyle`
|
||||
/// 's list/grid hints are exposed — and the raw platform key itself is
|
||||
/// designed for playback-COMPLETION tracking (e.g. "this podcast episode
|
||||
/// was already listened to"), not item SELECTION; repurposing it here could
|
||||
/// render as "already played" on some head units, which would be actively
|
||||
/// misleading for a preset picker, and there is no way to verify its actual
|
||||
/// rendering on a real head unit from this environment. A plain-text
|
||||
/// marker renders identically and unambiguously on every head unit, which
|
||||
/// an unverifiable, semantically-mismatched extras key cannot guarantee.
|
||||
String _marcarActivoEq(String titulo, {required bool activo}) =>
|
||||
activo ? '✓ $titulo' : titulo;
|
||||
|
||||
/// Builds the "Ecualizador" folder's children for the Android Auto browse
|
||||
/// tree (decision `auto/ecualizador-diseno`): "Desactivar" FIRST, then the
|
||||
/// six factory presets in [PresetEcualizador.presets] order, each localized
|
||||
/// via [nombrePresetVisible] — the SAME helper the toggle's custom-action
|
||||
/// label already uses, so a preset's name reads identically whether the
|
||||
/// driver sees it in the now-playing screen's tooltip or in this folder.
|
||||
/// All items are playable: tapping one is dispatched through
|
||||
/// `playFromMediaId` -> `seleccionarPresetEqPorMediaId` (`navegacion_auto.
|
||||
/// dart`), the same seam every other browse-tree leaf already uses; this
|
||||
/// folder has no sub-browsing. Exactly one item is marked active via
|
||||
/// [_marcarActivoEq]: "Desactivar" when [activo] is `false`, otherwise
|
||||
/// whichever preset equals [presetActual] — never both at once, and never
|
||||
/// zero once this function is reached (an unresolvable [presetActual] with
|
||||
/// [activo] `true` simply marks nothing, which cannot happen in practice
|
||||
/// since [presetActual] always originates from [PresetEcualizador.presets]
|
||||
/// or a "Personalizado" tweak that would just leave every item unmarked
|
||||
/// rather than mis-marking one).
|
||||
List<MediaItem> itemsEcualizadorAuto({
|
||||
required bool activo,
|
||||
required PresetEcualizador presetActual,
|
||||
required AppLocalizations l10n,
|
||||
}) {
|
||||
final constructor = ConstructorArbolAuto();
|
||||
final items = <MediaItem>[
|
||||
MediaItem(
|
||||
id: ConstructorArbolAuto.idDesactivarEq,
|
||||
title: _marcarActivoEq(l10n.autoEqDisableOption, activo: !activo),
|
||||
playable: true,
|
||||
extras: _contentStyleListaEq,
|
||||
),
|
||||
];
|
||||
for (final preset in PresetEcualizador.presets) {
|
||||
items.add(
|
||||
MediaItem(
|
||||
id: constructor.idPresetEq(preset.nombre),
|
||||
title: _marcarActivoEq(
|
||||
nombrePresetVisible(l10n, preset.nombre),
|
||||
activo: activo && preset == presetActual,
|
||||
),
|
||||
playable: true,
|
||||
extras: _contentStyleListaEq,
|
||||
),
|
||||
);
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
/// Wrapper de alto nivel para el UI.
|
||||
class ServicioAudio {
|
||||
PluriWaveAudioHandler get _handler {
|
||||
@@ -94,19 +309,9 @@ class ServicioAudio {
|
||||
});
|
||||
|
||||
Future<void> reproducir(Emisora emisora) async {
|
||||
final item = MediaItem(
|
||||
id: emisora.url,
|
||||
title: localizedStationName(
|
||||
lookupAppLocalizations(const Locale('es')),
|
||||
emisora.nombre,
|
||||
),
|
||||
artist: emisora.pais ?? '',
|
||||
album: 'PluriWave',
|
||||
artUri:
|
||||
emisora.favicon != null && emisora.favicon!.isNotEmpty
|
||||
? Uri.tryParse(emisora.favicon!)
|
||||
: null,
|
||||
extras: {'uuid': emisora.uuid},
|
||||
final item = mediaItemParaEmisora(
|
||||
emisora,
|
||||
l10n: lookupAppLocalizations(const Locale('es')),
|
||||
);
|
||||
await _handler.playMediaItem(item);
|
||||
}
|
||||
@@ -289,12 +494,10 @@ class PluriWaveAudioHandler extends BaseAudioHandler
|
||||
final colaActiva = _colaLocal != null;
|
||||
playbackState.add(
|
||||
playbackState.value.copyWith(
|
||||
controls: [
|
||||
if (colaActiva) MediaControl.skipToPrevious,
|
||||
if (playing) MediaControl.pause else MediaControl.play,
|
||||
MediaControl.stop,
|
||||
if (colaActiva) MediaControl.skipToNext,
|
||||
],
|
||||
controls: _controlesTransporte(
|
||||
colaActiva: colaActiva,
|
||||
playing: playing,
|
||||
),
|
||||
systemActions: {
|
||||
MediaAction.seek,
|
||||
MediaAction.stop,
|
||||
@@ -339,6 +542,48 @@ class PluriWaveAudioHandler extends BaseAudioHandler
|
||||
});
|
||||
}
|
||||
|
||||
/// The full transport `controls` list for a `playbackState` push (item 4):
|
||||
/// the existing skip/play-pause/stop set, plus the equalizer's custom
|
||||
/// actions appended at the end. Appending (rather than interleaving) keeps
|
||||
/// [MediaControl.skipToPrevious]/play-pause/stop/[MediaControl.skipToNext]
|
||||
/// at their existing indices 0-3, so `androidCompactActionIndices`
|
||||
/// (`[colaActiva ? 1 : 0]`) stays correct unchanged.
|
||||
List<MediaControl> _controlesTransporte({
|
||||
required bool colaActiva,
|
||||
required bool playing,
|
||||
}) => [
|
||||
if (colaActiva) MediaControl.skipToPrevious,
|
||||
if (playing) MediaControl.pause else MediaControl.play,
|
||||
MediaControl.stop,
|
||||
if (colaActiva) MediaControl.skipToNext,
|
||||
..._controlesEqPersonalizados(),
|
||||
];
|
||||
|
||||
List<MediaControl> _controlesEqPersonalizados() =>
|
||||
controlesEcualizadorPersonalizados(
|
||||
disponible: _eqDisponible,
|
||||
activo: _ecualizadorActivo,
|
||||
l10n: _textos,
|
||||
);
|
||||
|
||||
/// Re-pushes `playbackState` with a freshly built controls list (item 4):
|
||||
/// called whenever EQ availability/enabled/preset state changes outside a
|
||||
/// player-state transition (a custom-action tap, or a phone-side preset/
|
||||
/// toggle change), so the equalizer custom actions' label and current-
|
||||
/// preset name stay in sync on the now-playing screen without waiting for
|
||||
/// an unrelated player event. Idempotent and cheap (no native calls) —
|
||||
/// safe to call from any EQ state-changing path.
|
||||
void _actualizarControlesEq() {
|
||||
playbackState.add(
|
||||
playbackState.value.copyWith(
|
||||
controls: _controlesTransporte(
|
||||
colaActiva: _colaLocal != null,
|
||||
playing: playbackState.value.playing,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Gestiona cualquier error de reproducción de ExoPlayer.
|
||||
///
|
||||
/// Network-class failures while the user still intends to play enter the
|
||||
@@ -724,6 +969,11 @@ class PluriWaveAudioHandler extends BaseAudioHandler
|
||||
} catch (_) {
|
||||
_eqDisponible = false;
|
||||
}
|
||||
// Item 4: an availability flip (e.g. a station switch that lands on a
|
||||
// device without the native Equalizer effect) must show/hide the EQ
|
||||
// custom actions immediately, not wait for a coincidental later
|
||||
// player-state event.
|
||||
_actualizarControlesEq();
|
||||
}
|
||||
|
||||
/// Pure re-apply decision for a native session-id emission. No side effects.
|
||||
@@ -742,25 +992,31 @@ class PluriWaveAudioHandler extends BaseAudioHandler
|
||||
/// Aplica un preset al ecualizador nativo Android.
|
||||
Future<void> aplicarPreset(PresetEcualizador preset) async {
|
||||
_presetActual = preset;
|
||||
if (!_eqDisponible) return;
|
||||
try {
|
||||
await _eq.setEnabled(_ecualizadorActivo);
|
||||
if (!_ecualizadorActivo) return;
|
||||
final params = await _eq.parameters;
|
||||
for (
|
||||
int i = 0;
|
||||
i < params.bands.length && i < preset.bandas.length;
|
||||
i++
|
||||
) {
|
||||
await params.bands[i].setGain(
|
||||
_mapearGananciaNativa(
|
||||
preset.bandas[i],
|
||||
minDecibels: params.minDecibels,
|
||||
maxDecibels: params.maxDecibels,
|
||||
),
|
||||
);
|
||||
}
|
||||
} catch (_) {}
|
||||
if (_eqDisponible) {
|
||||
try {
|
||||
await _eq.setEnabled(_ecualizadorActivo);
|
||||
if (_ecualizadorActivo) {
|
||||
final params = await _eq.parameters;
|
||||
for (
|
||||
int i = 0;
|
||||
i < params.bands.length && i < preset.bandas.length;
|
||||
i++
|
||||
) {
|
||||
await params.bands[i].setGain(
|
||||
_mapearGananciaNativa(
|
||||
preset.bandas[i],
|
||||
minDecibels: params.minDecibels,
|
||||
maxDecibels: params.maxDecibels,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
// Item 4: keeps the EQ custom action's preset-cycle label in sync
|
||||
// regardless of WHO changed the preset (a car customAction tap or the
|
||||
// phone settings screen via EstadoEcualizador) — single chokepoint.
|
||||
_actualizarControlesEq();
|
||||
}
|
||||
|
||||
/// Ajusta una banda individual.
|
||||
@@ -796,13 +1052,18 @@ class PluriWaveAudioHandler extends BaseAudioHandler
|
||||
|
||||
Future<void> setEcualizadorActivo(bool activo) async {
|
||||
_ecualizadorActivo = activo;
|
||||
if (!_eqDisponible) return;
|
||||
try {
|
||||
await _eq.setEnabled(activo);
|
||||
if (activo) {
|
||||
await aplicarPreset(_presetActual);
|
||||
}
|
||||
} catch (_) {}
|
||||
if (_eqDisponible) {
|
||||
try {
|
||||
await _eq.setEnabled(activo);
|
||||
if (activo) {
|
||||
await aplicarPreset(_presetActual);
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
// Item 4: keeps the EQ custom action's on/off label in sync regardless
|
||||
// of WHO toggled it (a car customAction tap or the phone settings
|
||||
// screen via EstadoEcualizador).
|
||||
_actualizarControlesEq();
|
||||
}
|
||||
|
||||
Future<void> setVolumen(double vol) async {
|
||||
@@ -834,6 +1095,16 @@ class PluriWaveAudioHandler extends BaseAudioHandler
|
||||
await _player.setVolume(_volumenEfectivo);
|
||||
}
|
||||
|
||||
/// Fix "EQ Re-Apply After Audio-Focus Interruption": thin delegate to the
|
||||
/// existing [_activarEcualizador] (already does the correct idempotent
|
||||
/// `setEnabled` + re-push-gains work, already re-asserts the CURRENT
|
||||
/// [_ecualizadorActivo] rather than forcing it on). Called by
|
||||
/// [ServicioAudioSession] on resume-from-pause and on un-duck — see that
|
||||
/// interface member's doc for why the existing session-id-change trigger
|
||||
/// misses this case.
|
||||
@override
|
||||
Future<void> reaplicarEcualizador() => _activarEcualizador();
|
||||
|
||||
@override
|
||||
Future<void> play() {
|
||||
_intencionReproducir = true;
|
||||
@@ -899,6 +1170,28 @@ class PluriWaveAudioHandler extends BaseAudioHandler
|
||||
await _reproducirEntradaCola(anterior.actual);
|
||||
}
|
||||
|
||||
/// Dispatches the equalizer's only custom action (decision
|
||||
/// `auto/ecualizador-diseno`): `accionEqToggle` flips on/off, delegating
|
||||
/// to the existing [setEcualizadorActivo] — the SAME entry point the
|
||||
/// phone settings screen uses via `EstadoEcualizador` — so a car tap and a
|
||||
/// phone tap have identical effects and both refresh the action's label
|
||||
/// via `_actualizarControlesEq()` (already wired into that method). The
|
||||
/// preset-cycling action that used to live here is REMOVED — preset
|
||||
/// selection now goes through the "Ecualizador" browsable folder (see
|
||||
/// `seleccionarPresetEqPorMediaId` in `navegacion_auto.dart`, dispatched
|
||||
/// from [playFromMediaId] below). Any other [name] is a no-op — never
|
||||
/// throws.
|
||||
@override
|
||||
Future<dynamic> customAction(
|
||||
String name, [
|
||||
Map<String, dynamic>? extras,
|
||||
]) async {
|
||||
switch (name) {
|
||||
case accionEqToggle:
|
||||
await setEcualizadorActivo(!_ecualizadorActivo);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> onTaskRemoved() async {
|
||||
await stop();
|
||||
@@ -911,14 +1204,10 @@ class PluriWaveAudioHandler extends BaseAudioHandler
|
||||
}
|
||||
|
||||
Emisora _emisoraDesdeMediaItem(MediaItem mediaItem) {
|
||||
final uuid = mediaItem.extras?['uuid'] as String? ?? mediaItem.id;
|
||||
return Emisora(
|
||||
uuid: uuid,
|
||||
nombre: mediaItem.title,
|
||||
url: mediaItem.id,
|
||||
pais: (mediaItem.artist?.isNotEmpty ?? false) ? mediaItem.artist : null,
|
||||
favicon: mediaItem.artUri?.toString(),
|
||||
);
|
||||
// Item 3: delegates to the top-level, unit-testable function so the
|
||||
// `faviconUsable` guard (never reflect the on-brand fallback artUri
|
||||
// back as a real favicon) is covered without instantiating the handler.
|
||||
return emisoraDesdeMediaItem(mediaItem);
|
||||
}
|
||||
|
||||
// ── Android Auto browsing (thin delegation to navegacion_auto.dart's
|
||||
@@ -946,6 +1235,17 @@ class PluriWaveAudioHandler extends BaseAudioHandler
|
||||
fuente: fuenteLocal,
|
||||
);
|
||||
if (musicaLocal != null) return musicaLocal;
|
||||
// Ecualizador folder (decision `auto/ecualizador-diseno`): needs no
|
||||
// external data source, unlike every branch below it -- checked
|
||||
// before the `_fuenteNavegacionGlobal` gate, mirroring how the
|
||||
// local-music branch above is also resolved before that gate.
|
||||
if (parentMediaId == ConstructorArbolAuto.idEcualizador) {
|
||||
return itemsEcualizadorAuto(
|
||||
activo: _ecualizadorActivo,
|
||||
presetActual: _presetActual,
|
||||
l10n: _textos,
|
||||
);
|
||||
}
|
||||
final fuente = _fuenteNavegacionGlobal;
|
||||
if (fuente == null) return const [];
|
||||
if (parentMediaId == ConstructorArbolAuto.idFavoritos) {
|
||||
@@ -1016,14 +1316,24 @@ class PluriWaveAudioHandler extends BaseAudioHandler
|
||||
if (fuenteLocal == null) return;
|
||||
await reproducirCarpetaLocal(
|
||||
mediaId,
|
||||
aleatorio: constructorArbol.esCarpetaLocalAleatorioMediaId(
|
||||
mediaId,
|
||||
),
|
||||
aleatorio: constructorArbol.esCarpetaLocalAleatorioMediaId(mediaId),
|
||||
fuente: fuenteLocal,
|
||||
iniciarCola: _iniciarColaLocal,
|
||||
);
|
||||
return;
|
||||
}
|
||||
// Equalizer preset selection (decision `auto/ecualizador-diseno`):
|
||||
// THIRD branch, same unconditional-return shape as the two above --
|
||||
// an `eq_preset:` id never falls through to station routing.
|
||||
if (constructorArbol.esPresetEqMediaId(mediaId)) {
|
||||
await seleccionarPresetEqPorMediaId(
|
||||
mediaId,
|
||||
activo: _ecualizadorActivo,
|
||||
aplicarPreset: aplicarPreset,
|
||||
activarEcualizador: setEcualizadorActivo,
|
||||
);
|
||||
return;
|
||||
}
|
||||
final fuente = _fuenteNavegacionGlobal;
|
||||
if (fuente == null) return;
|
||||
await reproducirPorMediaId(
|
||||
|
||||
@@ -20,6 +20,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
|
||||
@@ -84,11 +98,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.
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import '../l10n/display_names.dart';
|
||||
import '../l10n/gen/app_localizations.dart';
|
||||
import '../modelos/emisora.dart';
|
||||
import '../tema/pluriwave_tokens.dart';
|
||||
import 'pluri_station_art_fallback.dart';
|
||||
|
||||
/// Item 23 / audit 4.3 + 6.5 (t4:226-232, 302-306): a flat, background-less
|
||||
/// station row — square thumbnail, name, meta line, and a caller-supplied
|
||||
@@ -234,10 +235,10 @@ class _ArteFilaEmisora extends StatelessWidget {
|
||||
imageUrl: emisora.favicon!,
|
||||
fit: BoxFit.cover,
|
||||
placeholder: (_, __) => _shimmer(theme),
|
||||
errorWidget: (_, __, ___) => _iconoFallback(theme),
|
||||
errorWidget: (_, __, ___) => _iconoFallback(),
|
||||
);
|
||||
}
|
||||
return _iconoFallback(theme);
|
||||
return _iconoFallback();
|
||||
}
|
||||
|
||||
Widget _shimmer(ThemeData theme) => shimmer.Shimmer.fromColors(
|
||||
@@ -246,12 +247,9 @@ class _ArteFilaEmisora extends StatelessWidget {
|
||||
child: Container(color: theme.colorScheme.surfaceContainerHighest),
|
||||
);
|
||||
|
||||
Widget _iconoFallback(ThemeData theme) => Container(
|
||||
color: theme.colorScheme.primaryContainer,
|
||||
child: Icon(
|
||||
Icons.radio_rounded,
|
||||
size: 22,
|
||||
color: theme.colorScheme.onPrimaryContainer,
|
||||
),
|
||||
);
|
||||
// Issue 6 (feedback-pruebas): was a flat `primaryContainer` square with a
|
||||
// bare `radio_rounded` icon — now the same shared fallback every other
|
||||
// surface uses.
|
||||
Widget _iconoFallback() =>
|
||||
PluriStationArtFallback(seed: emisora.uuid, iconSize: 22);
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import '../modelos/emisora.dart';
|
||||
import '../pantallas/pantalla_reproductor.dart';
|
||||
import '../servicios/servicio_audio.dart';
|
||||
import '../tema/pluriwave_theme.dart';
|
||||
import 'pluri_station_art_fallback.dart';
|
||||
|
||||
/// Barra inferior persistente con controles básicos de reproducción.
|
||||
/// Toca la barra para abrir PantallaReproductor completa.
|
||||
@@ -318,9 +319,9 @@ class _ArteMiniReproductor extends StatelessWidget {
|
||||
imageUrl: emisora.favicon!,
|
||||
fit: BoxFit.cover,
|
||||
placeholder: (_, __) => _shimmer(theme),
|
||||
errorWidget: (_, __, ___) => _iconoFallback(theme),
|
||||
errorWidget: (_, __, ___) => _iconoFallback(),
|
||||
)
|
||||
: _iconoFallback(theme),
|
||||
: _iconoFallback(),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -331,12 +332,9 @@ class _ArteMiniReproductor extends StatelessWidget {
|
||||
child: Container(color: theme.colorScheme.surfaceContainerHighest),
|
||||
);
|
||||
|
||||
Widget _iconoFallback(ThemeData theme) => Container(
|
||||
color: theme.colorScheme.primaryContainer,
|
||||
child: Icon(
|
||||
Icons.radio_rounded,
|
||||
size: 20,
|
||||
color: theme.colorScheme.onPrimaryContainer,
|
||||
),
|
||||
);
|
||||
// Issue 6 (feedback-pruebas): was a flat `primaryContainer` square with a
|
||||
// bare `radio_rounded` icon — now the same shared fallback every other
|
||||
// surface uses.
|
||||
Widget _iconoFallback() =>
|
||||
PluriStationArtFallback(seed: emisora.uuid, iconSize: 20);
|
||||
}
|
||||
|
||||
@@ -192,7 +192,10 @@ class PluriBottomNavigation extends StatelessWidget {
|
||||
/// the balloon's opaque fill already covers the seam where the bar's own
|
||||
/// shadow would otherwise show through.
|
||||
List<BoxShadow> get _shellShadows => [
|
||||
BoxShadow(color: Colors.white.withValues(alpha: 0.17), offset: const Offset(0, -1.5)),
|
||||
BoxShadow(
|
||||
color: Colors.white.withValues(alpha: 0.17),
|
||||
offset: const Offset(0, -1.5),
|
||||
),
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: 0.5),
|
||||
offset: const Offset(0, 14),
|
||||
@@ -214,6 +217,7 @@ class _PluriNavButton extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final t = context.pluriTokens;
|
||||
final motion = context.pluriMotion;
|
||||
return Semantics(
|
||||
button: true,
|
||||
selected: selected,
|
||||
@@ -222,6 +226,11 @@ class _PluriNavButton extends StatelessWidget {
|
||||
type: MaterialType.transparency,
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
// Without a shape the ink splash and hover highlight paint as a
|
||||
// full-bleed RECTANGLE over the cell — a hard square sitting on
|
||||
// top of the icon, which is what users reported. The whole bar is
|
||||
// built on 999-radius pills; the splash has to follow.
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
child: Align(
|
||||
alignment: Alignment.bottomCenter,
|
||||
// t4/4a spec: the items row itself is 52px tall — this inner
|
||||
@@ -238,54 +247,75 @@ class _PluriNavButton extends StatelessWidget {
|
||||
// Flutter joins merged labels with `\n`, so screen readers
|
||||
// would announce "Alarmas\nAlarmas" instead of "Alarmas".
|
||||
child: ExcludeSemantics(
|
||||
child: Transform.translate(
|
||||
// t4/4a spec: active item lift `translateY(-15px)`.
|
||||
offset: Offset(0, selected ? -15 : 0),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Opacity(
|
||||
// t4/4a spec: active icon full colour; inactive
|
||||
// `rgba(242,247,250,.46)` — .46 applied here as
|
||||
// uniform opacity dims both the fallback Icon
|
||||
// (already `onSurface` from
|
||||
// PluriIconVariant.filled) and the real raster
|
||||
// badge asset identically.
|
||||
opacity: selected ? 1 : 0.46,
|
||||
child: PluriIcon(
|
||||
glyph: item.glyph,
|
||||
variant: PluriIconVariant.filled,
|
||||
// t4/4a spec: icon `font-size:25px`/`23px`.
|
||||
size: selected ? 25 : 23,
|
||||
color: selected ? t.electricMagenta : null,
|
||||
// Same ARB string the outer Semantics already
|
||||
// uses — passing it explicitly skips
|
||||
// PluriIcon's own AppLocalizations.of lookup
|
||||
// (excluded from the tree above regardless).
|
||||
semanticLabel: item.label,
|
||||
),
|
||||
),
|
||||
if (selected) ...[
|
||||
const SizedBox(height: 3),
|
||||
Text(
|
||||
item.label,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
// t4/4a spec: label `font-size:11px;
|
||||
// font-weight:800; line-height:1.25`, brand
|
||||
// colour.
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.labelSmall?.copyWith(
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w800,
|
||||
height: 1.25,
|
||||
letterSpacing: 0,
|
||||
color: t.electricMagenta,
|
||||
// t4/4a spec: active item lift `translateY(-15px)`. Every
|
||||
// property here used to change INSTANTLY while the balloon
|
||||
// behind it slid with AnimatedPositioned — the balloon
|
||||
// glided and its contents teleported, which read as a
|
||||
// broken transition. All four now share the balloon's own
|
||||
// duration and curve so the whole tab moves as one.
|
||||
child: AnimatedSlide(
|
||||
duration: motion.normal,
|
||||
curve: Curves.easeOutCubic,
|
||||
// Slide is expressed in fractions of the child's size;
|
||||
// the icon column is ~40 tall, so -15px is about -0.375.
|
||||
offset: Offset(0, selected ? -0.375 : 0),
|
||||
child: AnimatedSize(
|
||||
duration: motion.normal,
|
||||
curve: Curves.easeOutCubic,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
AnimatedOpacity(
|
||||
duration: motion.normal,
|
||||
curve: Curves.easeOutCubic,
|
||||
// t4/4a spec: active icon full colour; inactive
|
||||
// `rgba(242,247,250,.46)` — .46 applied here as
|
||||
// uniform opacity dims both the fallback Icon
|
||||
// (already `onSurface` from
|
||||
// PluriIconVariant.filled) and the real raster
|
||||
// badge asset identically.
|
||||
opacity: selected ? 1 : 0.46,
|
||||
child: TweenAnimationBuilder<double>(
|
||||
duration: motion.normal,
|
||||
curve: Curves.easeOutCubic,
|
||||
tween: Tween<double>(end: selected ? 25 : 23),
|
||||
builder:
|
||||
(context, size, _) => PluriIcon(
|
||||
glyph: item.glyph,
|
||||
variant: PluriIconVariant.filled,
|
||||
// t4/4a spec: `font-size:25px`/`23px`.
|
||||
size: size,
|
||||
color: selected ? t.electricMagenta : null,
|
||||
// Same ARB string the outer Semantics
|
||||
// already uses — passing it explicitly
|
||||
// skips PluriIcon's own
|
||||
// AppLocalizations.of lookup.
|
||||
semanticLabel: item.label,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (selected) ...[
|
||||
const SizedBox(height: 3),
|
||||
Text(
|
||||
item.label,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
// t4/4a spec: label `font-size:11px;
|
||||
// font-weight:800; line-height:1.25`, brand
|
||||
// colour.
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.labelSmall?.copyWith(
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w800,
|
||||
height: 1.25,
|
||||
letterSpacing: 0,
|
||||
color: t.electricMagenta,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -14,6 +14,13 @@ import 'pluri_layout.dart';
|
||||
void showPluriSleepTimerSheet(BuildContext context) {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
// Issue 2 (feedback-pruebas): without this, the sheet is capped to a
|
||||
// FRACTION of the screen height. That never mattered while the sheet
|
||||
// always closed immediately after picking a duration (see the removed
|
||||
// `Navigator.pop` calls below) -- now that the countdown view actually
|
||||
// stays open, its title + description + headline-sized remaining-time
|
||||
// text can overflow that capped height on a real phone width.
|
||||
isScrollControlled: true,
|
||||
showDragHandle: true,
|
||||
builder:
|
||||
(ctx) => Consumer<EstadoRadio>(
|
||||
@@ -79,12 +86,17 @@ void showPluriSleepTimerSheet(BuildContext context) {
|
||||
Duration(seconds: segundos),
|
||||
),
|
||||
),
|
||||
onPressed: () {
|
||||
estado.iniciarTimerDuracion(
|
||||
Duration(seconds: segundos),
|
||||
);
|
||||
Navigator.pop(ctx);
|
||||
},
|
||||
// Issue 2 (feedback-pruebas): no longer pops
|
||||
// the sheet -- `estado.iniciarTimerDuracion`
|
||||
// notifies this `Consumer<EstadoRadio>`,
|
||||
// which swaps straight to the countdown
|
||||
// view above so the user actually SEES the
|
||||
// remaining time instead of the sheet just
|
||||
// closing with no feedback.
|
||||
onPressed:
|
||||
() => estado.iniciarTimerDuracion(
|
||||
Duration(seconds: segundos),
|
||||
),
|
||||
),
|
||||
ActionChip(
|
||||
avatar: const Icon(Icons.tune_rounded, size: 18),
|
||||
@@ -93,8 +105,9 @@ void showPluriSleepTimerSheet(BuildContext context) {
|
||||
final duracion =
|
||||
await _pedirDuracionPersonalizada(ctx);
|
||||
if (duracion == null || !ctx.mounted) return;
|
||||
// Issue 2: same as above -- stays open on
|
||||
// the countdown view rather than closing.
|
||||
estado.iniciarTimerDuracion(duracion);
|
||||
Navigator.pop(ctx);
|
||||
},
|
||||
),
|
||||
],
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../l10n/gen/app_localizations.dart';
|
||||
import '../tema/pluriwave_theme.dart';
|
||||
import 'pluri_icon.dart';
|
||||
|
||||
/// Shared station-art fallback (feedback-pruebas issue 6).
|
||||
///
|
||||
/// Before this widget existed, `TarjetaEmisora` had the only good fallback —
|
||||
/// a deterministic pick from 4 bundled illustrations (`_fallbackArtFor`),
|
||||
/// with a gradient + player glyph as a last resort if the asset itself ever
|
||||
/// fails to decode. Every OTHER surface that can render a station without
|
||||
/// artwork (`FilaEmisoraPlana`'s flat rows, the Escuchar hero, the "Tus
|
||||
/// emisoras" grid cell, the mini player, the full player) had its own,
|
||||
/// separate, much poorer copy: a flat `primaryContainer`-coloured square
|
||||
/// with a bare `radio_rounded` icon. This widget is the ONE shared
|
||||
/// implementation every one of those call sites now uses instead.
|
||||
///
|
||||
/// The selection formula is pinned EXACTLY as `TarjetaEmisora` originally
|
||||
/// had it (`seed.codeUnits.fold<int>(0, (a, b) => a + b) % 4`, asset order
|
||||
/// aurora/cosmic/pulse/nova) — `lib/servicios/navegacion_auto.dart` (a
|
||||
/// protected, empty-diff file) independently mirrors this SAME formula for
|
||||
/// Android Auto's own drawable-resource rotation
|
||||
/// (`test/servicios/navegacion_auto_test.dart`'s `indiceArtePara`/
|
||||
/// `artUriPara` tests assert it inline, not by importing this widget) —
|
||||
/// changing the order or the modulo here would silently desync the two
|
||||
/// without either test suite noticing until an actual device compared them
|
||||
/// side by side.
|
||||
class PluriStationArtFallback extends StatelessWidget {
|
||||
const PluriStationArtFallback({
|
||||
super.key,
|
||||
required this.seed,
|
||||
this.iconSize = 22,
|
||||
});
|
||||
|
||||
/// Typically the station's `uuid` — the seed that deterministically picks
|
||||
/// one of the 4 bundled arts below.
|
||||
final String seed;
|
||||
|
||||
/// Size of the centred player glyph drawn over the art.
|
||||
final double iconSize;
|
||||
|
||||
static const _arts = [
|
||||
'assets/images/station_art_aurora.png',
|
||||
'assets/images/station_art_cosmic.png',
|
||||
'assets/images/station_art_pulse.png',
|
||||
'assets/images/station_art_nova.png',
|
||||
];
|
||||
|
||||
/// Exposed so other call sites (and tests) can assert which asset a given
|
||||
/// seed resolves to without needing to render the widget.
|
||||
static String artFor(String seed) {
|
||||
final index = seed.codeUnits.fold<int>(0, (a, b) => a + b) % _arts.length;
|
||||
return _arts[index];
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
Image.asset(
|
||||
artFor(seed),
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder:
|
||||
(_, __, ___) => DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
colors: [
|
||||
context.pluriTokens.deepViolet,
|
||||
context.pluriTokens.electricMagenta.withValues(
|
||||
alpha: 0.8,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Center(
|
||||
child: PluriIcon(
|
||||
glyph: PluriIconGlyph.player,
|
||||
variant: PluriIconVariant.activeGlow,
|
||||
size: iconSize,
|
||||
semanticLabel: AppLocalizations.of(context).stationIconLabel,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import '../modelos/emisora.dart';
|
||||
import '../tema/pluriwave_theme.dart';
|
||||
import 'pluri_glass_surface.dart';
|
||||
import 'pluri_icon.dart';
|
||||
import 'pluri_station_art_fallback.dart';
|
||||
|
||||
/// Tarjeta compacta para mostrar una emisora en listas y grids.
|
||||
/// Incluye botón de favorito visible en ambos modos.
|
||||
@@ -302,51 +303,12 @@ class _TarjetaEmisoraState extends State<TarjetaEmisora> {
|
||||
);
|
||||
}
|
||||
|
||||
// Issue 6 (feedback-pruebas): extracted into the shared
|
||||
// `PluriStationArtFallback` so every surface that can render a station
|
||||
// without artwork shows the SAME fallback — see that widget's own doc
|
||||
// comment for why the selection formula must never drift.
|
||||
Widget _iconoFallback(double size) {
|
||||
final art = _fallbackArtFor(widget.emisora.uuid);
|
||||
return Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
Image.asset(
|
||||
art,
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder:
|
||||
(_, __, ___) => DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
colors: [
|
||||
context.pluriTokens.deepViolet,
|
||||
context.pluriTokens.electricMagenta.withValues(
|
||||
alpha: 0.8,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Center(
|
||||
child: PluriIcon(
|
||||
glyph: PluriIconGlyph.player,
|
||||
variant: PluriIconVariant.activeGlow,
|
||||
size: size,
|
||||
semanticLabel: AppLocalizations.of(context).stationIconLabel,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
String _fallbackArtFor(String seed) {
|
||||
const arts = [
|
||||
'assets/images/station_art_aurora.png',
|
||||
'assets/images/station_art_cosmic.png',
|
||||
'assets/images/station_art_pulse.png',
|
||||
'assets/images/station_art_nova.png',
|
||||
];
|
||||
final index = seed.codeUnits.fold<int>(0, (a, b) => a + b) % arts.length;
|
||||
return arts[index];
|
||||
return PluriStationArtFallback(seed: widget.emisora.uuid, iconSize: size);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
name: pluriwave
|
||||
description: "Radio mundial con ecualizador, reconocimiento de canciones y UI premium"
|
||||
publish_to: 'none'
|
||||
version: 1.2.0+122
|
||||
version: 1.2.7+129
|
||||
|
||||
environment:
|
||||
sdk: ^3.7.0
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
},
|
||||
);
|
||||
}
|
||||
+414
-229
@@ -412,259 +412,444 @@ void main() {
|
||||
);
|
||||
});
|
||||
|
||||
group(
|
||||
'EstadoRadio — emisoras custom: lectura tolerante y guardia de '
|
||||
'degradacion (persistence-resilience)',
|
||||
() {
|
||||
test(
|
||||
'entradas invalidas se omiten sin perder las validas ni fabricar '
|
||||
'uuid (D5 parcial)',
|
||||
() async {
|
||||
final archivo = await _crearArchivoCustomRaw(
|
||||
jsonEncode([
|
||||
{'uuid': 'custom-1', 'nombre': 'Valida Uno', 'url': 'http://a'},
|
||||
{'uuid': 'custom-2', 'nombre': 'Valida Dos', 'url': 'http://b'},
|
||||
// falta 'url' (campo requerido) -> Emisora.fromMap lanza.
|
||||
{'uuid': 'custom-3', 'nombre': 'Sin url'},
|
||||
// falta 'uuid' -> Emisora.fromMap lanza.
|
||||
{'nombre': 'Sin uuid', 'url': 'http://d'},
|
||||
]),
|
||||
);
|
||||
final estado = EstadoRadio(
|
||||
audio: FakeServicioAudio(),
|
||||
favoritos: FakeServicioFavoritos(),
|
||||
radio: FakeServicioRadio(),
|
||||
servicioEcualizador: FakeServicioEcualizador(),
|
||||
resolverArchivoCustom: () async => archivo,
|
||||
iniciarAutomaticamente: false,
|
||||
);
|
||||
|
||||
await estado.inicializar();
|
||||
|
||||
expect(estado.emisorasCustom, hasLength(2));
|
||||
expect(estado.emisorasCustom.map((e) => e.uuid).toSet(), {
|
||||
'custom-1',
|
||||
'custom-2',
|
||||
});
|
||||
},
|
||||
group('EstadoRadio — emisoras custom: lectura tolerante y guardia de '
|
||||
'degradacion (persistence-resilience)', () {
|
||||
test('entradas invalidas se omiten sin perder las validas ni fabricar '
|
||||
'uuid (D5 parcial)', () async {
|
||||
final archivo = await _crearArchivoCustomRaw(
|
||||
jsonEncode([
|
||||
{'uuid': 'custom-1', 'nombre': 'Valida Uno', 'url': 'http://a'},
|
||||
{'uuid': 'custom-2', 'nombre': 'Valida Dos', 'url': 'http://b'},
|
||||
// falta 'url' (campo requerido) -> Emisora.fromMap lanza.
|
||||
{'uuid': 'custom-3', 'nombre': 'Sin url'},
|
||||
// falta 'uuid' -> Emisora.fromMap lanza.
|
||||
{'nombre': 'Sin uuid', 'url': 'http://d'},
|
||||
]),
|
||||
);
|
||||
final estado = EstadoRadio(
|
||||
audio: FakeServicioAudio(),
|
||||
favoritos: FakeServicioFavoritos(),
|
||||
radio: FakeServicioRadio(),
|
||||
servicioEcualizador: FakeServicioEcualizador(),
|
||||
resolverArchivoCustom: () async => archivo,
|
||||
iniciarAutomaticamente: false,
|
||||
);
|
||||
|
||||
test(
|
||||
'si resolver la ruta del archivo custom falla, la inicializacion '
|
||||
'sobrevive y las cargas hermanas no se pierden (D5 IO-fail)',
|
||||
() async {
|
||||
final estado = EstadoRadio(
|
||||
audio: FakeServicioAudio(),
|
||||
favoritos: FakeServicioFavoritos(),
|
||||
radio: FakeServicioRadio(),
|
||||
servicioEcualizador: FakeServicioEcualizador(),
|
||||
resolverArchivoCustom:
|
||||
() async => throw const FileSystemException('sin storage'),
|
||||
iniciarAutomaticamente: false,
|
||||
);
|
||||
await estado.inicializar();
|
||||
|
||||
// Path resolution failing must be treated as an IO-fail, not
|
||||
// escape _cargarEmisorasCustom: it runs inside _init()'s
|
||||
// Future.wait, so an uncaught throw would also reject the
|
||||
// sibling loads (populares/favoritos/grupos).
|
||||
await estado.inicializar();
|
||||
expect(estado.emisorasCustom, hasLength(2));
|
||||
expect(estado.emisorasCustom.map((e) => e.uuid).toSet(), {
|
||||
'custom-1',
|
||||
'custom-2',
|
||||
});
|
||||
});
|
||||
|
||||
expect(estado.emisorasCustom, isEmpty);
|
||||
},
|
||||
test('si resolver la ruta del archivo custom falla, la inicializacion '
|
||||
'sobrevive y las cargas hermanas no se pierden (D5 IO-fail)', () async {
|
||||
final estado = EstadoRadio(
|
||||
audio: FakeServicioAudio(),
|
||||
favoritos: FakeServicioFavoritos(),
|
||||
radio: FakeServicioRadio(),
|
||||
servicioEcualizador: FakeServicioEcualizador(),
|
||||
resolverArchivoCustom:
|
||||
() async => throw const FileSystemException('sin storage'),
|
||||
iniciarAutomaticamente: false,
|
||||
);
|
||||
|
||||
test(
|
||||
'JSON invalido al nivel superior pone en cuarentena el archivo '
|
||||
'original (D5 parse-fail)',
|
||||
() async {
|
||||
final archivo = await _crearArchivoCustomRaw('{bad');
|
||||
final estado = EstadoRadio(
|
||||
audio: FakeServicioAudio(),
|
||||
favoritos: FakeServicioFavoritos(),
|
||||
radio: FakeServicioRadio(),
|
||||
servicioEcualizador: FakeServicioEcualizador(),
|
||||
resolverArchivoCustom: () async => archivo,
|
||||
iniciarAutomaticamente: false,
|
||||
);
|
||||
// Path resolution failing must be treated as an IO-fail, not
|
||||
// escape _cargarEmisorasCustom: it runs inside _init()'s
|
||||
// Future.wait, so an uncaught throw would also reject the
|
||||
// sibling loads (populares/favoritos/grupos).
|
||||
await estado.inicializar();
|
||||
|
||||
await estado.inicializar();
|
||||
expect(estado.emisorasCustom, isEmpty);
|
||||
});
|
||||
|
||||
expect(estado.emisorasCustom, isEmpty);
|
||||
final sidecar = File('${archivo.path}.corrupt');
|
||||
expect(await sidecar.exists(), isTrue);
|
||||
expect(await sidecar.readAsString(), '{bad');
|
||||
expect(await archivo.exists(), isFalse);
|
||||
},
|
||||
test('JSON invalido al nivel superior pone en cuarentena el archivo '
|
||||
'original (D5 parse-fail)', () async {
|
||||
final archivo = await _crearArchivoCustomRaw('{bad');
|
||||
final estado = EstadoRadio(
|
||||
audio: FakeServicioAudio(),
|
||||
favoritos: FakeServicioFavoritos(),
|
||||
radio: FakeServicioRadio(),
|
||||
servicioEcualizador: FakeServicioEcualizador(),
|
||||
resolverArchivoCustom: () async => archivo,
|
||||
iniciarAutomaticamente: false,
|
||||
);
|
||||
|
||||
test(
|
||||
'agregar tras la cuarentena escribe solo la nueva emisora y no '
|
||||
'toca el sidecar (D5, autoridad de escritura restaurada)',
|
||||
() async {
|
||||
final archivo = await _crearArchivoCustomRaw('{bad');
|
||||
final estado = EstadoRadio(
|
||||
audio: FakeServicioAudio(),
|
||||
favoritos: FakeServicioFavoritos(),
|
||||
radio: FakeServicioRadio(),
|
||||
servicioEcualizador: FakeServicioEcualizador(),
|
||||
resolverArchivoCustom: () async => archivo,
|
||||
iniciarAutomaticamente: false,
|
||||
);
|
||||
await estado.inicializar();
|
||||
final sidecar = File('${archivo.path}.corrupt');
|
||||
final sidecarPrevio = await sidecar.readAsString();
|
||||
await estado.inicializar();
|
||||
|
||||
final nueva = emisoraDemo(uuid: 'nueva-1', nombre: 'Nueva');
|
||||
await estado.agregarEmisoraCustom(nueva);
|
||||
expect(estado.emisorasCustom, isEmpty);
|
||||
final sidecar = File('${archivo.path}.corrupt');
|
||||
expect(await sidecar.exists(), isTrue);
|
||||
expect(await sidecar.readAsString(), '{bad');
|
||||
expect(await archivo.exists(), isFalse);
|
||||
});
|
||||
|
||||
expect(estado.emisorasCustom.map((e) => e.uuid), ['nueva-1']);
|
||||
final contenidoVivo =
|
||||
jsonDecode(await archivo.readAsString()) as List;
|
||||
expect(contenidoVivo, hasLength(1));
|
||||
expect((contenidoVivo.single as Map)['uuid'], 'nueva-1');
|
||||
expect(await sidecar.readAsString(), sidecarPrevio);
|
||||
},
|
||||
test('agregar tras la cuarentena escribe solo la nueva emisora y no '
|
||||
'toca el sidecar (D5, autoridad de escritura restaurada)', () async {
|
||||
final archivo = await _crearArchivoCustomRaw('{bad');
|
||||
final estado = EstadoRadio(
|
||||
audio: FakeServicioAudio(),
|
||||
favoritos: FakeServicioFavoritos(),
|
||||
radio: FakeServicioRadio(),
|
||||
servicioEcualizador: FakeServicioEcualizador(),
|
||||
resolverArchivoCustom: () async => archivo,
|
||||
iniciarAutomaticamente: false,
|
||||
);
|
||||
await estado.inicializar();
|
||||
final sidecar = File('${archivo.path}.corrupt');
|
||||
final sidecarPrevio = await sidecar.readAsString();
|
||||
|
||||
final nueva = emisoraDemo(uuid: 'nueva-1', nombre: 'Nueva');
|
||||
await estado.agregarEmisoraCustom(nueva);
|
||||
|
||||
expect(estado.emisorasCustom.map((e) => e.uuid), ['nueva-1']);
|
||||
final contenidoVivo = jsonDecode(await archivo.readAsString()) as List;
|
||||
expect(contenidoVivo, hasLength(1));
|
||||
expect((contenidoVivo.single as Map)['uuid'], 'nueva-1');
|
||||
expect(await sidecar.readAsString(), sidecarPrevio);
|
||||
});
|
||||
|
||||
test('fallo de IO al leer suprime la escritura y no se restaura con un '
|
||||
'alta explicita (D5 IO-fail)', () async {
|
||||
final espia = _ArchivoEspia(
|
||||
path: '/fake/emisoras_custom.json',
|
||||
exists: () async => true,
|
||||
readAsString:
|
||||
() async =>
|
||||
throw const FileSystemException('fallo simulado de lectura'),
|
||||
);
|
||||
final estado = EstadoRadio(
|
||||
audio: FakeServicioAudio(),
|
||||
favoritos: FakeServicioFavoritos(),
|
||||
radio: FakeServicioRadio(),
|
||||
servicioEcualizador: FakeServicioEcualizador(),
|
||||
resolverArchivoCustom: () async => espia,
|
||||
iniciarAutomaticamente: false,
|
||||
);
|
||||
|
||||
test(
|
||||
'fallo de IO al leer suprime la escritura y no se restaura con un '
|
||||
'alta explicita (D5 IO-fail)',
|
||||
() async {
|
||||
final espia = _ArchivoEspia(
|
||||
path: '/fake/emisoras_custom.json',
|
||||
exists: () async => true,
|
||||
readAsString:
|
||||
() async => throw const FileSystemException(
|
||||
'fallo simulado de lectura',
|
||||
),
|
||||
);
|
||||
final estado = EstadoRadio(
|
||||
audio: FakeServicioAudio(),
|
||||
favoritos: FakeServicioFavoritos(),
|
||||
radio: FakeServicioRadio(),
|
||||
servicioEcualizador: FakeServicioEcualizador(),
|
||||
resolverArchivoCustom: () async => espia,
|
||||
iniciarAutomaticamente: false,
|
||||
);
|
||||
await estado.inicializar();
|
||||
expect(estado.emisorasCustom, isEmpty);
|
||||
|
||||
await estado.inicializar();
|
||||
expect(estado.emisorasCustom, isEmpty);
|
||||
|
||||
await estado.agregarEmisoraCustom(
|
||||
emisoraDemo(uuid: 'nueva-x', nombre: 'X'),
|
||||
);
|
||||
|
||||
expect(
|
||||
estado.emisorasCustom.map((e) => e.uuid),
|
||||
contains('nueva-x'),
|
||||
);
|
||||
expect(espia.writeAsStringCalls, 0);
|
||||
},
|
||||
await estado.agregarEmisoraCustom(
|
||||
emisoraDemo(uuid: 'nueva-x', nombre: 'X'),
|
||||
);
|
||||
|
||||
test(
|
||||
'si ya existe un sidecar .corrupt no lo pisa, solo limpia el '
|
||||
'archivo vivo (D5)',
|
||||
() async {
|
||||
final archivo = await _crearArchivoCustomRaw('{bad-nuevo');
|
||||
final sidecar = File('${archivo.path}.corrupt');
|
||||
await sidecar.writeAsString('contenido-previo-X');
|
||||
expect(estado.emisorasCustom.map((e) => e.uuid), contains('nueva-x'));
|
||||
expect(espia.writeAsStringCalls, 0);
|
||||
});
|
||||
|
||||
final estado = EstadoRadio(
|
||||
audio: FakeServicioAudio(),
|
||||
favoritos: FakeServicioFavoritos(),
|
||||
radio: FakeServicioRadio(),
|
||||
servicioEcualizador: FakeServicioEcualizador(),
|
||||
resolverArchivoCustom: () async => archivo,
|
||||
iniciarAutomaticamente: false,
|
||||
);
|
||||
test('si ya existe un sidecar .corrupt no lo pisa, solo limpia el '
|
||||
'archivo vivo (D5)', () async {
|
||||
final archivo = await _crearArchivoCustomRaw('{bad-nuevo');
|
||||
final sidecar = File('${archivo.path}.corrupt');
|
||||
await sidecar.writeAsString('contenido-previo-X');
|
||||
|
||||
await estado.inicializar();
|
||||
|
||||
expect(await sidecar.readAsString(), 'contenido-previo-X');
|
||||
expect(await archivo.exists(), isFalse);
|
||||
expect(estado.emisorasCustom, isEmpty);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
group(
|
||||
'EstadoRadio — Android Auto: snapshot en vivo y reconciliación '
|
||||
'(android-auto-media)',
|
||||
() {
|
||||
test(
|
||||
'empuja un snapshot actualizado a la fuente registrada cuando '
|
||||
'cambian favoritos/custom/populares',
|
||||
() async {
|
||||
final fuenteAuto = _FuenteEmisorasAutoEspia();
|
||||
final archivo = await _crearArchivoCustom([
|
||||
emisoraDemo(uuid: 'custom-auto-1', nombre: 'Custom Auto'),
|
||||
]);
|
||||
final emisoraFav = emisoraDemo(uuid: 'fav-auto-1', nombre: 'Fav Auto');
|
||||
final estado = EstadoRadio(
|
||||
audio: FakeServicioAudio(),
|
||||
favoritos: FakeServicioFavoritos(),
|
||||
radio: FakeServicioRadio(
|
||||
populares: [emisoraDemo(uuid: 'pop-auto-1', nombre: 'Pop Auto')],
|
||||
),
|
||||
servicioEcualizador: FakeServicioEcualizador(),
|
||||
resolverArchivoCustom: () async => archivo,
|
||||
fuenteAuto: fuenteAuto,
|
||||
iniciarAutomaticamente: false,
|
||||
);
|
||||
|
||||
await estado.inicializar();
|
||||
|
||||
expect(
|
||||
fuenteAuto.ultimoMisEmisoras?.map((e) => e.uuid),
|
||||
contains('custom-auto-1'),
|
||||
);
|
||||
expect(
|
||||
fuenteAuto.ultimoTodas?.map((e) => e.uuid),
|
||||
contains('pop-auto-1'),
|
||||
);
|
||||
expect(
|
||||
fuenteAuto.ultimoGrupos?.map((g) => g.id),
|
||||
contains(GrupoFavoritos.sinAsignarId),
|
||||
);
|
||||
|
||||
await estado.toggleFavorito(emisoraFav);
|
||||
|
||||
expect(
|
||||
fuenteAuto.ultimoFavoritos?.map((e) => e.uuid),
|
||||
contains('fav-auto-1'),
|
||||
);
|
||||
},
|
||||
final estado = EstadoRadio(
|
||||
audio: FakeServicioAudio(),
|
||||
favoritos: FakeServicioFavoritos(),
|
||||
radio: FakeServicioRadio(),
|
||||
servicioEcualizador: FakeServicioEcualizador(),
|
||||
resolverArchivoCustom: () async => archivo,
|
||||
iniciarAutomaticamente: false,
|
||||
);
|
||||
|
||||
test(
|
||||
'reconcilia _emisoraSeleccionada cuando la selección viene desde '
|
||||
'el auto (no via reproducir())',
|
||||
() async {
|
||||
final audio = _AudioControlado();
|
||||
final estado = EstadoRadio(
|
||||
audio: audio,
|
||||
favoritos: FakeServicioFavoritos(),
|
||||
radio: FakeServicioRadio(),
|
||||
servicioEcualizador: FakeServicioEcualizador(),
|
||||
resolverArchivoCustom: _archivoCustomVacio,
|
||||
iniciarAutomaticamente: false,
|
||||
);
|
||||
await estado.inicializar();
|
||||
final desdeCoche = emisoraDemo(
|
||||
uuid: 'auto-selected',
|
||||
nombre: 'Desde el auto',
|
||||
);
|
||||
await estado.inicializar();
|
||||
|
||||
audio.seleccionarDesdeAuto(desdeCoche);
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
expect(await sidecar.readAsString(), 'contenido-previo-X');
|
||||
expect(await archivo.exists(), isFalse);
|
||||
expect(estado.emisorasCustom, isEmpty);
|
||||
});
|
||||
});
|
||||
|
||||
expect(estado.emisoraActual?.uuid, desdeCoche.uuid);
|
||||
},
|
||||
group('EstadoRadio — Android Auto: snapshot en vivo y reconciliación '
|
||||
'(android-auto-media)', () {
|
||||
test('empuja un snapshot actualizado a la fuente registrada cuando '
|
||||
'cambian favoritos/custom/populares', () async {
|
||||
final fuenteAuto = _FuenteEmisorasAutoEspia();
|
||||
final archivo = await _crearArchivoCustom([
|
||||
emisoraDemo(uuid: 'custom-auto-1', nombre: 'Custom Auto'),
|
||||
]);
|
||||
final emisoraFav = emisoraDemo(uuid: 'fav-auto-1', nombre: 'Fav Auto');
|
||||
final estado = EstadoRadio(
|
||||
audio: FakeServicioAudio(),
|
||||
favoritos: FakeServicioFavoritos(),
|
||||
radio: FakeServicioRadio(
|
||||
populares: [emisoraDemo(uuid: 'pop-auto-1', nombre: 'Pop Auto')],
|
||||
),
|
||||
servicioEcualizador: FakeServicioEcualizador(),
|
||||
resolverArchivoCustom: () async => archivo,
|
||||
fuenteAuto: fuenteAuto,
|
||||
iniciarAutomaticamente: false,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
await estado.inicializar();
|
||||
|
||||
expect(
|
||||
fuenteAuto.ultimoMisEmisoras?.map((e) => e.uuid),
|
||||
contains('custom-auto-1'),
|
||||
);
|
||||
expect(
|
||||
fuenteAuto.ultimoTodas?.map((e) => e.uuid),
|
||||
contains('pop-auto-1'),
|
||||
);
|
||||
expect(
|
||||
fuenteAuto.ultimoGrupos?.map((g) => g.id),
|
||||
contains(GrupoFavoritos.sinAsignarId),
|
||||
);
|
||||
|
||||
await estado.toggleFavorito(emisoraFav);
|
||||
|
||||
expect(
|
||||
fuenteAuto.ultimoFavoritos?.map((e) => e.uuid),
|
||||
contains('fav-auto-1'),
|
||||
);
|
||||
});
|
||||
|
||||
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();
|
||||
final estado = EstadoRadio(
|
||||
audio: audio,
|
||||
favoritos: FakeServicioFavoritos(),
|
||||
radio: FakeServicioRadio(),
|
||||
servicioEcualizador: FakeServicioEcualizador(),
|
||||
resolverArchivoCustom: _archivoCustomVacio,
|
||||
iniciarAutomaticamente: false,
|
||||
);
|
||||
await estado.inicializar();
|
||||
final desdeCoche = emisoraDemo(
|
||||
uuid: 'auto-selected',
|
||||
nombre: 'Desde el auto',
|
||||
);
|
||||
|
||||
audio.seleccionarDesdeAuto(desdeCoche);
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
|
||||
expect(estado.emisoraActual?.uuid, desdeCoche.uuid);
|
||||
});
|
||||
});
|
||||
|
||||
group('EstadoRadio — última emisora reproducida (feedback-pruebas #4)', () {
|
||||
test('la última emisora reproducida sobrevive a una nueva instancia '
|
||||
'(reinicio) como emisoraActual DETENIDA -- no arranca audio, no '
|
||||
'reproduce, sólo queda seleccionada', () async {
|
||||
final emisora = emisoraDemo(uuid: 'last-1', nombre: 'Ultima FM');
|
||||
final estadoUno = EstadoRadio(
|
||||
audio: FakeServicioAudio(),
|
||||
favoritos: FakeServicioFavoritos(),
|
||||
radio: FakeServicioRadio(),
|
||||
servicioEcualizador: FakeServicioEcualizador(),
|
||||
resolverArchivoCustom: _archivoCustomVacio,
|
||||
iniciarAutomaticamente: false,
|
||||
);
|
||||
await estadoUno.inicializar();
|
||||
await estadoUno.reproducir(emisora);
|
||||
await estadoUno.detenerReproduccion();
|
||||
// Lets the fire-and-forget persistence write settle before
|
||||
// spinning up the "restart" instance.
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
|
||||
final audioDos = FakeServicioAudio();
|
||||
final estadoDos = EstadoRadio(
|
||||
audio: audioDos,
|
||||
favoritos: FakeServicioFavoritos(),
|
||||
radio: FakeServicioRadio(),
|
||||
servicioEcualizador: FakeServicioEcualizador(),
|
||||
resolverArchivoCustom: _archivoCustomVacio,
|
||||
iniciarAutomaticamente: false,
|
||||
);
|
||||
|
||||
await estadoDos.inicializar();
|
||||
|
||||
expect(estadoDos.emisoraActual?.uuid, emisora.uuid);
|
||||
expect(estadoDos.emisoraActual?.nombre, emisora.nombre);
|
||||
expect(
|
||||
audioDos.estaSonando,
|
||||
isFalse,
|
||||
reason: 'restoring the last station must never auto-start audio',
|
||||
);
|
||||
});
|
||||
|
||||
test('sin ninguna emisora previamente reproducida, emisoraActual sigue '
|
||||
'siendo null tras inicializar (instalación nueva)', () async {
|
||||
final estado = EstadoRadio(
|
||||
audio: FakeServicioAudio(),
|
||||
favoritos: FakeServicioFavoritos(),
|
||||
radio: FakeServicioRadio(),
|
||||
servicioEcualizador: FakeServicioEcualizador(),
|
||||
resolverArchivoCustom: _archivoCustomVacio,
|
||||
iniciarAutomaticamente: false,
|
||||
);
|
||||
|
||||
await estado.inicializar();
|
||||
|
||||
expect(estado.emisoraActual, isNull);
|
||||
});
|
||||
|
||||
test('una emisora seleccionada desde el auto (fuera de reproducir()) '
|
||||
'también se recuerda para la próxima instancia', () async {
|
||||
final audio = _AudioControlado();
|
||||
final estadoUno = EstadoRadio(
|
||||
audio: audio,
|
||||
favoritos: FakeServicioFavoritos(),
|
||||
radio: FakeServicioRadio(),
|
||||
servicioEcualizador: FakeServicioEcualizador(),
|
||||
resolverArchivoCustom: _archivoCustomVacio,
|
||||
iniciarAutomaticamente: false,
|
||||
);
|
||||
await estadoUno.inicializar();
|
||||
final desdeCoche = emisoraDemo(
|
||||
uuid: 'auto-remembered',
|
||||
nombre: 'Recordada desde el auto',
|
||||
);
|
||||
audio.seleccionarDesdeAuto(desdeCoche);
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
|
||||
final estadoDos = EstadoRadio(
|
||||
audio: FakeServicioAudio(),
|
||||
favoritos: FakeServicioFavoritos(),
|
||||
radio: FakeServicioRadio(),
|
||||
servicioEcualizador: FakeServicioEcualizador(),
|
||||
resolverArchivoCustom: _archivoCustomVacio,
|
||||
iniciarAutomaticamente: false,
|
||||
);
|
||||
await estadoDos.inicializar();
|
||||
|
||||
expect(estadoDos.emisoraActual?.uuid, desdeCoche.uuid);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/// Spy [FuenteEmisorasAuto] that only records the last snapshot pushed by
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
@@ -98,6 +98,75 @@ void main() {
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets(
|
||||
'issue 5 (feedback-pruebas): a long trailing value does not squeeze the '
|
||||
'title into wrapping across multiple lines -- the title stays on ONE '
|
||||
'line, ellipsizing instead',
|
||||
(tester) async {
|
||||
const titulo = 'Emisora preferida';
|
||||
const valorLargo = 'Radio Nacional Clasica Internacional FM Stereo HD';
|
||||
|
||||
await tester.pumpWidget(
|
||||
host(
|
||||
SizedBox(
|
||||
width: 360,
|
||||
child: FilaAjuste(
|
||||
icon: Icons.radio_rounded,
|
||||
titulo: titulo,
|
||||
valor: valorLargo,
|
||||
onTap: () {},
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pump();
|
||||
|
||||
expect(
|
||||
tester.takeException(),
|
||||
isNull,
|
||||
reason: 'a squeezed row must not overflow either',
|
||||
);
|
||||
|
||||
final tituloWidget = tester.widget<Text>(find.text(titulo));
|
||||
expect(
|
||||
tituloWidget.maxLines,
|
||||
1,
|
||||
reason: 'issue 5: the title must be constrained to a single line',
|
||||
);
|
||||
expect(tituloWidget.overflow, TextOverflow.ellipsis);
|
||||
|
||||
// Measured, not just `find.text` (a wrapped-but-still-present Text
|
||||
// would still satisfy a bare `find.text` match, per the known
|
||||
// "find.text can't catch visual wrap" trap) -- compare the rendered
|
||||
// height against the SAME style/width rendered with a title that is
|
||||
// guaranteed to fit on one line.
|
||||
final alturaConValorLargo = tester.getSize(find.text(titulo)).height;
|
||||
|
||||
await tester.pumpWidget(
|
||||
host(
|
||||
SizedBox(
|
||||
width: 360,
|
||||
child: FilaAjuste(
|
||||
icon: Icons.radio_rounded,
|
||||
titulo: titulo,
|
||||
onTap: () {},
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pump();
|
||||
final alturaReferencia = tester.getSize(find.text(titulo)).height;
|
||||
|
||||
expect(
|
||||
alturaConValorLargo,
|
||||
closeTo(alturaReferencia, 1.0),
|
||||
reason:
|
||||
'the title rendered taller with a long value present -- it '
|
||||
'wrapped instead of staying on a single ellipsized line',
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets(
|
||||
'visual fidelity (audit S10): GrupoAjustes insets its row divider by '
|
||||
'47px, not full-bleed (t4 line 516)',
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -15,6 +15,7 @@ import 'package:pluriwave/servicios/servicio_audio.dart';
|
||||
import 'package:pluriwave/tema/pluriwave_theme.dart';
|
||||
import 'package:pluriwave/tema/pluriwave_tokens.dart';
|
||||
import 'package:pluriwave/widgets/fila_emisora_plana.dart';
|
||||
import 'package:pluriwave/widgets/pluri_layout.dart';
|
||||
import 'package:pluriwave/widgets/tarjeta_emisora.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
@@ -835,6 +836,78 @@ void main() {
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('issue 3 (feedback-pruebas): the results list uses row-tier '
|
||||
'horizontal padding (12), not the card-tier constant this "flat, '
|
||||
'background-less row" was documented as needing but never got', (
|
||||
tester,
|
||||
) async {
|
||||
_setLargeSurfaceSize(tester);
|
||||
final estado = _crearEstado(
|
||||
radio: FakeServicioRadio(
|
||||
busqueda: [emisoraDemo(uuid: 'r-1', nombre: 'Radio Uno')],
|
||||
),
|
||||
);
|
||||
addTearDown(estado.dispose);
|
||||
await tester.runAsync(estado.inicializar);
|
||||
|
||||
await tester.pumpWidget(_conProviders(estado, _testApp()));
|
||||
await _pumpStableFrame(tester);
|
||||
await tester.enterText(find.byType(SearchBar), 'radio');
|
||||
await tester.testTextInput.receiveAction(TextInputAction.done);
|
||||
await _pumpStableFrame(tester);
|
||||
|
||||
final fila = find.byType(FilaEmisoraPlana);
|
||||
expect(
|
||||
tester.getTopLeft(fila).dx,
|
||||
PluriLayout.rowHorizontal,
|
||||
reason:
|
||||
'issue 3: background-less rows are row tier (12), not card '
|
||||
'tier (16)',
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('issue 3 (feedback-pruebas): the results list is topped by the '
|
||||
'standard section gap, not the horizontal-inset constant reused for '
|
||||
'a vertical axis', (tester) async {
|
||||
_setLargeSurfaceSize(tester);
|
||||
final estado = _crearEstado(
|
||||
radio: FakeServicioRadio(
|
||||
busqueda: [emisoraDemo(uuid: 'r-1', nombre: 'Radio Uno')],
|
||||
),
|
||||
);
|
||||
addTearDown(estado.dispose);
|
||||
await tester.runAsync(estado.inicializar);
|
||||
|
||||
await tester.pumpWidget(_conProviders(estado, _testApp()));
|
||||
await _pumpStableFrame(tester);
|
||||
await tester.enterText(find.byType(SearchBar), 'radio');
|
||||
await tester.testTextInput.receiveAction(TextInputAction.done);
|
||||
await _pumpStableFrame(tester);
|
||||
|
||||
// Reads the structural padding directly, rather than measuring a
|
||||
// gap between two rendered widgets — the count row's own height is
|
||||
// dictated by its taller PopupMenuButton (48dp touch target), so a
|
||||
// position-based gap measurement against the count TEXT specifically
|
||||
// would be thrown off by that unrelated vertical centring.
|
||||
//
|
||||
// Scoped to `shrinkWrap: true` — the OUTER page ListView is ALSO an
|
||||
// ancestor of every `FilaEmisoraPlana`, but only `_resultados`'s OWN
|
||||
// inner `ListView.builder` sets `shrinkWrap`.
|
||||
final listaResultados = tester.widget<ListView>(
|
||||
find.byWidgetPredicate((w) => w is ListView && w.shrinkWrap),
|
||||
);
|
||||
final padding = listaResultados.padding as EdgeInsets;
|
||||
|
||||
expect(
|
||||
padding.top,
|
||||
PluriLayout.sectionGap,
|
||||
reason:
|
||||
'issue 3: the results list must use the dedicated vertical '
|
||||
'section gap above its first row, not the horizontal (16) '
|
||||
'constant reused for a vertical axis',
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets(
|
||||
'tapping the favourite toggle on a search result adds it to favorites',
|
||||
(tester) async {
|
||||
|
||||
@@ -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';
|
||||
@@ -472,6 +474,24 @@ void main() {
|
||||
expect(find.text('Move to list'), findsOneWidget);
|
||||
expect(find.text('Remove from favorites'), findsOneWidget);
|
||||
expect(find.byType(PopupMenuItem<String>), findsNWidgets(2));
|
||||
|
||||
// Regression guard for a real user-reported bug: the button carried
|
||||
// `constraints: BoxConstraints.tightFor(width: 38, height: 42)`,
|
||||
// which sizes the POPUP MENU rather than the button. Every item was
|
||||
// clipped to its first letter — users saw "M" and "E", not the
|
||||
// labels. The three assertions above all PASSED throughout, because
|
||||
// find.text matches a Text widget in the tree whether or not it is
|
||||
// visually clipped. Only measuring the laid-out width catches it.
|
||||
final anchoItem = tester.getSize(
|
||||
find.byType(PopupMenuItem<String>).first,
|
||||
);
|
||||
expect(
|
||||
anchoItem.width,
|
||||
greaterThan(100),
|
||||
reason:
|
||||
'a menu item narrower than its label means the popup is being '
|
||||
'constrained and the text is clipped',
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -528,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([
|
||||
|
||||
@@ -11,6 +11,7 @@ import 'package:pluriwave/l10n/gen/app_localizations.dart';
|
||||
import 'package:pluriwave/modelos/emisora.dart';
|
||||
import 'package:pluriwave/pantallas/pantalla_inicio.dart';
|
||||
import 'package:pluriwave/widgets/pluri_root_header.dart';
|
||||
import 'package:pluriwave/widgets/pluri_station_art_fallback.dart';
|
||||
import 'package:pluriwave/widgets/visualizador_audio.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
@@ -698,6 +699,76 @@ void main() {
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets('issue 6 (feedback-pruebas): the Escuchar hero shows the shared '
|
||||
'PluriStationArtFallback for a station with no favicon, not a flat '
|
||||
'coloured square', (tester) async {
|
||||
_setLargeSurfaceSize(tester);
|
||||
final favoritos = FakeServicioFavoritos();
|
||||
final estado = EstadoRadio(
|
||||
audio: FakeServicioAudio(),
|
||||
favoritos: favoritos,
|
||||
radio: FakeServicioRadio(),
|
||||
servicioEcualizador: FakeServicioEcualizador(),
|
||||
servicioGrabacion: FakeServicioGrabacionRadio(),
|
||||
resolverArchivoCustom: _archivoCustomVacio,
|
||||
iniciarAutomaticamente: false,
|
||||
);
|
||||
addTearDown(estado.dispose);
|
||||
await tester.runAsync(estado.inicializar);
|
||||
final sonando = emisoraDemo(uuid: 'sin-arte', nombre: 'Sin Arte');
|
||||
await tester.runAsync(() => estado.reproducir(sonando));
|
||||
|
||||
await tester.pumpWidget(
|
||||
_conProviders(estado, _testApp(const PantallaInicio())),
|
||||
);
|
||||
await _pumpBounded(tester);
|
||||
|
||||
expect(
|
||||
find.byType(PluriStationArtFallback),
|
||||
findsWidgets,
|
||||
reason:
|
||||
'issue 6: the hero must reach the shared fallback, not its own '
|
||||
'flat primaryContainer square',
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets(
|
||||
'issue 6 (feedback-pruebas): a "Tus emisoras" grid cell shows the shared '
|
||||
'PluriStationArtFallback for a favourite with no favicon',
|
||||
(tester) async {
|
||||
_setLargeSurfaceSize(tester);
|
||||
final favoritos = FakeServicioFavoritos();
|
||||
final estado = EstadoRadio(
|
||||
audio: FakeServicioAudio(),
|
||||
favoritos: favoritos,
|
||||
radio: FakeServicioRadio(),
|
||||
servicioEcualizador: FakeServicioEcualizador(),
|
||||
servicioGrabacion: FakeServicioGrabacionRadio(),
|
||||
resolverArchivoCustom: _archivoCustomVacio,
|
||||
iniciarAutomaticamente: false,
|
||||
);
|
||||
addTearDown(estado.dispose);
|
||||
await tester.runAsync(estado.inicializar);
|
||||
await favoritos.agregar(
|
||||
emisoraDemo(uuid: 'grid-sin-arte', nombre: 'Grid Sin Arte'),
|
||||
);
|
||||
await estado.cargarFavoritos();
|
||||
|
||||
await tester.pumpWidget(
|
||||
_conProviders(estado, _testApp(const PantallaInicio())),
|
||||
);
|
||||
await _pumpStableFrame(tester);
|
||||
|
||||
expect(
|
||||
find.byType(PluriStationArtFallback),
|
||||
findsWidgets,
|
||||
reason:
|
||||
'issue 6: the grid cell must reach the shared fallback, not its '
|
||||
'own flat primaryContainer square',
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Mirrors the app.dart wiring: EstadoRadio owns the domain notifiers and
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -12,6 +12,7 @@ import 'package:pluriwave/pantallas/pantalla_reproductor.dart';
|
||||
import 'package:pluriwave/tema/pluriwave_tokens.dart';
|
||||
import 'package:pluriwave/widgets/ecualizador_widget.dart';
|
||||
import 'package:pluriwave/widgets/pluri_glass_surface.dart';
|
||||
import 'package:pluriwave/widgets/pluri_station_art_fallback.dart';
|
||||
import 'package:pluriwave/widgets/visualizador_audio.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
@@ -704,4 +705,30 @@ void main() {
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
group('issue 6 (feedback-pruebas): shared station-art fallback', () {
|
||||
testWidgets(
|
||||
'a station with no favicon shows the shared PluriStationArtFallback, '
|
||||
'not a flat coloured square',
|
||||
(tester) async {
|
||||
// `emisora` (the file-level fixture) never sets a favicon.
|
||||
final estado = crearEstado();
|
||||
addTearDown(estado.dispose);
|
||||
|
||||
await montarPantalla(tester, estado);
|
||||
|
||||
final arte = find.byKey(const Key('player-hero-art'));
|
||||
expect(
|
||||
find.descendant(
|
||||
of: arte,
|
||||
matching: find.byType(PluriStationArtFallback),
|
||||
),
|
||||
findsOneWidget,
|
||||
reason:
|
||||
'issue 6: the full player must reach the shared fallback, '
|
||||
'not its own flat primaryContainer square',
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -445,4 +445,313 @@ void main() {
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
group('issue 1 (feedback-pruebas): editar y eliminar rangos', () {
|
||||
testWidgets(
|
||||
'tocar la tarjeta de un rango programado abre el editor precargado '
|
||||
'con su nombre y fechas',
|
||||
(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);
|
||||
|
||||
expect(find.text(l10n.editVacationRangeTitle), findsOneWidget);
|
||||
// Scoped to the TextField specifically -- the original card's OWN
|
||||
// "Verano" label is still (offstage, behind the modal) in the tree,
|
||||
// so a bare `find.text('Verano')` would ambiguously match both.
|
||||
expect(find.widgetWithText(TextField, 'Verano'), findsOneWidget);
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets(
|
||||
'guardar el editor abierto por tap actualiza el rango existente (no '
|
||||
'crea uno nuevo)',
|
||||
(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.enterText(find.byType(TextField), 'Verano renombrado');
|
||||
final boton = tester.widget<FilledButton>(
|
||||
find.widgetWithText(FilledButton, l10n.saveRangeAction),
|
||||
);
|
||||
boton.onPressed!();
|
||||
await _pumpEstable(tester);
|
||||
|
||||
expect(estado.vacaciones, hasLength(1));
|
||||
expect(estado.vacaciones.single.id, 'f2');
|
||||
expect(estado.vacaciones.single.nombre, 'Verano renombrado');
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets(
|
||||
'deslizar la tarjeta de un rango pide confirmacion; cancelar la '
|
||||
'conserva y confirmar la elimina',
|
||||
(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)),
|
||||
);
|
||||
|
||||
// Cancelar: el rango se conserva.
|
||||
await tester.drag(
|
||||
find.byKey(const ValueKey('vacaciones-tarjeta-f2')),
|
||||
const Offset(-600, 0),
|
||||
);
|
||||
await _pumpEstable(tester);
|
||||
expect(find.text(l10n.vacationDeleteConfirmTitle), findsOneWidget);
|
||||
await tester.tap(find.text(l10n.cancelAction));
|
||||
await _pumpEstable(tester);
|
||||
expect(estado.vacaciones, hasLength(1));
|
||||
|
||||
// Confirmar: el rango se elimina.
|
||||
await tester.drag(
|
||||
find.byKey(const ValueKey('vacaciones-tarjeta-f2')),
|
||||
const Offset(-600, 0),
|
||||
);
|
||||
await _pumpEstable(tester);
|
||||
expect(find.text(l10n.vacationDeleteConfirmTitle), findsOneWidget);
|
||||
await tester.tap(find.text(l10n.deleteAction));
|
||||
await _pumpEstable(tester);
|
||||
|
||||
expect(estado.vacaciones, isEmpty);
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets(
|
||||
'el rango ACTIVO (mostrado en el hero) tambien se puede editar (tap) '
|
||||
'y eliminar (swipe) -- un rango recien creado siempre esta activo y '
|
||||
'nunca aparece en las listas programado/pasado',
|
||||
(tester) async {
|
||||
final estado = await _crearEstado(
|
||||
vacaciones: [
|
||||
RangoVacaciones(
|
||||
id: 'v1',
|
||||
nombre: 'Julio activo',
|
||||
inicio: _hoyDia.subtract(const Duration(days: 3)),
|
||||
fin: _hoyDia.add(const Duration(days: 5)),
|
||||
),
|
||||
],
|
||||
);
|
||||
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-v1')));
|
||||
await _pumpEstable(tester);
|
||||
expect(find.text(l10n.editVacationRangeTitle), findsOneWidget);
|
||||
// Scoped to the TextField specifically -- the hero's OWN "Julio
|
||||
// activo" label is still (offstage, behind the modal) in the tree.
|
||||
expect(find.widgetWithText(TextField, 'Julio activo'), findsOneWidget);
|
||||
// Dismiss the editor sheet (no explicit close button -- same as the
|
||||
// pre-existing "Anadir rango" sheet, dismissible via the standard
|
||||
// modal-bottom-sheet Navigator.pop) before interacting with the
|
||||
// list underneath it.
|
||||
Navigator.of(tester.element(find.byType(PantallaVacaciones))).pop();
|
||||
await _pumpEstable(tester);
|
||||
|
||||
await tester.drag(
|
||||
find.byKey(const ValueKey('vacaciones-tarjeta-v1')),
|
||||
const Offset(-600, 0),
|
||||
);
|
||||
await _pumpEstable(tester);
|
||||
await tester.tap(find.text(l10n.deleteAction));
|
||||
await _pumpEstable(tester);
|
||||
|
||||
expect(estado.vacaciones, isEmpty);
|
||||
expect(find.text(l10n.vacationNoActiveRangeHint), findsOneWidget);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
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',
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
+2387
-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,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',
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
import 'dart:ui' show Locale;
|
||||
|
||||
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/navegacion_auto.dart';
|
||||
import 'package:pluriwave/servicios/servicio_audio.dart';
|
||||
|
||||
/// Decision `auto/ecualizador-diseno` -- the "Ecualizador" browsable
|
||||
/// folder's item-building (`itemsEcualizadorAuto`), the pure,
|
||||
/// `AppLocalizations`-dependent half of the folder feature. Lives in a
|
||||
/// dedicated file, separate from `servicio_audio_eq_custom_actions_test.dart`
|
||||
/// (which covers the now-playing screen's on/off toggle) because this is a
|
||||
/// different car UI surface: a browsable folder, not a custom action.
|
||||
void main() {
|
||||
final l10n = lookupAppLocalizations(const Locale('es'));
|
||||
|
||||
group('itemsEcualizadorAuto (decision `auto/ecualizador-diseno`)', () {
|
||||
test('devuelve exactamente 7 items: Desactivar primero, luego los 6 '
|
||||
'presets de fábrica, todos playable', () {
|
||||
final items = itemsEcualizadorAuto(
|
||||
activo: true,
|
||||
presetActual: PresetEcualizador.flat,
|
||||
l10n: l10n,
|
||||
);
|
||||
|
||||
expect(items, hasLength(7));
|
||||
expect(items.first.id, ConstructorArbolAuto.idDesactivarEq);
|
||||
for (final item in items) {
|
||||
expect(item.playable, isTrue);
|
||||
}
|
||||
});
|
||||
|
||||
test('los 6 presets aparecen en el mismo orden que '
|
||||
'PresetEcualizador.presets, cada uno con su id eq_preset:<nombre>', () {
|
||||
final items = itemsEcualizadorAuto(
|
||||
activo: true,
|
||||
presetActual: PresetEcualizador.flat,
|
||||
l10n: l10n,
|
||||
);
|
||||
final builder = ConstructorArbolAuto();
|
||||
|
||||
final idsPresets = items.skip(1).map((i) => i.id).toList();
|
||||
final idsEsperados =
|
||||
PresetEcualizador.presets
|
||||
.map((p) => builder.idPresetEq(p.nombre))
|
||||
.toList();
|
||||
|
||||
expect(idsPresets, idsEsperados);
|
||||
});
|
||||
|
||||
test('los nombres de preset están localizados vía nombrePresetVisible, '
|
||||
'no crudos', () {
|
||||
final items = itemsEcualizadorAuto(
|
||||
activo: true,
|
||||
presetActual: PresetEcualizador.flat,
|
||||
l10n: l10n,
|
||||
);
|
||||
final builder = ConstructorArbolAuto();
|
||||
|
||||
final rock = items.firstWhere(
|
||||
(i) => i.id == builder.idPresetEq(PresetEcualizador.rock.nombre),
|
||||
);
|
||||
|
||||
expect(rock.title, contains(nombrePresetVisible(l10n, 'Rock')));
|
||||
});
|
||||
|
||||
test('con el ecualizador ACTIVO, el preset actual queda marcado y '
|
||||
'Desactivar NO', () {
|
||||
final items = itemsEcualizadorAuto(
|
||||
activo: true,
|
||||
presetActual: PresetEcualizador.jazz,
|
||||
l10n: l10n,
|
||||
);
|
||||
final builder = ConstructorArbolAuto();
|
||||
|
||||
final desactivar = items.firstWhere(
|
||||
(i) => i.id == ConstructorArbolAuto.idDesactivarEq,
|
||||
);
|
||||
final jazz = items.firstWhere(
|
||||
(i) => i.id == builder.idPresetEq(PresetEcualizador.jazz.nombre),
|
||||
);
|
||||
final marcados = items.where((i) => i.title.contains('✓')).toList();
|
||||
|
||||
expect(desactivar.title, isNot(contains('✓')));
|
||||
expect(jazz.title, contains('✓'));
|
||||
expect(marcados, hasLength(1));
|
||||
expect(marcados.single.id, jazz.id);
|
||||
});
|
||||
|
||||
test('con el ecualizador DESACTIVADO, Desactivar queda marcado y NINGÚN '
|
||||
'preset lo está', () {
|
||||
final items = itemsEcualizadorAuto(
|
||||
activo: false,
|
||||
presetActual: PresetEcualizador.rock,
|
||||
l10n: l10n,
|
||||
);
|
||||
|
||||
final desactivar = items.firstWhere(
|
||||
(i) => i.id == ConstructorArbolAuto.idDesactivarEq,
|
||||
);
|
||||
final marcados = items.where((i) => i.title.contains('✓')).toList();
|
||||
|
||||
expect(desactivar.title, contains('✓'));
|
||||
expect(marcados, hasLength(1));
|
||||
expect(marcados.single.id, ConstructorArbolAuto.idDesactivarEq);
|
||||
});
|
||||
|
||||
test('el label de Desactivar usa l10n.autoEqDisableOption', () {
|
||||
final items = itemsEcualizadorAuto(
|
||||
activo: false,
|
||||
presetActual: PresetEcualizador.flat,
|
||||
l10n: l10n,
|
||||
);
|
||||
|
||||
final desactivar = items.firstWhere(
|
||||
(i) => i.id == ConstructorArbolAuto.idDesactivarEq,
|
||||
);
|
||||
|
||||
expect(desactivar.title, contains(l10n.autoEqDisableOption));
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
import 'package:audio_session/audio_session.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:pluriwave/servicios/servicio_audio.dart';
|
||||
import 'package:pluriwave/servicios/servicio_audio_session.dart';
|
||||
|
||||
/// EQ audio-focus re-apply — pure decision predicate truth table.
|
||||
///
|
||||
@@ -96,4 +98,192 @@ void main() {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ── EQ re-apply after a SHORT audio-focus interruption ──────────────────
|
||||
// debeReaplicarEcualizador only fires on a session-id CHANGE. A short
|
||||
// transient interruption (a nav-app voice prompt) keeps the SAME player
|
||||
// session id, so that trigger never fires and the equalizer stays
|
||||
// silently disabled after Android lets another app's AudioEffect steal
|
||||
// control. Fix: re-assert the equalizer on resume-from-pause and on
|
||||
// un-duck too, via a new no-arg ObjetivoAudioInterrumpible.reaplicarEcualizador()
|
||||
// that the handler implements as a thin delegate to the existing
|
||||
// _activarEcualizador() (setEnabled + band gains, already correct).
|
||||
//
|
||||
// ServicioAudioSession is the orchestration layer under test here (the
|
||||
// same layer servicio_audio_session_test.dart already covers) -- it is
|
||||
// fully unit-testable, unlike PluriWaveAudioHandler itself.
|
||||
group(
|
||||
'ServicioAudioSession re-applies the equalizer on interruption resume '
|
||||
'(no session-id change involved)',
|
||||
() {
|
||||
test(
|
||||
'a pause-interruption cycle (begin -> end/resume) calls '
|
||||
'reaplicarEcualizador exactly once, AFTER reanudar()',
|
||||
() async {
|
||||
final objetivo = _ObjetivoFake()
|
||||
..reproduciendo = true
|
||||
..intencion = true;
|
||||
final servicio = ServicioAudioSession(objetivo: objetivo);
|
||||
|
||||
await servicio.manejarInterrupcion(
|
||||
AudioInterruptionEvent(true, AudioInterruptionType.pause),
|
||||
);
|
||||
await servicio.manejarInterrupcion(
|
||||
AudioInterruptionEvent(false, AudioInterruptionType.pause),
|
||||
);
|
||||
|
||||
expect(objetivo.reaplicaciones, 1);
|
||||
expect(
|
||||
objetivo.eventos,
|
||||
['pausar', 'reanudar', 'reaplicar'],
|
||||
reason:
|
||||
'the re-apply must happen on RESUME, after reanudar() -- '
|
||||
'never before, never on the begin/pause side',
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'a duck cycle (begin -> end/un-duck) calls reaplicarEcualizador '
|
||||
'exactly once, AFTER setAtenuado(false)',
|
||||
() async {
|
||||
final objetivo = _ObjetivoFake()
|
||||
..reproduciendo = true
|
||||
..intencion = true;
|
||||
final servicio = ServicioAudioSession(objetivo: objetivo);
|
||||
|
||||
await servicio.manejarInterrupcion(
|
||||
AudioInterruptionEvent(true, AudioInterruptionType.duck),
|
||||
);
|
||||
await servicio.manejarInterrupcion(
|
||||
AudioInterruptionEvent(false, AudioInterruptionType.duck),
|
||||
);
|
||||
|
||||
expect(objetivo.reaplicaciones, 1);
|
||||
expect(
|
||||
objetivo.eventos,
|
||||
['atenuado:true', 'atenuado:false', 'reaplicar'],
|
||||
reason:
|
||||
'the re-apply must happen on UN-DUCK, after '
|
||||
'setAtenuado(false)',
|
||||
);
|
||||
expect(objetivo.pausas, 0, reason: 'a duck never pauses');
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'with the equalizer switched OFF by the user, an interruption '
|
||||
'cycle still only calls the SAME parameterless reassert -- '
|
||||
'ServicioAudioSession has no way to force it on',
|
||||
() async {
|
||||
final objetivo = _ObjetivoFake()
|
||||
..reproduciendo = true
|
||||
..intencion = true
|
||||
..eqActivo = false;
|
||||
final servicio = ServicioAudioSession(objetivo: objetivo);
|
||||
|
||||
await servicio.manejarInterrupcion(
|
||||
AudioInterruptionEvent(true, AudioInterruptionType.pause),
|
||||
);
|
||||
await servicio.manejarInterrupcion(
|
||||
AudioInterruptionEvent(false, AudioInterruptionType.pause),
|
||||
);
|
||||
|
||||
expect(objetivo.reaplicaciones, 1);
|
||||
expect(
|
||||
objetivo.estadosReaplicados,
|
||||
[false],
|
||||
reason:
|
||||
'reaplicarEcualizador takes no boolean argument -- it can '
|
||||
'only ask the handler to reassert whatever state it '
|
||||
'ALREADY holds, never flip it on',
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'end without a prior begin/pause never calls reaplicarEcualizador '
|
||||
'(mirrors "end sin pausa previa" -- no resume happened)',
|
||||
() async {
|
||||
final objetivo = _ObjetivoFake();
|
||||
final servicio = ServicioAudioSession(objetivo: objetivo);
|
||||
|
||||
await servicio.manejarInterrupcion(
|
||||
AudioInterruptionEvent(false, AudioInterruptionType.pause),
|
||||
);
|
||||
|
||||
expect(objetivo.reaplicaciones, 0);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'a permanent (unknown-type) focus loss never calls '
|
||||
'reaplicarEcualizador -- there is no resume to re-assert after',
|
||||
() async {
|
||||
final objetivo = _ObjetivoFake()
|
||||
..reproduciendo = true
|
||||
..intencion = true;
|
||||
final servicio = ServicioAudioSession(objetivo: objetivo);
|
||||
|
||||
await servicio.manejarInterrupcion(
|
||||
AudioInterruptionEvent(true, AudioInterruptionType.unknown),
|
||||
);
|
||||
await servicio.manejarInterrupcion(
|
||||
AudioInterruptionEvent(false, AudioInterruptionType.unknown),
|
||||
);
|
||||
|
||||
expect(objetivo.reaplicaciones, 0);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
class _ObjetivoFake implements ObjetivoAudioInterrumpible {
|
||||
bool intencion = false;
|
||||
bool reproduciendo = false;
|
||||
bool eqActivo = true;
|
||||
int pausas = 0;
|
||||
int reaplicaciones = 0;
|
||||
final List<bool> atenuaciones = [];
|
||||
final List<bool> estadosReaplicados = [];
|
||||
|
||||
/// Ordering log shared across every method — proves reaplicarEcualizador
|
||||
/// fires at the EXACT point in the sequence the fix requires (after
|
||||
/// reanudar()/setAtenuado(false)), not merely "at some point".
|
||||
final List<String> eventos = [];
|
||||
|
||||
@override
|
||||
bool get intencionReproducir => intencion;
|
||||
|
||||
@override
|
||||
bool get estaReproduciendo => reproduciendo;
|
||||
|
||||
@override
|
||||
Future<void> pausar() async {
|
||||
pausas++;
|
||||
reproduciendo = false;
|
||||
intencion = false;
|
||||
eventos.add('pausar');
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> reanudar() async {
|
||||
reproduciendo = true;
|
||||
intencion = true;
|
||||
eventos.add('reanudar');
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> setAtenuado(bool atenuado) async {
|
||||
atenuaciones.add(atenuado);
|
||||
eventos.add('atenuado:$atenuado');
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> reaplicarEcualizador() async {
|
||||
reaplicaciones++;
|
||||
estadosReaplicados.add(eqActivo);
|
||||
eventos.add('reaplicar');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
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/emisora.dart';
|
||||
import 'package:pluriwave/servicios/navegacion_auto.dart';
|
||||
import 'package:pluriwave/servicios/servicio_audio.dart';
|
||||
|
||||
/// Item 3 (Android Auto fallback artwork) — the pure, handler-independent
|
||||
/// half of the fix. `PluriWaveAudioHandler` cannot be instantiated in unit
|
||||
/// tests (a real `just_audio.AudioPlayer` requires platform MethodChannels,
|
||||
/// confirmed by `servicio_audio_source_switch_test.dart`), so the actual
|
||||
/// "now playing" MediaItem construction and the reverse Emisora
|
||||
/// reconstruction are extracted as pure top-level functions here, exactly
|
||||
/// like `debeReaplicarEcualizador` was extracted for the EQ re-apply fix.
|
||||
void main() {
|
||||
final l10n = lookupAppLocalizations(const Locale('es'));
|
||||
|
||||
group('mediaItemParaEmisora (item 3)', () {
|
||||
test(
|
||||
'estación SIN favicon usable: el MediaItem usa el fallback de marca '
|
||||
'(artUriPara), no queda con artUri null',
|
||||
() {
|
||||
const emisora = Emisora(
|
||||
uuid: 'uuid-sin-arte',
|
||||
nombre: 'Radio sin logo',
|
||||
url: 'https://stream.demo/sin-logo',
|
||||
);
|
||||
|
||||
final item = mediaItemParaEmisora(emisora, l10n: l10n);
|
||||
|
||||
expect(item.artUri, isNotNull);
|
||||
expect(item.artUri.toString(), artUriPara(emisora));
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'estación CON favicon http(s) usable: el MediaItem sigue usando ese '
|
||||
'favicon real, no el fallback',
|
||||
() {
|
||||
const emisora = Emisora(
|
||||
uuid: 'uuid-con-arte',
|
||||
nombre: 'Radio con logo',
|
||||
url: 'https://stream.demo/con-logo',
|
||||
favicon: 'https://cdn.example.com/logo.png',
|
||||
);
|
||||
|
||||
final item = mediaItemParaEmisora(emisora, l10n: l10n);
|
||||
|
||||
expect(item.artUri.toString(), emisora.favicon);
|
||||
},
|
||||
);
|
||||
|
||||
test('preserva id, artist y extras.uuid como antes', () {
|
||||
const emisora = Emisora(
|
||||
uuid: 'uuid-forma',
|
||||
nombre: 'Radio Forma',
|
||||
url: 'https://stream.demo/forma',
|
||||
pais: 'Argentina',
|
||||
);
|
||||
|
||||
final item = mediaItemParaEmisora(emisora, l10n: l10n);
|
||||
|
||||
expect(item.id, emisora.url);
|
||||
expect(item.artist, 'Argentina');
|
||||
expect(item.album, 'PluriWave');
|
||||
expect(item.extras?['uuid'], emisora.uuid);
|
||||
});
|
||||
});
|
||||
|
||||
group('emisoraDesdeMediaItem (item 3 — no phone-UI regression)', () {
|
||||
test(
|
||||
'artUri de marca (android.resource://…, no http) NUNCA se refleja '
|
||||
'como favicon -- evitaría un intento de red inválido en '
|
||||
'CachedNetworkImage del lado telefono',
|
||||
() {
|
||||
final mediaItem = MediaItem(
|
||||
id: 'https://stream.demo/sin-logo',
|
||||
title: 'Radio sin logo',
|
||||
artUri: Uri.parse(
|
||||
'android.resource://es.freetimelab.pluriwave/drawable/'
|
||||
'station_art_aurora',
|
||||
),
|
||||
extras: const {'uuid': 'uuid-sin-arte'},
|
||||
);
|
||||
|
||||
final emisora = emisoraDesdeMediaItem(mediaItem);
|
||||
|
||||
expect(emisora.favicon, isNull);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'artUri http(s) real SÍ se refleja como favicon (comportamiento '
|
||||
'previo preservado)',
|
||||
() {
|
||||
final mediaItem = MediaItem(
|
||||
id: 'https://stream.demo/con-logo',
|
||||
title: 'Radio con logo',
|
||||
artUri: Uri.parse('https://cdn.example.com/logo.png'),
|
||||
extras: const {'uuid': 'uuid-con-arte'},
|
||||
);
|
||||
|
||||
final emisora = emisoraDesdeMediaItem(mediaItem);
|
||||
|
||||
expect(emisora.favicon, 'https://cdn.example.com/logo.png');
|
||||
},
|
||||
);
|
||||
|
||||
test('sin artUri: favicon queda null, sin lanzar', () {
|
||||
final mediaItem = MediaItem(
|
||||
id: 'https://stream.demo/sin-arturi',
|
||||
title: 'Radio',
|
||||
extras: const {'uuid': 'uuid-x'},
|
||||
);
|
||||
|
||||
final emisora = emisoraDesdeMediaItem(mediaItem);
|
||||
|
||||
expect(emisora.favicon, isNull);
|
||||
});
|
||||
|
||||
test('preserva uuid, nombre, url y pais como antes', () {
|
||||
final mediaItem = MediaItem(
|
||||
id: 'https://stream.demo/forma',
|
||||
title: 'Radio Forma',
|
||||
artist: 'Argentina',
|
||||
extras: const {'uuid': 'uuid-forma'},
|
||||
);
|
||||
|
||||
final emisora = emisoraDesdeMediaItem(mediaItem);
|
||||
|
||||
expect(emisora.uuid, 'uuid-forma');
|
||||
expect(emisora.nombre, 'Radio Forma');
|
||||
expect(emisora.url, mediaItem.id);
|
||||
expect(emisora.pais, 'Argentina');
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -33,6 +33,13 @@ class _ObjetivoFake implements ObjetivoAudioInterrumpible {
|
||||
Future<void> setAtenuado(bool atenuado) async {
|
||||
atenuaciones.add(atenuado);
|
||||
}
|
||||
|
||||
int reaplicaciones = 0;
|
||||
|
||||
@override
|
||||
Future<void> reaplicarEcualizador() async {
|
||||
reaplicaciones++;
|
||||
}
|
||||
}
|
||||
|
||||
/// S3-R1: audio-session interruptions (phone call, transient loss, duck) and
|
||||
|
||||
@@ -75,6 +75,42 @@ void main() {
|
||||
expect(proxima, DateTime(2026, 5, 23, 9));
|
||||
});
|
||||
|
||||
test(
|
||||
'un registro de fallo de programacion NO omite esa ejecucion (solo '
|
||||
'skipNext debe hacerlo)',
|
||||
() {
|
||||
final alarma = AlarmaMusical(
|
||||
id: 'a4',
|
||||
nombre: 'Diaria',
|
||||
hora: 9,
|
||||
minuto: 0,
|
||||
tipoProgramacion: TipoProgramacionAlarma.diaria,
|
||||
diasSemana: const [],
|
||||
);
|
||||
final ocurrencia = DateTime(2026, 5, 22, 9);
|
||||
|
||||
final proxima = servicio.calcularProxima(
|
||||
alarma: alarma,
|
||||
desde: DateTime(2026, 5, 22, 8),
|
||||
excepciones: [
|
||||
ExcepcionAlarma(
|
||||
alarmaId: 'a4',
|
||||
ejecucion: ocurrencia,
|
||||
tipo: ExcepcionAlarma.tipoFalloProgramacion,
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
expect(
|
||||
proxima,
|
||||
ocurrencia,
|
||||
reason:
|
||||
'un fallo de programacion registrado no debe comportarse '
|
||||
'como un salto de usuario',
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
test('snooze solo permite 3, 5 o 10 minutos y cae a 5', () {
|
||||
expect(
|
||||
servicio.calcularSnooze(DateTime(2026, 5, 21, 7), 10),
|
||||
|
||||
@@ -4,6 +4,7 @@ import 'package:pluriwave/estado/estado_radio.dart';
|
||||
import 'package:pluriwave/l10n/gen/app_localizations.dart';
|
||||
import 'package:pluriwave/tema/pluriwave_tokens.dart';
|
||||
import 'package:pluriwave/widgets/fila_emisora_plana.dart';
|
||||
import 'package:pluriwave/widgets/pluri_station_art_fallback.dart';
|
||||
import 'package:pluriwave/widgets/tarjeta_emisora.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
@@ -77,6 +78,32 @@ void main() {
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets(
|
||||
'issue 6 (feedback-pruebas): a station with no favicon shows the shared '
|
||||
'PluriStationArtFallback, not a flat coloured square',
|
||||
(tester) async {
|
||||
final estado = _estado();
|
||||
addTearDown(estado.dispose);
|
||||
final emisora = emisoraDemo(uuid: 'sin-arte', nombre: 'Sin Arte');
|
||||
|
||||
await tester.pumpWidget(
|
||||
_host(estado, FilaEmisoraPlana(emisora: emisora, meta: '')),
|
||||
);
|
||||
await tester.pump();
|
||||
|
||||
expect(
|
||||
find.descendant(
|
||||
of: find.byKey(const ValueKey('fila-emisora-plana-arte')),
|
||||
matching: find.byType(PluriStationArtFallback),
|
||||
),
|
||||
findsOneWidget,
|
||||
reason:
|
||||
'issue 6: the flat row must reach the shared fallback, not its '
|
||||
'own flat primaryContainer square',
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets('omits the meta line entirely when empty (no stray gap)', (
|
||||
tester,
|
||||
) async {
|
||||
|
||||
@@ -5,6 +5,7 @@ import 'package:pluriwave/l10n/gen/app_localizations.dart';
|
||||
import 'package:pluriwave/tema/pluriwave_theme.dart';
|
||||
import 'package:pluriwave/tema/pluriwave_tokens.dart';
|
||||
import 'package:pluriwave/widgets/mini_reproductor.dart';
|
||||
import 'package:pluriwave/widgets/pluri_station_art_fallback.dart';
|
||||
import 'package:pluriwave/widgets/visualizador_audio.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
@@ -75,6 +76,33 @@ void main() {
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets(
|
||||
'issue 6 (feedback-pruebas): a station with no favicon shows the shared '
|
||||
'PluriStationArtFallback, not a flat coloured square',
|
||||
(tester) async {
|
||||
final estado = _estadoConEmisora();
|
||||
addTearDown(estado.dispose);
|
||||
await estado.reproducir(
|
||||
emisoraDemo(uuid: 'sin-arte', nombre: 'Sin Arte'),
|
||||
);
|
||||
|
||||
await tester.pumpWidget(_hostFor(estado));
|
||||
await tester.pump();
|
||||
|
||||
final arte = find.byKey(const ValueKey('mini-reproductor-arte'));
|
||||
expect(
|
||||
find.descendant(
|
||||
of: arte,
|
||||
matching: find.byType(PluriStationArtFallback),
|
||||
),
|
||||
findsOneWidget,
|
||||
reason:
|
||||
'issue 6: the mini player must reach the shared fallback, not '
|
||||
'its own flat primaryContainer square',
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets(
|
||||
'is opaque -- no BackdropFilter -- unlike the former glass pill',
|
||||
(tester) async {
|
||||
|
||||
@@ -96,13 +96,17 @@ void main() {
|
||||
);
|
||||
expect(inactiveIcon.color, isNull);
|
||||
|
||||
final dimmed = tester.widget<Opacity>(
|
||||
// AnimatedOpacity, not a plain Opacity: the dim now transitions with the
|
||||
// balloon instead of snapping. Users reported the bar's contents
|
||||
// teleporting while the balloon slid — the lift, dim, icon size and
|
||||
// label all animate together now.
|
||||
final dimmed = tester.widget<AnimatedOpacity>(
|
||||
find.ancestor(
|
||||
of: find.descendant(
|
||||
of: find.byKey(PluriBottomNavigation.itemKey(1)),
|
||||
matching: find.byType(PluriIcon),
|
||||
),
|
||||
matching: find.byType(Opacity),
|
||||
matching: find.byType(AnimatedOpacity),
|
||||
),
|
||||
);
|
||||
expect(dimmed.opacity, closeTo(0.46, 0.001));
|
||||
|
||||
@@ -12,12 +12,23 @@ import 'package:pluriwave/widgets/pluri_root_header.dart';
|
||||
/// sleep-timer action that used to live on `app.dart`'s single global
|
||||
/// `AppBar` stays reachable from every tab.
|
||||
void main() {
|
||||
Widget host(Widget child) {
|
||||
// [topInset] simulates `MediaQuery.paddingOf(context).top` (status bar /
|
||||
// camera cutout) — the same technique already used by
|
||||
// `pantalla_alarma_sonando_scaffold_test.dart`'s `MediaQuery` override via
|
||||
// `MaterialApp.builder`.
|
||||
Widget host(Widget child, {double topInset = 0}) {
|
||||
return MaterialApp(
|
||||
theme: PluriWaveTheme.dark(),
|
||||
locale: const Locale('en'),
|
||||
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||
supportedLocales: AppLocalizations.supportedLocales,
|
||||
builder:
|
||||
(context, app) => MediaQuery(
|
||||
data: MediaQuery.of(
|
||||
context,
|
||||
).copyWith(padding: EdgeInsets.only(top: topInset)),
|
||||
child: app!,
|
||||
),
|
||||
home: Scaffold(body: child),
|
||||
);
|
||||
}
|
||||
@@ -42,7 +53,13 @@ void main() {
|
||||
host(PluriRootHeader(title: 'Settings', onSleepTimer: () {})),
|
||||
);
|
||||
|
||||
final padding = tester.widget<Padding>(find.byType(Padding).first);
|
||||
// Keyed lookup (not `find.byType(Padding).first`): the top-inset fix
|
||||
// wraps this content padding in an outer `Padding(top: topInset)`, so
|
||||
// `.first` would no longer reliably resolve to the content row's own
|
||||
// padding.
|
||||
final padding = tester.widget<Padding>(
|
||||
find.byKey(const ValueKey('pluri-root-header-content')),
|
||||
);
|
||||
final insets = padding.padding as EdgeInsets;
|
||||
expect(insets.left, PluriLayout.titleHorizontal);
|
||||
expect(insets.right, PluriLayout.rowHorizontal);
|
||||
@@ -97,4 +114,92 @@ void main() {
|
||||
reason: 'actions render before (to the left of) the bedtime button',
|
||||
);
|
||||
});
|
||||
|
||||
group('fix top-inset: total height and content position track '
|
||||
'MediaQuery.paddingOf(context).top, instead of always sitting flush '
|
||||
'at y=0 under the status bar', () {
|
||||
// Vertical text centering inside the 56px content row means the
|
||||
// title's own top-left never sits exactly AT the header's top-left
|
||||
// (even at topInset=0) — so these tests compare each inset's title
|
||||
// position against the topInset=0 BASELINE, isolating exactly the
|
||||
// inset's own contribution instead of asserting a brittle absolute
|
||||
// offset.
|
||||
Future<double> tituloTopPara(WidgetTester tester, double topInset) async {
|
||||
await tester.pumpWidget(
|
||||
host(
|
||||
PluriRootHeader(title: 'Settings', onSleepTimer: () {}),
|
||||
topInset: topInset,
|
||||
),
|
||||
);
|
||||
return tester.getTopLeft(find.text('Settings')).dy;
|
||||
}
|
||||
|
||||
testWidgets(
|
||||
'topInset=0 (e.g. desktop/no cutout): total height stays the plain '
|
||||
'56px content height',
|
||||
(tester) async {
|
||||
await tester.pumpWidget(
|
||||
host(PluriRootHeader(title: 'Settings', onSleepTimer: () {})),
|
||||
);
|
||||
|
||||
expect(tester.getSize(find.byType(PluriRootHeader)).height, 56);
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets(
|
||||
'topInset=24 (typical status bar): total height becomes 56+24=80, '
|
||||
'and the title shifts down by exactly the inset relative to the '
|
||||
'topInset=0 baseline',
|
||||
(tester) async {
|
||||
final base = await tituloTopPara(tester, 0);
|
||||
final conInset = await tituloTopPara(tester, 24);
|
||||
|
||||
expect(tester.getSize(find.byType(PluriRootHeader)).height, 80);
|
||||
expect(conInset - base, 24);
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets(
|
||||
'topInset=44 (taller status bar): total height becomes 56+44=100, '
|
||||
'and the title still shifts down by exactly the inset — never '
|
||||
'clipped',
|
||||
(tester) async {
|
||||
final base = await tituloTopPara(tester, 0);
|
||||
final conInset = await tituloTopPara(tester, 44);
|
||||
|
||||
expect(tester.getSize(find.byType(PluriRootHeader)).height, 100);
|
||||
expect(conInset - base, 44);
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets(
|
||||
'topInset=60 (notch/camera-cutout simulation): total height becomes '
|
||||
'56+60=116, and the title still fully clears the inset — never '
|
||||
'overlapping it',
|
||||
(tester) async {
|
||||
final base = await tituloTopPara(tester, 0);
|
||||
final conInset = await tituloTopPara(tester, 60);
|
||||
|
||||
expect(tester.getSize(find.byType(PluriRootHeader)).height, 116);
|
||||
expect(conInset - base, 60);
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets(
|
||||
'PluriRootHeader.height stays the CONTENT height (56) regardless of '
|
||||
'inset — callers doing total-height math must separately add '
|
||||
'MediaQuery.paddingOf(context).top',
|
||||
(tester) async {
|
||||
await tester.pumpWidget(
|
||||
host(
|
||||
PluriRootHeader(title: 'Settings', onSleepTimer: () {}),
|
||||
topInset: 44,
|
||||
),
|
||||
);
|
||||
|
||||
expect(PluriRootHeader.height, 56);
|
||||
expect(tester.getSize(find.byType(PluriRootHeader)).height, 100);
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
import 'package:flutter/material.dart';
|
||||
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/widgets/pluri_sleep_timer_sheet.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import '../helpers/fakes.dart';
|
||||
import '../helpers/fakes_alarmas.dart';
|
||||
|
||||
/// Issue 2 (feedback-pruebas): "Timer de sueño" opened from Escuchar (and
|
||||
/// every other root header) showed a bottom sheet with no visible
|
||||
/// countdown. `ServicioTimer.tiempoRestanteStream`/`tiempoRestante` already
|
||||
/// existed and this sheet already had a `StreamBuilder` countdown branch —
|
||||
/// but every preset/custom-duration action popped the sheet immediately
|
||||
/// after starting the timer, so the countdown never had a chance to render
|
||||
/// in the primary flow. Zero test coverage existed for this file before.
|
||||
EstadoRadio _estado() => EstadoRadio(
|
||||
audio: FakeServicioAudio(),
|
||||
favoritos: FakeServicioFavoritos(),
|
||||
radio: FakeServicioRadio(),
|
||||
servicioEcualizador: FakeServicioEcualizador(),
|
||||
servicioGrabacion: FakeServicioGrabacionRadioInactiva(),
|
||||
iniciarAutomaticamente: false,
|
||||
);
|
||||
|
||||
Widget _host(EstadoRadio estado) {
|
||||
return ChangeNotifierProvider<EstadoRadio>.value(
|
||||
value: estado,
|
||||
child: MaterialApp(
|
||||
locale: const Locale('es'),
|
||||
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||
supportedLocales: AppLocalizations.supportedLocales,
|
||||
home: Scaffold(
|
||||
body: Builder(
|
||||
builder:
|
||||
(context) => TextButton(
|
||||
onPressed: () => showPluriSleepTimerSheet(context),
|
||||
child: const Text('abrir'),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void main() {
|
||||
setUp(() {
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
});
|
||||
|
||||
// The default 800x600 test viewport is shorter than a typical phone —
|
||||
// same fix `pantalla_reproductor_test.dart`/`pantalla_inicio_test.dart`
|
||||
// already established for other bottom-sheet/full-bleed content.
|
||||
void ajustarSuperficieRealista(WidgetTester tester) {
|
||||
tester.view.physicalSize = const Size(390, 844);
|
||||
tester.view.devicePixelRatio = 1.0;
|
||||
addTearDown(tester.view.resetPhysicalSize);
|
||||
addTearDown(tester.view.resetDevicePixelRatio);
|
||||
}
|
||||
|
||||
testWidgets(
|
||||
'selecting a preset keeps the sheet open and switches it to the live '
|
||||
'remaining-time countdown, instead of closing with no feedback',
|
||||
(tester) async {
|
||||
ajustarSuperficieRealista(tester);
|
||||
final estado = _estado();
|
||||
addTearDown(estado.dispose);
|
||||
|
||||
await tester.pumpWidget(_host(estado));
|
||||
await tester.tap(find.text('abrir'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final l10n = AppLocalizations.of(tester.element(find.text('abrir')));
|
||||
|
||||
expect(estado.timer.activo, isFalse);
|
||||
expect(find.byType(ActionChip), findsWidgets);
|
||||
|
||||
final chip = tester.widget<ActionChip>(
|
||||
find.widgetWithText(ActionChip, l10n.durationMinutesOnly(5)),
|
||||
);
|
||||
chip.onPressed!();
|
||||
await tester.pump();
|
||||
|
||||
expect(estado.timer.activo, isTrue);
|
||||
expect(
|
||||
find.byType(ActionChip),
|
||||
findsNothing,
|
||||
reason: 'issue 2: the picker is replaced by the countdown view',
|
||||
);
|
||||
expect(find.text(l10n.cancelTimer), findsOneWidget);
|
||||
expect(
|
||||
find.text(l10n.durationMinutesOnly(5)),
|
||||
findsOneWidget,
|
||||
reason: 'issue 2: the remaining time is now surfaced live',
|
||||
);
|
||||
|
||||
// `ServicioTimer` starts a REAL periodic Timer -- `addTearDown`
|
||||
// callbacks run too late to satisfy the "no pending timers" check
|
||||
// (a well-known flutter_test ordering quirk), so it must be
|
||||
// cancelled here, inside the test body, before it ends.
|
||||
await estado.timer.cancelar();
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets('reopening the sheet while a timer is already active shows the '
|
||||
'countdown immediately, not the duration picker', (tester) async {
|
||||
ajustarSuperficieRealista(tester);
|
||||
final estado = _estado();
|
||||
addTearDown(estado.dispose);
|
||||
estado.iniciarTimerDuracion(const Duration(minutes: 10));
|
||||
|
||||
await tester.pumpWidget(_host(estado));
|
||||
await tester.tap(find.text('abrir'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final l10n = AppLocalizations.of(tester.element(find.text('abrir')));
|
||||
expect(find.byType(ActionChip), findsNothing);
|
||||
expect(find.text(l10n.cancelTimer), findsOneWidget);
|
||||
expect(find.text(l10n.durationMinutesOnly(10)), findsOneWidget);
|
||||
|
||||
// See the comment in the previous test — cancel before the body ends.
|
||||
await estado.timer.cancelar();
|
||||
});
|
||||
|
||||
testWidgets(
|
||||
'cancelling from the countdown view stops the timer and closes the '
|
||||
'sheet',
|
||||
(tester) async {
|
||||
ajustarSuperficieRealista(tester);
|
||||
final estado = _estado();
|
||||
addTearDown(estado.dispose);
|
||||
estado.iniciarTimerDuracion(const Duration(minutes: 10));
|
||||
|
||||
await tester.pumpWidget(_host(estado));
|
||||
await tester.tap(find.text('abrir'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final l10n = AppLocalizations.of(tester.element(find.text('abrir')));
|
||||
final boton = tester.widget<FilledButton>(
|
||||
find.widgetWithText(FilledButton, l10n.cancelTimer),
|
||||
);
|
||||
boton.onPressed!();
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(estado.timer.activo, isFalse);
|
||||
expect(find.text(l10n.cancelTimer), findsNothing);
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets(
|
||||
'starting a custom duration ALSO keeps the sheet open on the countdown '
|
||||
'view, not just the presets',
|
||||
(tester) async {
|
||||
ajustarSuperficieRealista(tester);
|
||||
final estado = _estado();
|
||||
addTearDown(estado.dispose);
|
||||
|
||||
await tester.pumpWidget(_host(estado));
|
||||
await tester.tap(find.text('abrir'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final l10n = AppLocalizations.of(tester.element(find.text('abrir')));
|
||||
await tester.tap(find.text(l10n.optionOther));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Confirm the custom-duration sub-sheet directly (default prefilled
|
||||
// value is already "15" minutes -- see _TimerPersonalizadoSheetState).
|
||||
final confirmar = tester.widget<FilledButton>(
|
||||
find.widgetWithText(FilledButton, l10n.startTimer),
|
||||
);
|
||||
confirmar.onPressed!();
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(estado.timer.activo, isTrue);
|
||||
expect(find.byType(ActionChip), findsNothing);
|
||||
expect(find.text(l10n.cancelTimer), findsOneWidget);
|
||||
|
||||
// See the comment in the first test — cancel before the body ends.
|
||||
await estado.timer.cancelar();
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:pluriwave/l10n/gen/app_localizations.dart';
|
||||
import 'package:pluriwave/widgets/pluri_icon.dart';
|
||||
import 'package:pluriwave/widgets/pluri_station_art_fallback.dart';
|
||||
|
||||
/// Issue 6 (feedback-pruebas): the shared station-art fallback extracted
|
||||
/// from `TarjetaEmisora`'s original `_fallbackArtFor`. The formula and asset
|
||||
/// order are pinned exactly — `navegacion_auto_test.dart`'s
|
||||
/// `indiceArtePara`/`artUriPara` tests mirror the SAME formula independently
|
||||
/// (protected file, empty diff vs main) and must keep agreeing with this
|
||||
/// widget without either suite importing the other.
|
||||
void main() {
|
||||
Widget host(Widget child) {
|
||||
return MaterialApp(
|
||||
locale: const Locale('en'),
|
||||
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||
supportedLocales: AppLocalizations.supportedLocales,
|
||||
home: Scaffold(body: SizedBox(width: 60, height: 60, child: child)),
|
||||
);
|
||||
}
|
||||
|
||||
test('artFor reproduces the canonical aurora/cosmic/pulse/nova order '
|
||||
'(same formula as navegacion_auto.dart\'s indiceArtePara)', () {
|
||||
// Single-letter seeds whose codeUnit % 4 covers all 4 indices, mirroring
|
||||
// navegacion_auto_test.dart's own fixture exactly: 'd'(100)->0 aurora,
|
||||
// 'a'(97)->1 cosmic, 'b'(98)->2 pulse, 'c'(99)->3 nova.
|
||||
expect(
|
||||
PluriStationArtFallback.artFor('d'),
|
||||
'assets/images/station_art_aurora.png',
|
||||
);
|
||||
expect(
|
||||
PluriStationArtFallback.artFor('a'),
|
||||
'assets/images/station_art_cosmic.png',
|
||||
);
|
||||
expect(
|
||||
PluriStationArtFallback.artFor('b'),
|
||||
'assets/images/station_art_pulse.png',
|
||||
);
|
||||
expect(
|
||||
PluriStationArtFallback.artFor('c'),
|
||||
'assets/images/station_art_nova.png',
|
||||
);
|
||||
});
|
||||
|
||||
test('artFor is deterministic for a given seed (same station always picks '
|
||||
'the same art)', () {
|
||||
const seed = 'uuid-1234-abcd-real-looking';
|
||||
expect(
|
||||
PluriStationArtFallback.artFor(seed),
|
||||
PluriStationArtFallback.artFor(seed),
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets(
|
||||
'renders the deterministic station-art asset plus a centred player glyph',
|
||||
(tester) async {
|
||||
await tester.pumpWidget(
|
||||
host(const PluriStationArtFallback(seed: 'd', iconSize: 22)),
|
||||
);
|
||||
await tester.pump();
|
||||
|
||||
// Scoped by asset-name prefix, not `find.byType(Image).first` — the
|
||||
// player glyph (`PluriIcon`) ALSO renders via its own `Image.asset`
|
||||
// internally, so a bare type match would be ambiguous.
|
||||
final arte = tester
|
||||
.widgetList<Image>(find.byType(Image))
|
||||
.firstWhere(
|
||||
(img) =>
|
||||
img.image is AssetImage &&
|
||||
(img.image as AssetImage).assetName.startsWith(
|
||||
'assets/images/station_art_',
|
||||
),
|
||||
);
|
||||
expect(
|
||||
(arte.image as AssetImage).assetName,
|
||||
'assets/images/station_art_aurora.png',
|
||||
);
|
||||
|
||||
final icono = tester.widget<PluriIcon>(find.byType(PluriIcon));
|
||||
expect(icono.glyph, PluriIconGlyph.player);
|
||||
expect(icono.variant, PluriIconVariant.activeGlow);
|
||||
expect(icono.size, 22);
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
|
||||
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/widgets/pluri_station_art_fallback.dart';
|
||||
import 'package:pluriwave/widgets/tarjeta_emisora.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
@@ -73,6 +74,24 @@ void main() {
|
||||
expect(tester.getSize(thumbnailClip), const Size(48, 48));
|
||||
});
|
||||
|
||||
testWidgets(
|
||||
'issue 6 (feedback-pruebas): a station with no favicon renders via the '
|
||||
'shared PluriStationArtFallback (extracted from this exact fallback)',
|
||||
(tester) async {
|
||||
await tester.pumpWidget(
|
||||
host(
|
||||
TarjetaEmisora(
|
||||
emisora: emisoraDemo(uuid: 'sin-arte', nombre: 'Sin Arte'),
|
||||
esCompacta: true,
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pump();
|
||||
|
||||
expect(find.byType(PluriStationArtFallback), findsOneWidget);
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets('esCompacta shimmer placeholder is a square block, not a '
|
||||
'circle', (tester) async {
|
||||
await tester.pumpWidget(
|
||||
|
||||
Reference in New Issue
Block a user