Compare commits
2
Commits
71978de68f
...
4ffd73d136
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4ffd73d136 | ||
|
|
58922de6fc |
@@ -39,7 +39,8 @@ class AlarmScheduler(private val context: Context) {
|
||||
snoozeMinutes: Int = 5,
|
||||
fallbackStationName: String? = null,
|
||||
fallbackStationUrl: String? = null,
|
||||
fadeInSegundos: Int = 0
|
||||
fadeInSegundos: Int = 0,
|
||||
preNoticeTemplate: String? = null
|
||||
): Boolean {
|
||||
val existing = readSpec(id)
|
||||
val preservedSnooze = preserveNativeSnooze(
|
||||
@@ -70,7 +71,8 @@ class AlarmScheduler(private val context: Context) {
|
||||
fallbackSound = fallbackSound,
|
||||
volume = volume.coerceIn(0f, 1f),
|
||||
fadeInSegundos = fadeInSegundos.coerceIn(0, 60),
|
||||
timezoneId = TimeZone.getDefault().id
|
||||
timezoneId = TimeZone.getDefault().id,
|
||||
preNoticeTemplate = preNoticeTemplate
|
||||
)
|
||||
return scheduleSpec(spec, persistOnSuccess = true)
|
||||
}
|
||||
@@ -145,6 +147,7 @@ class AlarmScheduler(private val context: Context) {
|
||||
PluriWaveAlarmReceiver.EXTRA_OCCURRENCE_AT,
|
||||
spec.snoozeOriginMillis ?: spec.triggerAtMillis
|
||||
)
|
||||
putExtra(EXTRA_PRE_NOTICE_TEMPLATE, spec.preNoticeTemplate)
|
||||
},
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||
)
|
||||
@@ -165,6 +168,7 @@ class AlarmScheduler(private val context: Context) {
|
||||
PluriWaveAlarmReceiver.EXTRA_OCCURRENCE_AT,
|
||||
spec.snoozeOriginMillis ?: spec.triggerAtMillis
|
||||
)
|
||||
putExtra(EXTRA_PRE_NOTICE_TEMPLATE, spec.preNoticeTemplate)
|
||||
}
|
||||
)
|
||||
Log.d(tag, "alarm.schedule preNotice immediate id=${spec.id}")
|
||||
@@ -652,7 +656,10 @@ class AlarmScheduler(private val context: Context) {
|
||||
val fallbackSound: String?,
|
||||
val volume: Float,
|
||||
val fadeInSegundos: Int = 0,
|
||||
val timezoneId: String
|
||||
val timezoneId: String,
|
||||
// Nullable for backward compat: old persisted alarms without this field
|
||||
// fall back to the English default in the receiver. Schema stays v3.
|
||||
val preNoticeTemplate: String? = null
|
||||
) {
|
||||
fun toJson(): JSONObject = JSONObject().apply {
|
||||
put("schemaVersion", 3)
|
||||
@@ -679,6 +686,7 @@ class AlarmScheduler(private val context: Context) {
|
||||
put("volume", volume)
|
||||
put("fadeInSegundos", fadeInSegundos)
|
||||
put("timezoneId", timezoneId)
|
||||
put("preNoticeTemplate", preNoticeTemplate)
|
||||
}
|
||||
|
||||
companion object {
|
||||
@@ -715,7 +723,8 @@ class AlarmScheduler(private val context: Context) {
|
||||
fallbackSound = json.optString("fallbackSound").takeIf { it.isNotBlank() },
|
||||
volume = json.optDouble("volume", 0.85).toFloat(),
|
||||
fadeInSegundos = json.optInt("fadeInSegundos", 0).coerceIn(0, 60),
|
||||
timezoneId = json.optString("timezoneId", TimeZone.getDefault().id)
|
||||
timezoneId = json.optString("timezoneId", TimeZone.getDefault().id),
|
||||
preNoticeTemplate = json.optString("preNoticeTemplate").takeIf { it.isNotBlank() }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -730,6 +739,9 @@ class AlarmScheduler(private val context: Context) {
|
||||
private const val PRE_NOTICE_MILLIS = 30 * 60 * 1000L
|
||||
private const val SCHEDULE_UNICA = "unica"
|
||||
private const val SCHEDULE_DIAS_SEMANA = "diasSemana"
|
||||
// Intent extra key for the localized pre-notice template string.
|
||||
// Declared once here; PluriWaveAlarmReceiver reads it via this constant.
|
||||
const val EXTRA_PRE_NOTICE_TEMPLATE = "preNoticeTemplate"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -115,7 +115,8 @@ class MainActivity : AudioServiceActivity() {
|
||||
snoozeMinutes = call.argument<Int>("snoozeMinutes") ?: 5,
|
||||
fallbackStationName = call.argument<String>("fallbackStationName"),
|
||||
fallbackStationUrl = call.argument<String>("fallbackStationUrl"),
|
||||
fadeInSegundos = call.argument<Int>("fadeInSegundos") ?: 0
|
||||
fadeInSegundos = call.argument<Int>("fadeInSegundos") ?: 0,
|
||||
preNoticeTemplate = call.argument<String>("preNoticeTemplate")
|
||||
)
|
||||
result.success(scheduled)
|
||||
}
|
||||
|
||||
@@ -51,7 +51,8 @@ class PluriWaveAlarmReceiver : BroadcastReceiver() {
|
||||
title,
|
||||
snoozeMinutes,
|
||||
intent.getLongExtra(EXTRA_TRIGGER_AT, 0L),
|
||||
intent.getLongExtra(EXTRA_OCCURRENCE_AT, 0L)
|
||||
intent.getLongExtra(EXTRA_OCCURRENCE_AT, 0L),
|
||||
intent.getStringExtra(AlarmScheduler.EXTRA_PRE_NOTICE_TEMPLATE)
|
||||
)
|
||||
}
|
||||
ACTION_POSTPONE_NEXT -> {
|
||||
@@ -100,10 +101,14 @@ class PluriWaveAlarmReceiver : BroadcastReceiver() {
|
||||
title: String,
|
||||
snoozeMinutes: Int,
|
||||
triggerAtMillis: Long,
|
||||
occurrenceAtMillis: Long
|
||||
occurrenceAtMillis: Long,
|
||||
preNoticeTemplate: String? = null
|
||||
) {
|
||||
ensureChannel(context)
|
||||
|
||||
val remaining = computeRemainingMinutes(triggerAtMillis)
|
||||
val contentText = formatPreNoticeText(preNoticeTemplate, remaining)
|
||||
|
||||
val openAppIntent = PendingIntent.getActivity(
|
||||
context,
|
||||
requestCode(alarmId, 1),
|
||||
@@ -144,7 +149,7 @@ class PluriWaveAlarmReceiver : BroadcastReceiver() {
|
||||
val notification = NotificationCompat.Builder(context, CHANNEL_ID)
|
||||
.setSmallIcon(android.R.drawable.ic_dialog_info)
|
||||
.setContentTitle(title)
|
||||
.setContentText("Empieza en 30 minutos")
|
||||
.setContentText(contentText)
|
||||
.setPriority(NotificationCompat.PRIORITY_LOW)
|
||||
.setCategory(NotificationCompat.CATEGORY_REMINDER)
|
||||
.setSilent(true)
|
||||
@@ -156,12 +161,31 @@ class PluriWaveAlarmReceiver : BroadcastReceiver() {
|
||||
|
||||
try {
|
||||
NotificationManagerCompat.from(context).notify(notificationIdForAlarm(alarmId), notification)
|
||||
Log.d(TAG, "alarm.notification preNotice shown id=$alarmId")
|
||||
Log.d(TAG, "alarm.notification preNotice shown id=$alarmId remaining=$remaining")
|
||||
} catch (error: SecurityException) {
|
||||
Log.e(TAG, "alarm.notification preNotice SecurityException id=$alarmId", error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Computes the number of minutes remaining until [triggerAtMillis],
|
||||
* clamped to a minimum of 1. Handles Doze-delayed wakeups and clock drift.
|
||||
*/
|
||||
private fun computeRemainingMinutes(triggerAtMillis: Long): Long =
|
||||
maxOf(1L, (triggerAtMillis - System.currentTimeMillis()) / 60_000L)
|
||||
|
||||
/**
|
||||
* Formats the pre-notice notification text by replacing the `{minutes}`
|
||||
* placeholder in [template] with [remaining]. Falls back to an English
|
||||
* default if [template] is null or blank.
|
||||
*/
|
||||
private fun formatPreNoticeText(template: String?, remaining: Long): String {
|
||||
if (template.isNullOrBlank()) {
|
||||
return "Starts in $remaining min"
|
||||
}
|
||||
return template.replace("{minutes}", remaining.toString())
|
||||
}
|
||||
|
||||
private fun ensureChannel(context: Context) {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
|
||||
val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||
|
||||
@@ -51,6 +51,9 @@ class EstadoEcualizador extends ChangeNotifier {
|
||||
/// Matrix presets: "stationUuid:deviceId" → PresetEcualizador.
|
||||
final Map<String, PresetEcualizador> _presetsMatriz = {};
|
||||
|
||||
/// Custom display names for devices: deviceId → custom name.
|
||||
final Map<String, String> _nombresDispositivos = {};
|
||||
|
||||
PresetEcualizador _presetPrincipal = PresetEcualizador.flat;
|
||||
PresetEcualizador _presetActual = PresetEcualizador.flat;
|
||||
bool _activo = true;
|
||||
@@ -65,6 +68,9 @@ class EstadoEcualizador extends ChangeNotifier {
|
||||
bool get eqMultiDeviceEnabled => _eqMultiDeviceEnabled;
|
||||
String? get dispositivoActualId => _dispositivoActualId;
|
||||
|
||||
Map<String, String> get nombresDispositivos =>
|
||||
Map.unmodifiable(_nombresDispositivos);
|
||||
|
||||
Map<String, PresetEcualizador> get presetsPorEmisora =>
|
||||
Map.unmodifiable(_presetsEmisoraMap);
|
||||
|
||||
@@ -148,6 +154,9 @@ class EstadoEcualizador extends ChangeNotifier {
|
||||
_presetsMatriz
|
||||
..clear()
|
||||
..addAll(config.presetsMatriz);
|
||||
_nombresDispositivos
|
||||
..clear()
|
||||
..addAll(config.nombresDispositivos);
|
||||
|
||||
// Resolve active preset and apply it.
|
||||
_presetActual = _resolverPresetActivo();
|
||||
@@ -156,6 +165,11 @@ class EstadoEcualizador extends ChangeNotifier {
|
||||
|
||||
// Subscribe to device changes only when toggle is on.
|
||||
_configurarSuscripcionDispositivo();
|
||||
|
||||
// Seed current device immediately so presets resolve before first event.
|
||||
if (_eqMultiDeviceEnabled) {
|
||||
await _sembrarDispositivoActual();
|
||||
}
|
||||
} catch (_) {
|
||||
_presetPrincipal = PresetEcualizador.flat;
|
||||
_presetActual = PresetEcualizador.flat;
|
||||
@@ -164,6 +178,20 @@ class EstadoEcualizador extends ChangeNotifier {
|
||||
_presetsEmisoraMap.clear();
|
||||
_presetsDispositivo.clear();
|
||||
_presetsMatriz.clear();
|
||||
_nombresDispositivos.clear();
|
||||
}
|
||||
}
|
||||
|
||||
/// Queries the current device and seeds [_dispositivoActualId] without
|
||||
/// waiting for a stream event. Falls back to `'builtin_speaker'` on error.
|
||||
Future<void> _sembrarDispositivoActual() async {
|
||||
final svc = _dispositivoAudio;
|
||||
if (svc == null) return;
|
||||
try {
|
||||
final dispositivo = await svc.obtenerDispositivoActual();
|
||||
await _onDispositivoCambiado(dispositivo);
|
||||
} catch (_) {
|
||||
_dispositivoActualId = 'builtin_speaker';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -280,6 +308,55 @@ class EstadoEcualizador extends ChangeNotifier {
|
||||
}
|
||||
}
|
||||
|
||||
/// Persists a custom display name for [deviceId].
|
||||
///
|
||||
/// Empty names are silently ignored so the existing name is preserved.
|
||||
/// No-op when the multi-device toggle is off.
|
||||
Future<void> renombrarDispositivo(String deviceId, String nombre) async {
|
||||
if (!_eqMultiDeviceEnabled) return;
|
||||
final nombreTrimmed = nombre.trim();
|
||||
if (nombreTrimmed.isEmpty) return;
|
||||
_nombresDispositivos[deviceId] = nombreTrimmed;
|
||||
await servicio.guardarNombresDispositivos(
|
||||
Map.unmodifiable(_nombresDispositivos),
|
||||
);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Persists a per-device EQ preset for [deviceId].
|
||||
///
|
||||
/// Guards on [_eqMultiDeviceEnabled]: no-op when toggle is off.
|
||||
/// If [deviceId] is the currently active device, re-resolves and
|
||||
/// applies the effective preset immediately.
|
||||
Future<void> guardarPresetDispositivo(
|
||||
String deviceId,
|
||||
PresetEcualizador preset,
|
||||
) async {
|
||||
if (!_eqMultiDeviceEnabled) return;
|
||||
_presetsDispositivo[deviceId] = preset;
|
||||
await servicio.guardarPresetDispositivo(deviceId, preset);
|
||||
if (_dispositivoActualId == deviceId) {
|
||||
// Re-resolve using the full hierarchy (station/matrix may override).
|
||||
final resuelto = _resolverPresetActivo();
|
||||
_presetActual = resuelto;
|
||||
await audio.aplicarPreset(resuelto);
|
||||
}
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Returns the stored custom name for [deviceId], or an empty string if none.
|
||||
String obtenerNombreDispositivo(String deviceId) =>
|
||||
_nombresDispositivos[deviceId] ?? '';
|
||||
|
||||
/// Resolves the display name for [deviceId] using the fallback chain:
|
||||
/// custom name → [platformName] → raw [deviceId].
|
||||
String nombreVisible(String deviceId, String platformName) {
|
||||
final custom = _nombresDispositivos[deviceId];
|
||||
if (custom != null && custom.isNotEmpty) return custom;
|
||||
if (platformName.isNotEmpty) return platformName;
|
||||
return deviceId;
|
||||
}
|
||||
|
||||
Future<void> cambiarActivo(bool activo) async {
|
||||
_activo = activo;
|
||||
await servicio.guardarActivo(activo);
|
||||
|
||||
+15
-2
@@ -627,5 +627,18 @@
|
||||
"placeholders": {
|
||||
"presetName": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"preNoticeCountdown": "يبدأ خلال {minutes} دقيقة",
|
||||
"@preNoticeCountdown": {
|
||||
"placeholders": {
|
||||
"minutes": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"eqDeviceEditTitle": "تعديل الجهاز",
|
||||
"eqDeviceNameLabel": "اسم الجهاز",
|
||||
"eqDeviceNameHint": "مثال: مكبر صوت غرفة المعيشة",
|
||||
"eqDeviceNameConfirm": "حفظ",
|
||||
"eqDeviceConnected": "متصل"
|
||||
}
|
||||
+15
-2
@@ -627,5 +627,18 @@
|
||||
"placeholders": {
|
||||
"presetName": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"preNoticeCountdown": "{minutes} মিনিটে শুরু হবে",
|
||||
"@preNoticeCountdown": {
|
||||
"placeholders": {
|
||||
"minutes": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"eqDeviceEditTitle": "ডিভাইস সম্পাদনা করুন",
|
||||
"eqDeviceNameLabel": "ডিভাইসের নাম",
|
||||
"eqDeviceNameHint": "যেমন: লিভিং রুমের স্পিকার",
|
||||
"eqDeviceNameConfirm": "সংরক্ষণ করুন",
|
||||
"eqDeviceConnected": "সংযুক্ত"
|
||||
}
|
||||
+15
-2
@@ -627,5 +627,18 @@
|
||||
"placeholders": {
|
||||
"presetName": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"preNoticeCountdown": "Startet in {minutes} Min.",
|
||||
"@preNoticeCountdown": {
|
||||
"placeholders": {
|
||||
"minutes": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"eqDeviceEditTitle": "Gerät bearbeiten",
|
||||
"eqDeviceNameLabel": "Gerätename",
|
||||
"eqDeviceNameHint": "z. B. Wohnzimmer-Lautsprecher",
|
||||
"eqDeviceNameConfirm": "Speichern",
|
||||
"eqDeviceConnected": "Verbunden"
|
||||
}
|
||||
+14
-1
@@ -627,5 +627,18 @@
|
||||
"placeholders": {
|
||||
"presetName": {}
|
||||
}
|
||||
}
|
||||
},
|
||||
"preNoticeCountdown": "Starts in {minutes} min",
|
||||
"@preNoticeCountdown": {
|
||||
"placeholders": {
|
||||
"minutes": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"eqDeviceEditTitle": "Edit device",
|
||||
"eqDeviceNameLabel": "Device name",
|
||||
"eqDeviceNameHint": "e.g. Living Room Speaker",
|
||||
"eqDeviceNameConfirm": "Save",
|
||||
"eqDeviceConnected": "Connected"
|
||||
}
|
||||
|
||||
+14
-1
@@ -590,5 +590,18 @@
|
||||
"placeholders": {
|
||||
"presetName": {}
|
||||
}
|
||||
}
|
||||
},
|
||||
"preNoticeCountdown": "Empieza en {minutes} min",
|
||||
"@preNoticeCountdown": {
|
||||
"placeholders": {
|
||||
"minutes": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"eqDeviceEditTitle": "Editar dispositivo",
|
||||
"eqDeviceNameLabel": "Nombre del dispositivo",
|
||||
"eqDeviceNameHint": "Ej: Altavoz del living",
|
||||
"eqDeviceNameConfirm": "Guardar",
|
||||
"eqDeviceConnected": "Conectado"
|
||||
}
|
||||
|
||||
+15
-2
@@ -627,5 +627,18 @@
|
||||
"placeholders": {
|
||||
"presetName": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"preNoticeCountdown": "Démarre dans {minutes} min",
|
||||
"@preNoticeCountdown": {
|
||||
"placeholders": {
|
||||
"minutes": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"eqDeviceEditTitle": "Modifier l'appareil",
|
||||
"eqDeviceNameLabel": "Nom de l'appareil",
|
||||
"eqDeviceNameHint": "ex. : Enceinte salon",
|
||||
"eqDeviceNameConfirm": "Enregistrer",
|
||||
"eqDeviceConnected": "Connecté"
|
||||
}
|
||||
+15
-2
@@ -627,5 +627,18 @@
|
||||
"placeholders": {
|
||||
"presetName": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"preNoticeCountdown": "{minutes} मिनट में शुरू होगा",
|
||||
"@preNoticeCountdown": {
|
||||
"placeholders": {
|
||||
"minutes": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"eqDeviceEditTitle": "डिवाइस संपादित करें",
|
||||
"eqDeviceNameLabel": "डिवाइस का नाम",
|
||||
"eqDeviceNameHint": "उदा. लिविंग रूम स्पीकर",
|
||||
"eqDeviceNameConfirm": "सहेजें",
|
||||
"eqDeviceConnected": "कनेक्टेड"
|
||||
}
|
||||
+15
-2
@@ -627,5 +627,18 @@
|
||||
"placeholders": {
|
||||
"presetName": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"preNoticeCountdown": "Mulai dalam {minutes} menit",
|
||||
"@preNoticeCountdown": {
|
||||
"placeholders": {
|
||||
"minutes": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"eqDeviceEditTitle": "Edit perangkat",
|
||||
"eqDeviceNameLabel": "Nama perangkat",
|
||||
"eqDeviceNameHint": "Mis: Speaker ruang tamu",
|
||||
"eqDeviceNameConfirm": "Simpan",
|
||||
"eqDeviceConnected": "Terhubung"
|
||||
}
|
||||
+15
-2
@@ -627,5 +627,18 @@
|
||||
"placeholders": {
|
||||
"presetName": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"preNoticeCountdown": "Inizia tra {minutes} min",
|
||||
"@preNoticeCountdown": {
|
||||
"placeholders": {
|
||||
"minutes": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"eqDeviceEditTitle": "Modifica dispositivo",
|
||||
"eqDeviceNameLabel": "Nome del dispositivo",
|
||||
"eqDeviceNameHint": "Es: Cassa del salotto",
|
||||
"eqDeviceNameConfirm": "Salva",
|
||||
"eqDeviceConnected": "Connesso"
|
||||
}
|
||||
+15
-2
@@ -627,5 +627,18 @@
|
||||
"placeholders": {
|
||||
"presetName": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"preNoticeCountdown": "{minutes}分後に開始",
|
||||
"@preNoticeCountdown": {
|
||||
"placeholders": {
|
||||
"minutes": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"eqDeviceEditTitle": "デバイスを編集",
|
||||
"eqDeviceNameLabel": "デバイス名",
|
||||
"eqDeviceNameHint": "例:リビングのスピーカー",
|
||||
"eqDeviceNameConfirm": "保存",
|
||||
"eqDeviceConnected": "接続中"
|
||||
}
|
||||
+15
-2
@@ -627,5 +627,18 @@
|
||||
"placeholders": {
|
||||
"presetName": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"preNoticeCountdown": "Começa em {minutes} min",
|
||||
"@preNoticeCountdown": {
|
||||
"placeholders": {
|
||||
"minutes": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"eqDeviceEditTitle": "Editar dispositivo",
|
||||
"eqDeviceNameLabel": "Nome do dispositivo",
|
||||
"eqDeviceNameHint": "Ex: Caixa da sala",
|
||||
"eqDeviceNameConfirm": "Salvar",
|
||||
"eqDeviceConnected": "Conectado"
|
||||
}
|
||||
+15
-2
@@ -627,5 +627,18 @@
|
||||
"placeholders": {
|
||||
"presetName": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"preNoticeCountdown": "Начнётся через {minutes} мин",
|
||||
"@preNoticeCountdown": {
|
||||
"placeholders": {
|
||||
"minutes": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"eqDeviceEditTitle": "Изменить устройство",
|
||||
"eqDeviceNameLabel": "Название устройства",
|
||||
"eqDeviceNameHint": "Напр.: Колонка в гостиной",
|
||||
"eqDeviceNameConfirm": "Сохранить",
|
||||
"eqDeviceConnected": "Подключено"
|
||||
}
|
||||
+15
-2
@@ -627,5 +627,18 @@
|
||||
"placeholders": {
|
||||
"presetName": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"preNoticeCountdown": "{minutes}分钟后开始",
|
||||
"@preNoticeCountdown": {
|
||||
"placeholders": {
|
||||
"minutes": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"eqDeviceEditTitle": "编辑设备",
|
||||
"eqDeviceNameLabel": "设备名称",
|
||||
"eqDeviceNameHint": "例:客厅音箱",
|
||||
"eqDeviceNameConfirm": "保存",
|
||||
"eqDeviceConnected": "已连接"
|
||||
}
|
||||
@@ -2305,6 +2305,42 @@ abstract class AppLocalizations {
|
||||
/// In es, this message translates to:
|
||||
/// **'Preset: {presetName}'**
|
||||
String advancedEqDevicePresetLabel(Object presetName);
|
||||
|
||||
/// No description provided for @preNoticeCountdown.
|
||||
///
|
||||
/// In es, this message translates to:
|
||||
/// **'Empieza en {minutes} min'**
|
||||
String preNoticeCountdown(int minutes);
|
||||
|
||||
/// No description provided for @eqDeviceEditTitle.
|
||||
///
|
||||
/// In es, this message translates to:
|
||||
/// **'Editar dispositivo'**
|
||||
String get eqDeviceEditTitle;
|
||||
|
||||
/// No description provided for @eqDeviceNameLabel.
|
||||
///
|
||||
/// In es, this message translates to:
|
||||
/// **'Nombre del dispositivo'**
|
||||
String get eqDeviceNameLabel;
|
||||
|
||||
/// No description provided for @eqDeviceNameHint.
|
||||
///
|
||||
/// In es, this message translates to:
|
||||
/// **'Ej: Altavoz del living'**
|
||||
String get eqDeviceNameHint;
|
||||
|
||||
/// No description provided for @eqDeviceNameConfirm.
|
||||
///
|
||||
/// In es, this message translates to:
|
||||
/// **'Guardar'**
|
||||
String get eqDeviceNameConfirm;
|
||||
|
||||
/// No description provided for @eqDeviceConnected.
|
||||
///
|
||||
/// In es, this message translates to:
|
||||
/// **'Conectado'**
|
||||
String get eqDeviceConnected;
|
||||
}
|
||||
|
||||
class _AppLocalizationsDelegate
|
||||
|
||||
@@ -1247,4 +1247,24 @@ class AppLocalizationsAr extends AppLocalizations {
|
||||
String advancedEqDevicePresetLabel(Object presetName) {
|
||||
return 'الإعداد المسبق: $presetName';
|
||||
}
|
||||
|
||||
@override
|
||||
String preNoticeCountdown(int minutes) {
|
||||
return 'يبدأ خلال $minutes دقيقة';
|
||||
}
|
||||
|
||||
@override
|
||||
String get eqDeviceEditTitle => 'تعديل الجهاز';
|
||||
|
||||
@override
|
||||
String get eqDeviceNameLabel => 'اسم الجهاز';
|
||||
|
||||
@override
|
||||
String get eqDeviceNameHint => 'مثال: مكبر صوت غرفة المعيشة';
|
||||
|
||||
@override
|
||||
String get eqDeviceNameConfirm => 'حفظ';
|
||||
|
||||
@override
|
||||
String get eqDeviceConnected => 'متصل';
|
||||
}
|
||||
|
||||
@@ -1254,4 +1254,24 @@ class AppLocalizationsBn extends AppLocalizations {
|
||||
String advancedEqDevicePresetLabel(Object presetName) {
|
||||
return 'প্রিসেট: $presetName';
|
||||
}
|
||||
|
||||
@override
|
||||
String preNoticeCountdown(int minutes) {
|
||||
return '$minutes মিনিটে শুরু হবে';
|
||||
}
|
||||
|
||||
@override
|
||||
String get eqDeviceEditTitle => 'ডিভাইস সম্পাদনা করুন';
|
||||
|
||||
@override
|
||||
String get eqDeviceNameLabel => 'ডিভাইসের নাম';
|
||||
|
||||
@override
|
||||
String get eqDeviceNameHint => 'যেমন: লিভিং রুমের স্পিকার';
|
||||
|
||||
@override
|
||||
String get eqDeviceNameConfirm => 'সংরক্ষণ করুন';
|
||||
|
||||
@override
|
||||
String get eqDeviceConnected => 'সংযুক্ত';
|
||||
}
|
||||
|
||||
@@ -1264,4 +1264,24 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
String advancedEqDevicePresetLabel(Object presetName) {
|
||||
return 'Preset: $presetName';
|
||||
}
|
||||
|
||||
@override
|
||||
String preNoticeCountdown(int minutes) {
|
||||
return 'Startet in $minutes Min.';
|
||||
}
|
||||
|
||||
@override
|
||||
String get eqDeviceEditTitle => 'Gerät bearbeiten';
|
||||
|
||||
@override
|
||||
String get eqDeviceNameLabel => 'Gerätename';
|
||||
|
||||
@override
|
||||
String get eqDeviceNameHint => 'z. B. Wohnzimmer-Lautsprecher';
|
||||
|
||||
@override
|
||||
String get eqDeviceNameConfirm => 'Speichern';
|
||||
|
||||
@override
|
||||
String get eqDeviceConnected => 'Verbunden';
|
||||
}
|
||||
|
||||
@@ -1250,4 +1250,24 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
String advancedEqDevicePresetLabel(Object presetName) {
|
||||
return 'Preset: $presetName';
|
||||
}
|
||||
|
||||
@override
|
||||
String preNoticeCountdown(int minutes) {
|
||||
return 'Starts in $minutes min';
|
||||
}
|
||||
|
||||
@override
|
||||
String get eqDeviceEditTitle => 'Edit device';
|
||||
|
||||
@override
|
||||
String get eqDeviceNameLabel => 'Device name';
|
||||
|
||||
@override
|
||||
String get eqDeviceNameHint => 'e.g. Living Room Speaker';
|
||||
|
||||
@override
|
||||
String get eqDeviceNameConfirm => 'Save';
|
||||
|
||||
@override
|
||||
String get eqDeviceConnected => 'Connected';
|
||||
}
|
||||
|
||||
@@ -1259,4 +1259,24 @@ class AppLocalizationsEs extends AppLocalizations {
|
||||
String advancedEqDevicePresetLabel(Object presetName) {
|
||||
return 'Preset: $presetName';
|
||||
}
|
||||
|
||||
@override
|
||||
String preNoticeCountdown(int minutes) {
|
||||
return 'Empieza en $minutes min';
|
||||
}
|
||||
|
||||
@override
|
||||
String get eqDeviceEditTitle => 'Editar dispositivo';
|
||||
|
||||
@override
|
||||
String get eqDeviceNameLabel => 'Nombre del dispositivo';
|
||||
|
||||
@override
|
||||
String get eqDeviceNameHint => 'Ej: Altavoz del living';
|
||||
|
||||
@override
|
||||
String get eqDeviceNameConfirm => 'Guardar';
|
||||
|
||||
@override
|
||||
String get eqDeviceConnected => 'Conectado';
|
||||
}
|
||||
|
||||
@@ -1269,4 +1269,24 @@ class AppLocalizationsFr extends AppLocalizations {
|
||||
String advancedEqDevicePresetLabel(Object presetName) {
|
||||
return 'Preset : $presetName';
|
||||
}
|
||||
|
||||
@override
|
||||
String preNoticeCountdown(int minutes) {
|
||||
return 'Démarre dans $minutes min';
|
||||
}
|
||||
|
||||
@override
|
||||
String get eqDeviceEditTitle => 'Modifier l\'appareil';
|
||||
|
||||
@override
|
||||
String get eqDeviceNameLabel => 'Nom de l\'appareil';
|
||||
|
||||
@override
|
||||
String get eqDeviceNameHint => 'ex. : Enceinte salon';
|
||||
|
||||
@override
|
||||
String get eqDeviceNameConfirm => 'Enregistrer';
|
||||
|
||||
@override
|
||||
String get eqDeviceConnected => 'Connecté';
|
||||
}
|
||||
|
||||
@@ -1253,4 +1253,24 @@ class AppLocalizationsHi extends AppLocalizations {
|
||||
String advancedEqDevicePresetLabel(Object presetName) {
|
||||
return 'प्रीसेट: $presetName';
|
||||
}
|
||||
|
||||
@override
|
||||
String preNoticeCountdown(int minutes) {
|
||||
return '$minutes मिनट में शुरू होगा';
|
||||
}
|
||||
|
||||
@override
|
||||
String get eqDeviceEditTitle => 'डिवाइस संपादित करें';
|
||||
|
||||
@override
|
||||
String get eqDeviceNameLabel => 'डिवाइस का नाम';
|
||||
|
||||
@override
|
||||
String get eqDeviceNameHint => 'उदा. लिविंग रूम स्पीकर';
|
||||
|
||||
@override
|
||||
String get eqDeviceNameConfirm => 'सहेजें';
|
||||
|
||||
@override
|
||||
String get eqDeviceConnected => 'कनेक्टेड';
|
||||
}
|
||||
|
||||
@@ -1258,4 +1258,24 @@ class AppLocalizationsId extends AppLocalizations {
|
||||
String advancedEqDevicePresetLabel(Object presetName) {
|
||||
return 'Preset: $presetName';
|
||||
}
|
||||
|
||||
@override
|
||||
String preNoticeCountdown(int minutes) {
|
||||
return 'Mulai dalam $minutes menit';
|
||||
}
|
||||
|
||||
@override
|
||||
String get eqDeviceEditTitle => 'Edit perangkat';
|
||||
|
||||
@override
|
||||
String get eqDeviceNameLabel => 'Nama perangkat';
|
||||
|
||||
@override
|
||||
String get eqDeviceNameHint => 'Mis: Speaker ruang tamu';
|
||||
|
||||
@override
|
||||
String get eqDeviceNameConfirm => 'Simpan';
|
||||
|
||||
@override
|
||||
String get eqDeviceConnected => 'Terhubung';
|
||||
}
|
||||
|
||||
@@ -1264,4 +1264,24 @@ class AppLocalizationsIt extends AppLocalizations {
|
||||
String advancedEqDevicePresetLabel(Object presetName) {
|
||||
return 'Preset: $presetName';
|
||||
}
|
||||
|
||||
@override
|
||||
String preNoticeCountdown(int minutes) {
|
||||
return 'Inizia tra $minutes min';
|
||||
}
|
||||
|
||||
@override
|
||||
String get eqDeviceEditTitle => 'Modifica dispositivo';
|
||||
|
||||
@override
|
||||
String get eqDeviceNameLabel => 'Nome del dispositivo';
|
||||
|
||||
@override
|
||||
String get eqDeviceNameHint => 'Es: Cassa del salotto';
|
||||
|
||||
@override
|
||||
String get eqDeviceNameConfirm => 'Salva';
|
||||
|
||||
@override
|
||||
String get eqDeviceConnected => 'Connesso';
|
||||
}
|
||||
|
||||
@@ -1214,4 +1214,24 @@ class AppLocalizationsJa extends AppLocalizations {
|
||||
String advancedEqDevicePresetLabel(Object presetName) {
|
||||
return 'プリセット: $presetName';
|
||||
}
|
||||
|
||||
@override
|
||||
String preNoticeCountdown(int minutes) {
|
||||
return '$minutes分後に開始';
|
||||
}
|
||||
|
||||
@override
|
||||
String get eqDeviceEditTitle => 'デバイスを編集';
|
||||
|
||||
@override
|
||||
String get eqDeviceNameLabel => 'デバイス名';
|
||||
|
||||
@override
|
||||
String get eqDeviceNameHint => '例:リビングのスピーカー';
|
||||
|
||||
@override
|
||||
String get eqDeviceNameConfirm => '保存';
|
||||
|
||||
@override
|
||||
String get eqDeviceConnected => '接続中';
|
||||
}
|
||||
|
||||
@@ -1256,4 +1256,24 @@ class AppLocalizationsPt extends AppLocalizations {
|
||||
String advancedEqDevicePresetLabel(Object presetName) {
|
||||
return 'Preset: $presetName';
|
||||
}
|
||||
|
||||
@override
|
||||
String preNoticeCountdown(int minutes) {
|
||||
return 'Começa em $minutes min';
|
||||
}
|
||||
|
||||
@override
|
||||
String get eqDeviceEditTitle => 'Editar dispositivo';
|
||||
|
||||
@override
|
||||
String get eqDeviceNameLabel => 'Nome do dispositivo';
|
||||
|
||||
@override
|
||||
String get eqDeviceNameHint => 'Ex: Caixa da sala';
|
||||
|
||||
@override
|
||||
String get eqDeviceNameConfirm => 'Salvar';
|
||||
|
||||
@override
|
||||
String get eqDeviceConnected => 'Conectado';
|
||||
}
|
||||
|
||||
@@ -1260,4 +1260,24 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
String advancedEqDevicePresetLabel(Object presetName) {
|
||||
return 'Пресет: $presetName';
|
||||
}
|
||||
|
||||
@override
|
||||
String preNoticeCountdown(int minutes) {
|
||||
return 'Начнётся через $minutes мин';
|
||||
}
|
||||
|
||||
@override
|
||||
String get eqDeviceEditTitle => 'Изменить устройство';
|
||||
|
||||
@override
|
||||
String get eqDeviceNameLabel => 'Название устройства';
|
||||
|
||||
@override
|
||||
String get eqDeviceNameHint => 'Напр.: Колонка в гостиной';
|
||||
|
||||
@override
|
||||
String get eqDeviceNameConfirm => 'Сохранить';
|
||||
|
||||
@override
|
||||
String get eqDeviceConnected => 'Подключено';
|
||||
}
|
||||
|
||||
@@ -1207,4 +1207,24 @@ class AppLocalizationsZh extends AppLocalizations {
|
||||
String advancedEqDevicePresetLabel(Object presetName) {
|
||||
return '预设:$presetName';
|
||||
}
|
||||
|
||||
@override
|
||||
String preNoticeCountdown(int minutes) {
|
||||
return '$minutes分钟后开始';
|
||||
}
|
||||
|
||||
@override
|
||||
String get eqDeviceEditTitle => '编辑设备';
|
||||
|
||||
@override
|
||||
String get eqDeviceNameLabel => '设备名称';
|
||||
|
||||
@override
|
||||
String get eqDeviceNameHint => '例:客厅音箱';
|
||||
|
||||
@override
|
||||
String get eqDeviceNameConfirm => '保存';
|
||||
|
||||
@override
|
||||
String get eqDeviceConnected => '已连接';
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ import '../l10n/display_names.dart';
|
||||
import '../l10n/gen/app_localizations.dart';
|
||||
import '../modelos/emisora.dart';
|
||||
import '../modelos/grupo_favoritos.dart';
|
||||
import '../modelos/preset_ecualizador.dart';
|
||||
import '../widgets/ecualizador_widget.dart';
|
||||
import '../widgets/pluri_glass_surface.dart';
|
||||
import '../widgets/pluri_icon.dart';
|
||||
@@ -739,33 +740,9 @@ class _SeccionEcualizadorAvanzado extends StatelessWidget {
|
||||
)
|
||||
else
|
||||
for (final entry in presetsDispositivo.entries)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 6),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.headphones_rounded, size: 20),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
entry.key,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
),
|
||||
Text(
|
||||
l10n.advancedEqDevicePresetLabel(
|
||||
entry.value.nombre,
|
||||
),
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
_FilaDispositivo(
|
||||
deviceId: entry.key,
|
||||
preset: entry.value,
|
||||
),
|
||||
],
|
||||
],
|
||||
@@ -774,6 +751,162 @@ class _SeccionEcualizadorAvanzado extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
/// A single device row in the known-devices list.
|
||||
///
|
||||
/// Shows a connection indicator (green dot) when [deviceId] matches the
|
||||
/// currently active device. Tapping the edit icon opens [_DialogoEdicionDispositivo].
|
||||
class _FilaDispositivo extends StatelessWidget {
|
||||
const _FilaDispositivo({required this.deviceId, required this.preset});
|
||||
|
||||
final String deviceId;
|
||||
final PresetEcualizador preset;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final eq = context.watch<EstadoEcualizador>();
|
||||
final isActive = eq.dispositivoActualId == deviceId;
|
||||
final displayName = eq.nombreVisible(deviceId, '');
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 6),
|
||||
child: Row(
|
||||
children: [
|
||||
if (isActive)
|
||||
const Icon(Icons.circle, size: 10, color: Colors.green)
|
||||
else
|
||||
const SizedBox(width: 10),
|
||||
const SizedBox(width: 8),
|
||||
const Icon(Icons.headphones_rounded, size: 20),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
displayName,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
),
|
||||
Text(
|
||||
l10n.advancedEqDevicePresetLabel(preset.nombre),
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.edit_rounded, size: 20),
|
||||
tooltip: l10n.eqDeviceEditTitle,
|
||||
onPressed: () => _abrirModal(context),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _abrirModal(BuildContext context) async {
|
||||
await showModalBottomSheet<void>(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
showDragHandle: true,
|
||||
builder: (ctx) => _DialogoEdicionDispositivo(
|
||||
deviceId: deviceId,
|
||||
preset: preset,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Bottom sheet for editing a device's custom name and EQ preset.
|
||||
class _DialogoEdicionDispositivo extends StatefulWidget {
|
||||
const _DialogoEdicionDispositivo({
|
||||
required this.deviceId,
|
||||
required this.preset,
|
||||
});
|
||||
|
||||
final String deviceId;
|
||||
final PresetEcualizador preset;
|
||||
|
||||
@override
|
||||
State<_DialogoEdicionDispositivo> createState() =>
|
||||
_DialogoEdicionDispositivoState();
|
||||
}
|
||||
|
||||
class _DialogoEdicionDispositivoState
|
||||
extends State<_DialogoEdicionDispositivo> {
|
||||
late final TextEditingController _nombreCtrl;
|
||||
late PresetEcualizador _presetActual;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
final eq = context.read<EstadoEcualizador>();
|
||||
final displayName = eq.nombreVisible(widget.deviceId, '');
|
||||
_nombreCtrl = TextEditingController(text: displayName);
|
||||
_presetActual = widget.preset;
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_nombreCtrl.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _guardar() async {
|
||||
final eq = context.read<EstadoEcualizador>();
|
||||
await eq.renombrarDispositivo(widget.deviceId, _nombreCtrl.text);
|
||||
if (_presetActual != widget.preset) {
|
||||
await eq.guardarPresetDispositivo(widget.deviceId, _presetActual);
|
||||
}
|
||||
if (mounted) Navigator.of(context).pop();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final bottom = MediaQuery.viewInsetsOf(context).bottom;
|
||||
|
||||
return SingleChildScrollView(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.fromLTRB(20, 0, 20, bottom + 24),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
l10n.eqDeviceEditTitle,
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextField(
|
||||
controller: _nombreCtrl,
|
||||
autofocus: true,
|
||||
decoration: InputDecoration(
|
||||
labelText: l10n.eqDeviceNameLabel,
|
||||
hintText: l10n.eqDeviceNameHint,
|
||||
border: const OutlineInputBorder(),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
EcualizadorWidget(
|
||||
preset: _presetActual,
|
||||
onCambio: (p) => setState(() => _presetActual = p),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
FilledButton.icon(
|
||||
onPressed: _guardar,
|
||||
icon: const Icon(Icons.save_rounded),
|
||||
label: Text(l10n.eqDeviceNameConfirm),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SeccionOrdenListas extends StatelessWidget {
|
||||
const _SeccionOrdenListas();
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:just_audio/just_audio.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
@@ -153,11 +154,10 @@ class _PantallaAlarmaSonandoState extends State<PantallaAlarmaSonando> {
|
||||
Future<void> _detener() async {
|
||||
final radio = context.read<EstadoRadio>();
|
||||
final alarmas = context.read<EstadoAlarmas>();
|
||||
final navigator = Navigator.of(context);
|
||||
await _liberarAudioLocal();
|
||||
await radio.audio.pausar();
|
||||
await alarmas.finalizarEjecucion(widget.alarma.id);
|
||||
if (mounted) navigator.pop();
|
||||
if (mounted) _dismissScreen();
|
||||
}
|
||||
|
||||
/// Flutter-first snooze (S2-R1): tears down local audio, then routes
|
||||
@@ -166,11 +166,25 @@ class _PantallaAlarmaSonandoState extends State<PantallaAlarmaSonando> {
|
||||
Future<void> _posponer(int minutos) async {
|
||||
final radio = context.read<EstadoRadio>();
|
||||
final alarmas = context.read<EstadoAlarmas>();
|
||||
final navigator = Navigator.of(context);
|
||||
await _liberarAudioLocal();
|
||||
await radio.audio.pausar();
|
||||
await alarmas.posponerAlarma(widget.alarma, minutos);
|
||||
if (mounted) navigator.pop();
|
||||
if (mounted) _dismissScreen();
|
||||
}
|
||||
|
||||
/// Dismisses the alarm screen safely in both live-app and dead-app states.
|
||||
///
|
||||
/// When the alarm screen is the root activity (launched via full-screen intent
|
||||
/// from a dead app), [Navigator.canPop] returns false and calling
|
||||
/// [Navigator.pop] would be a no-op. In that case [SystemNavigator.pop] is
|
||||
/// used to call `Activity.finish()` and return to the home screen.
|
||||
void _dismissScreen() {
|
||||
final navigator = Navigator.of(context);
|
||||
if (navigator.canPop()) {
|
||||
navigator.pop();
|
||||
} else {
|
||||
SystemNavigator.pop();
|
||||
}
|
||||
}
|
||||
|
||||
List<int> _opcionesSnooze() {
|
||||
|
||||
@@ -170,6 +170,19 @@ class ServicioAlarmasAndroid implements PuertoAlarmasAndroid {
|
||||
_l10n = l10n;
|
||||
}
|
||||
|
||||
/// Builds a pre-notice template string with a literal `{minutes}` placeholder
|
||||
/// for Kotlin to replace at broadcast-receiver fire time.
|
||||
///
|
||||
/// Strategy: call [preNoticeCountdown] with a unique sentinel integer and
|
||||
/// replace the sentinel's string representation with `{minutes}`.
|
||||
static String _preNoticeTemplate(AppLocalizations l10n) {
|
||||
const sentinel = 42424242;
|
||||
return l10n.preNoticeCountdown(sentinel).replaceFirst(
|
||||
sentinel.toString(),
|
||||
'{minutes}',
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Stream<EventoAlarmaAndroid> get eventosAlarma => _eventosController.stream;
|
||||
|
||||
@@ -189,6 +202,7 @@ class ServicioAlarmasAndroid implements PuertoAlarmasAndroid {
|
||||
final programada = await _channel.invokeMethod<bool>('scheduleAlarm', {
|
||||
'id': alarma.id,
|
||||
'title': localizedAlarmName(_textos, alarma.nombre),
|
||||
'preNoticeTemplate': _preNoticeTemplate(_textos),
|
||||
'triggerAtMillis': proxima.millisecondsSinceEpoch,
|
||||
'preNoticeAtMillis':
|
||||
alarma.snoozeHasta == null
|
||||
|
||||
@@ -12,6 +12,7 @@ class ConfiguracionEcualizador {
|
||||
this.eqMultiDeviceEnabled = false,
|
||||
this.presetsDispositivo = const {},
|
||||
this.presetsMatriz = const {},
|
||||
this.nombresDispositivos = const {},
|
||||
});
|
||||
|
||||
final PresetEcualizador principal;
|
||||
@@ -26,6 +27,9 @@ class ConfiguracionEcualizador {
|
||||
|
||||
/// Matrix presets: "stationUuid:deviceId" → PresetEcualizador.
|
||||
final Map<String, PresetEcualizador> presetsMatriz;
|
||||
|
||||
/// Custom display names for devices: deviceId → custom name.
|
||||
final Map<String, String> nombresDispositivos;
|
||||
}
|
||||
|
||||
class ServicioEcualizador {
|
||||
@@ -37,6 +41,7 @@ class ServicioEcualizador {
|
||||
static const _keyMultiDeviceEnabled = 'eq_multi_device_enabled_v1';
|
||||
static const _keyPresetsPorDispositivo = 'eq_preset_por_dispositivo_v1';
|
||||
static const _keyPresetsMatriz = 'eq_presets_matriz_v1';
|
||||
static const _keyNombresDispositivos = 'eq_nombres_dispositivos_v1';
|
||||
|
||||
final SharedPreferences? _prefs;
|
||||
|
||||
@@ -50,6 +55,7 @@ class ServicioEcualizador {
|
||||
final porEmisora = _leerPresetsPorEmisora(prefs);
|
||||
final presetsDispositivo = _leerMapa(prefs, _keyPresetsPorDispositivo);
|
||||
final presetsMatriz = _leerMapa(prefs, _keyPresetsMatriz);
|
||||
final nombresDispositivos = _leerMapaStrings(prefs, _keyNombresDispositivos);
|
||||
return ConfiguracionEcualizador(
|
||||
principal: principal,
|
||||
porEmisora: porEmisora,
|
||||
@@ -57,6 +63,7 @@ class ServicioEcualizador {
|
||||
eqMultiDeviceEnabled: prefs.getBool(_keyMultiDeviceEnabled) ?? false,
|
||||
presetsDispositivo: presetsDispositivo,
|
||||
presetsMatriz: presetsMatriz,
|
||||
nombresDispositivos: nombresDispositivos,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -99,6 +106,11 @@ class ServicioEcualizador {
|
||||
config.presetsDispositivo,
|
||||
);
|
||||
await _guardarMapa(prefs, _keyPresetsMatriz, config.presetsMatriz);
|
||||
await _guardarMapaStrings(
|
||||
prefs,
|
||||
_keyNombresDispositivos,
|
||||
config.nombresDispositivos,
|
||||
);
|
||||
}
|
||||
|
||||
/// Persists the multi-device feature toggle.
|
||||
@@ -145,6 +157,18 @@ class ServicioEcualizador {
|
||||
await _guardarMapa(prefs, _keyPresetsMatriz, mapa);
|
||||
}
|
||||
|
||||
/// Returns the persisted device custom names map.
|
||||
Future<Map<String, String>> cargarNombresDispositivos() async {
|
||||
final prefs = await _resolverPrefs();
|
||||
return _leerMapaStrings(prefs, _keyNombresDispositivos);
|
||||
}
|
||||
|
||||
/// Persists the device custom names map.
|
||||
Future<void> guardarNombresDispositivos(Map<String, String> nombres) async {
|
||||
final prefs = await _resolverPrefs();
|
||||
await _guardarMapaStrings(prefs, _keyNombresDispositivos, nombres);
|
||||
}
|
||||
|
||||
/// Reads a `Map<String, PresetEcualizador>` from a SharedPreferences JSON key.
|
||||
Map<String, PresetEcualizador> _leerMapa(
|
||||
SharedPreferences prefs,
|
||||
@@ -174,6 +198,26 @@ class ServicioEcualizador {
|
||||
await prefs.setString(key, jsonEncode(serializado));
|
||||
}
|
||||
|
||||
/// Reads a `Map<String, String>` from a SharedPreferences JSON key.
|
||||
Map<String, String> _leerMapaStrings(SharedPreferences prefs, String key) {
|
||||
final raw = prefs.getString(key);
|
||||
if (raw == null || raw.isEmpty) return {};
|
||||
try {
|
||||
final data = Map<String, dynamic>.from(jsonDecode(raw) as Map);
|
||||
return data.map((k, v) => MapEntry(k, v as String));
|
||||
} catch (_) {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _guardarMapaStrings(
|
||||
SharedPreferences prefs,
|
||||
String key,
|
||||
Map<String, String> mapa,
|
||||
) async {
|
||||
await prefs.setString(key, jsonEncode(mapa));
|
||||
}
|
||||
|
||||
PresetEcualizador _leerPresetPrincipal(SharedPreferences prefs) {
|
||||
final raw = prefs.getString(_keyPresetPrincipal);
|
||||
if (raw == null || raw.isEmpty) {
|
||||
|
||||
@@ -538,4 +538,408 @@ void main() {
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// eq-device-autoswitch-ux Phase 2: startup seeding + rename API
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
group('EstadoEcualizador — startup device seeding (eq-device-autoswitch-ux Phase 2)', () {
|
||||
const btDevice = DispositivoAudio(
|
||||
id: 'bt_a2dp:AA:BB',
|
||||
tipo: TipoDispositivo.bluetoothA2dp,
|
||||
nombre: 'BT Speaker',
|
||||
);
|
||||
|
||||
// 2.1 RED — after cargarPersistido with multiDevice ON, dispositivoActualId is seeded
|
||||
test('2.1 cargarPersistido seeds dispositivoActualId from obtenerDispositivoActual', () async {
|
||||
final fakeDispositivo = FakeServicioDispositivoAudio()
|
||||
..emitirDispositivo(btDevice); // sets _dispositivoActual
|
||||
|
||||
final eq = EstadoEcualizador(
|
||||
audio: FakeServicioAudio(),
|
||||
servicio: FakeServicioEcualizador(eqMultiDeviceEnabled: true),
|
||||
dispositivoAudio: fakeDispositivo,
|
||||
);
|
||||
await eq.cargarPersistido();
|
||||
|
||||
expect(eq.dispositivoActualId, equals('bt_a2dp:AA:BB'));
|
||||
eq.dispose();
|
||||
});
|
||||
|
||||
// 2.2 RED — first-seen device is bootstrapped from resolved preset at startup
|
||||
test('2.2 first-seen device is bootstrapped at startup with resolved preset', () async {
|
||||
final fakeServicio = FakeServicioEcualizador(
|
||||
principal: PresetEcualizador.rock,
|
||||
eqMultiDeviceEnabled: true,
|
||||
presetsDispositivo: {},
|
||||
);
|
||||
final fakeDispositivo = FakeServicioDispositivoAudio()
|
||||
..emitirDispositivo(btDevice);
|
||||
|
||||
final eq = EstadoEcualizador(
|
||||
audio: FakeServicioAudio(),
|
||||
servicio: fakeServicio,
|
||||
dispositivoAudio: fakeDispositivo,
|
||||
);
|
||||
await eq.cargarPersistido();
|
||||
|
||||
expect(
|
||||
eq.presetsDispositivo[btDevice.id],
|
||||
equals(PresetEcualizador.rock),
|
||||
);
|
||||
eq.dispose();
|
||||
});
|
||||
|
||||
// 2.3 RED — obtenerDispositivoActual throws → fallback to 'builtin_speaker'
|
||||
test('2.3 obtenerDispositivoActual throws → dispositivoActualId falls back to builtin_speaker', () async {
|
||||
final fakeDispositivo = FakeServicioDispositivoAudioThrows();
|
||||
|
||||
final eq = EstadoEcualizador(
|
||||
audio: FakeServicioAudio(),
|
||||
servicio: FakeServicioEcualizador(eqMultiDeviceEnabled: true),
|
||||
dispositivoAudio: fakeDispositivo,
|
||||
);
|
||||
await eq.cargarPersistido(); // must not throw
|
||||
|
||||
expect(eq.dispositivoActualId, equals('builtin_speaker'));
|
||||
eq.dispose();
|
||||
});
|
||||
|
||||
// 2.4 RED — toggle off → obtenerDispositivoActual is never called
|
||||
test('2.4 eqMultiDeviceEnabled=false → obtenerDispositivoActual NOT called', () async {
|
||||
final fakeDispositivo = FakeServicioDispositivoAudioThrows();
|
||||
|
||||
final eq = EstadoEcualizador(
|
||||
audio: FakeServicioAudio(),
|
||||
servicio: FakeServicioEcualizador(eqMultiDeviceEnabled: false),
|
||||
dispositivoAudio: fakeDispositivo,
|
||||
);
|
||||
// If obtenerDispositivoActual were called it would throw → test would fail
|
||||
await expectLater(eq.cargarPersistido(), completes);
|
||||
expect(eq.dispositivoActualId, isNull);
|
||||
eq.dispose();
|
||||
});
|
||||
});
|
||||
|
||||
group('EstadoEcualizador — rename API (eq-device-autoswitch-ux Phase 2)', () {
|
||||
// 2.6 RED — renombrarDispositivo writes to _nombresDispositivos and notifies
|
||||
test('2.6a renombrarDispositivo stores name and notifyListeners', () async {
|
||||
final eq = EstadoEcualizador(
|
||||
audio: FakeServicioAudio(),
|
||||
servicio: FakeServicioEcualizador(eqMultiDeviceEnabled: true),
|
||||
dispositivoAudio: FakeServicioDispositivoAudio(),
|
||||
);
|
||||
await eq.cargarPersistido();
|
||||
|
||||
var avisos = 0;
|
||||
eq.addListener(() => avisos++);
|
||||
await eq.renombrarDispositivo('bt_a2dp:AA:BB', 'Living Room BT');
|
||||
|
||||
expect(eq.obtenerNombreDispositivo('bt_a2dp:AA:BB'), equals('Living Room BT'));
|
||||
expect(avisos, greaterThanOrEqualTo(1));
|
||||
eq.dispose();
|
||||
});
|
||||
|
||||
// 2.6 RED — empty name is no-op (preserves existing name)
|
||||
test('2.6b renombrarDispositivo with empty string is no-op', () async {
|
||||
final eq = EstadoEcualizador(
|
||||
audio: FakeServicioAudio(),
|
||||
servicio: FakeServicioEcualizador(eqMultiDeviceEnabled: true),
|
||||
dispositivoAudio: FakeServicioDispositivoAudio(),
|
||||
);
|
||||
await eq.cargarPersistido();
|
||||
await eq.renombrarDispositivo('bt_a2dp:AA:BB', 'My Headset');
|
||||
await eq.renombrarDispositivo('bt_a2dp:AA:BB', '');
|
||||
|
||||
expect(eq.obtenerNombreDispositivo('bt_a2dp:AA:BB'), equals('My Headset'));
|
||||
eq.dispose();
|
||||
});
|
||||
|
||||
// 2.7 RED — obtenerNombreDispositivo returns empty string for unknown device
|
||||
test('2.7 obtenerNombreDispositivo returns empty string for unknown device', () async {
|
||||
final eq = EstadoEcualizador(
|
||||
audio: FakeServicioAudio(),
|
||||
servicio: FakeServicioEcualizador(),
|
||||
dispositivoAudio: FakeServicioDispositivoAudio(),
|
||||
);
|
||||
await eq.cargarPersistido();
|
||||
expect(eq.obtenerNombreDispositivo('unknown_device'), equals(''));
|
||||
eq.dispose();
|
||||
});
|
||||
|
||||
// 2.8 RED — nombreVisible fallback chain: custom > platform > id
|
||||
test('2.8a nombreVisible returns custom name when available', () async {
|
||||
final eq = EstadoEcualizador(
|
||||
audio: FakeServicioAudio(),
|
||||
servicio: FakeServicioEcualizador(eqMultiDeviceEnabled: true),
|
||||
dispositivoAudio: FakeServicioDispositivoAudio(),
|
||||
);
|
||||
await eq.cargarPersistido();
|
||||
await eq.renombrarDispositivo('bt_a2dp:AA:BB', 'Studio Monitors');
|
||||
|
||||
expect(
|
||||
eq.nombreVisible('bt_a2dp:AA:BB', 'USB Audio'),
|
||||
equals('Studio Monitors'),
|
||||
);
|
||||
eq.dispose();
|
||||
});
|
||||
|
||||
test('2.8b nombreVisible returns platform name when no custom name', () async {
|
||||
final eq = EstadoEcualizador(
|
||||
audio: FakeServicioAudio(),
|
||||
servicio: FakeServicioEcualizador(),
|
||||
dispositivoAudio: FakeServicioDispositivoAudio(),
|
||||
);
|
||||
await eq.cargarPersistido();
|
||||
|
||||
expect(
|
||||
eq.nombreVisible('bt_a2dp:AA:BB', 'Sony WH-1000XM5'),
|
||||
equals('Sony WH-1000XM5'),
|
||||
);
|
||||
eq.dispose();
|
||||
});
|
||||
|
||||
test('2.8c nombreVisible returns raw deviceId when no custom and no platform name', () async {
|
||||
final eq = EstadoEcualizador(
|
||||
audio: FakeServicioAudio(),
|
||||
servicio: FakeServicioEcualizador(),
|
||||
dispositivoAudio: FakeServicioDispositivoAudio(),
|
||||
);
|
||||
await eq.cargarPersistido();
|
||||
|
||||
expect(eq.nombreVisible('bt_a2dp:AA:BB', ''), equals('bt_a2dp:AA:BB'));
|
||||
eq.dispose();
|
||||
});
|
||||
|
||||
// nombresDispositivos getter
|
||||
test('2.9 nombresDispositivos getter returns current map', () async {
|
||||
final eq = EstadoEcualizador(
|
||||
audio: FakeServicioAudio(),
|
||||
servicio: FakeServicioEcualizador(eqMultiDeviceEnabled: true),
|
||||
dispositivoAudio: FakeServicioDispositivoAudio(),
|
||||
);
|
||||
await eq.cargarPersistido();
|
||||
await eq.renombrarDispositivo('bt_a2dp:AA:BB', 'My BT');
|
||||
|
||||
expect(eq.nombresDispositivos['bt_a2dp:AA:BB'], equals('My BT'));
|
||||
eq.dispose();
|
||||
});
|
||||
|
||||
// 2.10 RED — _nombresDispositivos loaded from ConfiguracionEcualizador.nombresDispositivos
|
||||
test('2.10 cargarPersistido loads nombresDispositivos from config', () async {
|
||||
final eq = EstadoEcualizador(
|
||||
audio: FakeServicioAudio(),
|
||||
servicio: FakeServicioEcualizador(
|
||||
nombresDispositivos: {'bt_a2dp:AA:BB': 'Loaded Name'},
|
||||
),
|
||||
dispositivoAudio: FakeServicioDispositivoAudio(),
|
||||
);
|
||||
await eq.cargarPersistido();
|
||||
|
||||
expect(eq.obtenerNombreDispositivo('bt_a2dp:AA:BB'), equals('Loaded Name'));
|
||||
eq.dispose();
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CRITICAL-1: renombrarDispositivo is a no-op when toggle is OFF
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
group('EstadoEcualizador — renombrarDispositivo toggle guard (CRITICAL-1)', () {
|
||||
test(
|
||||
'renombrarDispositivo is no-op when eqMultiDeviceEnabled is false',
|
||||
() async {
|
||||
final fakeServicio = FakeServicioEcualizador(eqMultiDeviceEnabled: false);
|
||||
final eq = EstadoEcualizador(
|
||||
audio: FakeServicioAudio(),
|
||||
servicio: fakeServicio,
|
||||
dispositivoAudio: FakeServicioDispositivoAudio(),
|
||||
);
|
||||
await eq.cargarPersistido();
|
||||
|
||||
var avisos = 0;
|
||||
eq.addListener(() => avisos++);
|
||||
await eq.renombrarDispositivo('bt_a2dp:AA:BB', 'My Device');
|
||||
|
||||
// No name stored, no listeners notified, service not called
|
||||
expect(eq.obtenerNombreDispositivo('bt_a2dp:AA:BB'), equals(''));
|
||||
expect(avisos, equals(0));
|
||||
expect(fakeServicio.config.nombresDispositivos, isEmpty);
|
||||
eq.dispose();
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'renombrarDispositivo works normally when eqMultiDeviceEnabled is true',
|
||||
() async {
|
||||
final eq = EstadoEcualizador(
|
||||
audio: FakeServicioAudio(),
|
||||
servicio: FakeServicioEcualizador(eqMultiDeviceEnabled: true),
|
||||
dispositivoAudio: FakeServicioDispositivoAudio(),
|
||||
);
|
||||
await eq.cargarPersistido();
|
||||
|
||||
var avisos = 0;
|
||||
eq.addListener(() => avisos++);
|
||||
await eq.renombrarDispositivo('bt_a2dp:AA:BB', 'My Device');
|
||||
|
||||
expect(eq.obtenerNombreDispositivo('bt_a2dp:AA:BB'), equals('My Device'));
|
||||
expect(avisos, greaterThanOrEqualTo(1));
|
||||
eq.dispose();
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CRITICAL-2: guardarPresetDispositivo method
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
group('EstadoEcualizador — guardarPresetDispositivo (CRITICAL-2)', () {
|
||||
const btDevice = DispositivoAudio(
|
||||
id: 'bt_a2dp:AA:BB',
|
||||
tipo: TipoDispositivo.bluetoothA2dp,
|
||||
nombre: 'BT Speaker',
|
||||
);
|
||||
|
||||
test(
|
||||
'guardarPresetDispositivo stores preset and notifies listeners',
|
||||
() async {
|
||||
final fakeServicio = FakeServicioEcualizador(
|
||||
eqMultiDeviceEnabled: true,
|
||||
presetsDispositivo: {btDevice.id: PresetEcualizador.flat},
|
||||
);
|
||||
final eq = EstadoEcualizador(
|
||||
audio: FakeServicioAudio(),
|
||||
servicio: fakeServicio,
|
||||
dispositivoAudio: FakeServicioDispositivoAudio(),
|
||||
);
|
||||
await eq.cargarPersistido();
|
||||
|
||||
var avisos = 0;
|
||||
eq.addListener(() => avisos++);
|
||||
await eq.guardarPresetDispositivo(btDevice.id, PresetEcualizador.jazz);
|
||||
|
||||
expect(eq.presetsDispositivo[btDevice.id], equals(PresetEcualizador.jazz));
|
||||
expect(fakeServicio.config.presetsDispositivo[btDevice.id], equals(PresetEcualizador.jazz));
|
||||
expect(avisos, greaterThanOrEqualTo(1));
|
||||
eq.dispose();
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'guardarPresetDispositivo is no-op when toggle is OFF',
|
||||
() async {
|
||||
final fakeServicio = FakeServicioEcualizador(
|
||||
eqMultiDeviceEnabled: false,
|
||||
presetsDispositivo: {btDevice.id: PresetEcualizador.flat},
|
||||
);
|
||||
final eq = EstadoEcualizador(
|
||||
audio: FakeServicioAudio(),
|
||||
servicio: fakeServicio,
|
||||
dispositivoAudio: FakeServicioDispositivoAudio(),
|
||||
);
|
||||
await eq.cargarPersistido();
|
||||
|
||||
var avisos = 0;
|
||||
eq.addListener(() => avisos++);
|
||||
await eq.guardarPresetDispositivo(btDevice.id, PresetEcualizador.jazz);
|
||||
|
||||
// Preset must remain unchanged
|
||||
expect(eq.presetsDispositivo[btDevice.id], equals(PresetEcualizador.flat));
|
||||
expect(avisos, equals(0));
|
||||
eq.dispose();
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'guardarPresetDispositivo triggers re-resolve and apply when device is active',
|
||||
() async {
|
||||
final fakeAudio = FakeServicioAudio();
|
||||
final fakeDispositivo = FakeServicioDispositivoAudio()
|
||||
..emitirDispositivo(btDevice);
|
||||
const stationUuid = 'station-save-test';
|
||||
final fakeServicio = FakeServicioEcualizador(
|
||||
eqMultiDeviceEnabled: true,
|
||||
presetsDispositivo: {btDevice.id: PresetEcualizador.flat},
|
||||
);
|
||||
final eq = EstadoEcualizador(
|
||||
audio: fakeAudio,
|
||||
servicio: fakeServicio,
|
||||
dispositivoAudio: fakeDispositivo,
|
||||
emisoraActualUuid: () => stationUuid,
|
||||
);
|
||||
await eq.cargarPersistido();
|
||||
await Future<void>.delayed(const Duration(milliseconds: 50));
|
||||
|
||||
fakeAudio.presetsAplicados.clear();
|
||||
await eq.guardarPresetDispositivo(btDevice.id, PresetEcualizador.rock);
|
||||
|
||||
// Audio was re-applied (re-resolve was triggered)
|
||||
expect(fakeAudio.presetsAplicados, isNotEmpty);
|
||||
// The resolved preset is the device preset (level 3, no station preset set)
|
||||
expect(fakeAudio.presetsAplicados.last, equals(PresetEcualizador.rock));
|
||||
eq.dispose();
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'guardarPresetDispositivo does NOT re-apply when device is not active',
|
||||
() async {
|
||||
final fakeAudio = FakeServicioAudio();
|
||||
final fakeServicio = FakeServicioEcualizador(
|
||||
eqMultiDeviceEnabled: true,
|
||||
presetsDispositivo: {
|
||||
btDevice.id: PresetEcualizador.flat,
|
||||
'other_device': PresetEcualizador.flat,
|
||||
},
|
||||
);
|
||||
final fakeDispositivo = FakeServicioDispositivoAudio()
|
||||
..emitirDispositivo(btDevice);
|
||||
final eq = EstadoEcualizador(
|
||||
audio: fakeAudio,
|
||||
servicio: fakeServicio,
|
||||
dispositivoAudio: fakeDispositivo,
|
||||
emisoraActualUuid: () => null,
|
||||
);
|
||||
await eq.cargarPersistido();
|
||||
await Future<void>.delayed(const Duration(milliseconds: 50));
|
||||
// Active device is btDevice; save for 'other_device'
|
||||
fakeAudio.presetsAplicados.clear();
|
||||
await eq.guardarPresetDispositivo('other_device', PresetEcualizador.rock);
|
||||
|
||||
// Preset saved but audio NOT re-applied since 'other_device' is not active
|
||||
expect(eq.presetsDispositivo['other_device'], equals(PresetEcualizador.rock));
|
||||
expect(fakeAudio.presetsAplicados, isEmpty);
|
||||
eq.dispose();
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// WARNING-3: FakeServicioEcualizador preserves nombresDispositivos
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
group('FakeServicioEcualizador — nombresDispositivos preservation (WARNING-3)', () {
|
||||
test(
|
||||
'guardarPrincipal preserves nombresDispositivos',
|
||||
() async {
|
||||
final fake = FakeServicioEcualizador(
|
||||
nombresDispositivos: {'dev1': 'My Speaker'},
|
||||
);
|
||||
await fake.guardarPrincipal(PresetEcualizador.jazz);
|
||||
expect(fake.config.nombresDispositivos['dev1'], equals('My Speaker'));
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'guardarActivo preserves nombresDispositivos',
|
||||
() async {
|
||||
final fake = FakeServicioEcualizador(
|
||||
nombresDispositivos: {'dev1': 'My Speaker'},
|
||||
);
|
||||
await fake.guardarActivo(false);
|
||||
expect(fake.config.nombresDispositivos['dev1'], equals('My Speaker'));
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -287,6 +287,7 @@ class FakeServicioEcualizador extends ServicioEcualizador {
|
||||
bool eqMultiDeviceEnabled = false,
|
||||
Map<String, PresetEcualizador>? presetsDispositivo,
|
||||
Map<String, PresetEcualizador>? presetsMatriz,
|
||||
Map<String, String>? nombresDispositivos,
|
||||
}) : _config = ConfiguracionEcualizador(
|
||||
principal: principal ?? PresetEcualizador.flat,
|
||||
porEmisora: porEmisora ?? {},
|
||||
@@ -294,6 +295,7 @@ class FakeServicioEcualizador extends ServicioEcualizador {
|
||||
eqMultiDeviceEnabled: eqMultiDeviceEnabled,
|
||||
presetsDispositivo: presetsDispositivo ?? {},
|
||||
presetsMatriz: presetsMatriz ?? {},
|
||||
nombresDispositivos: nombresDispositivos ?? {},
|
||||
);
|
||||
|
||||
ConfiguracionEcualizador _config;
|
||||
@@ -311,6 +313,7 @@ class FakeServicioEcualizador extends ServicioEcualizador {
|
||||
eqMultiDeviceEnabled: _config.eqMultiDeviceEnabled,
|
||||
presetsDispositivo: _config.presetsDispositivo,
|
||||
presetsMatriz: _config.presetsMatriz,
|
||||
nombresDispositivos: _config.nombresDispositivos,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -323,6 +326,7 @@ class FakeServicioEcualizador extends ServicioEcualizador {
|
||||
eqMultiDeviceEnabled: _config.eqMultiDeviceEnabled,
|
||||
presetsDispositivo: _config.presetsDispositivo,
|
||||
presetsMatriz: _config.presetsMatriz,
|
||||
nombresDispositivos: _config.nombresDispositivos,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -399,8 +403,45 @@ class FakeServicioEcualizador extends ServicioEcualizador {
|
||||
eqMultiDeviceEnabled: _config.eqMultiDeviceEnabled,
|
||||
presetsDispositivo: _config.presetsDispositivo,
|
||||
presetsMatriz: mapa,
|
||||
nombresDispositivos: _config.nombresDispositivos,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> guardarNombresDispositivos(Map<String, String> nombres) async {
|
||||
_config = ConfiguracionEcualizador(
|
||||
principal: _config.principal,
|
||||
porEmisora: _config.porEmisora,
|
||||
activo: _config.activo,
|
||||
eqMultiDeviceEnabled: _config.eqMultiDeviceEnabled,
|
||||
presetsDispositivo: _config.presetsDispositivo,
|
||||
presetsMatriz: _config.presetsMatriz,
|
||||
nombresDispositivos: nombres,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A [ServicioDispositivoAudio] fake that throws on [obtenerDispositivoActual].
|
||||
///
|
||||
/// Used to test the graceful-failure path in [EstadoEcualizador.cargarPersistido].
|
||||
class FakeServicioDispositivoAudioThrows extends ServicioDispositivoAudio {
|
||||
final _controller = StreamController<DispositivoAudio>.broadcast();
|
||||
|
||||
@override
|
||||
DispositivoAudio? get dispositivoActual => null;
|
||||
|
||||
@override
|
||||
Stream<DispositivoAudio> get onDispositivoCambiado => _controller.stream;
|
||||
|
||||
@override
|
||||
Future<DispositivoAudio> obtenerDispositivoActual() async {
|
||||
throw Exception('Platform channel error: device unavailable');
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> dispose() async {
|
||||
await _controller.close();
|
||||
}
|
||||
}
|
||||
|
||||
/// Fake implementation of [ServicioDispositivoAudio] for unit tests.
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import 'dart:ui';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:pluriwave/l10n/gen/app_localizations.dart';
|
||||
|
||||
void main() {
|
||||
group('preNoticeCountdown ARB key', () {
|
||||
test('English returns expected sentence with minutes placeholder', () {
|
||||
final l10n = lookupAppLocalizations(const Locale('en'));
|
||||
expect(l10n.preNoticeCountdown(30), 'Starts in 30 min');
|
||||
expect(l10n.preNoticeCountdown(1), 'Starts in 1 min');
|
||||
});
|
||||
|
||||
test('Spanish returns localized sentence', () {
|
||||
final l10n = lookupAppLocalizations(const Locale('es'));
|
||||
expect(l10n.preNoticeCountdown(30), 'Empieza en 30 min');
|
||||
});
|
||||
|
||||
test('Arabic returns localized sentence', () {
|
||||
final l10n = lookupAppLocalizations(const Locale('ar'));
|
||||
expect(l10n.preNoticeCountdown(5), 'يبدأ خلال 5 دقيقة');
|
||||
});
|
||||
|
||||
test('German returns localized sentence', () {
|
||||
final l10n = lookupAppLocalizations(const Locale('de'));
|
||||
expect(l10n.preNoticeCountdown(10), 'Startet in 10 Min.');
|
||||
});
|
||||
|
||||
test('French returns localized sentence', () {
|
||||
final l10n = lookupAppLocalizations(const Locale('fr'));
|
||||
expect(l10n.preNoticeCountdown(15), 'Démarre dans 15 min');
|
||||
});
|
||||
|
||||
test('Portuguese returns localized sentence', () {
|
||||
final l10n = lookupAppLocalizations(const Locale('pt'));
|
||||
expect(l10n.preNoticeCountdown(20), 'Começa em 20 min');
|
||||
});
|
||||
|
||||
test('Italian returns localized sentence', () {
|
||||
final l10n = lookupAppLocalizations(const Locale('it'));
|
||||
expect(l10n.preNoticeCountdown(25), 'Inizia tra 25 min');
|
||||
});
|
||||
|
||||
test('Japanese returns localized sentence', () {
|
||||
final l10n = lookupAppLocalizations(const Locale('ja'));
|
||||
expect(l10n.preNoticeCountdown(30), '30分後に開始');
|
||||
});
|
||||
|
||||
test('Russian returns localized sentence', () {
|
||||
final l10n = lookupAppLocalizations(const Locale('ru'));
|
||||
expect(l10n.preNoticeCountdown(30), 'Начнётся через 30 мин');
|
||||
});
|
||||
|
||||
test('Chinese returns localized sentence', () {
|
||||
final l10n = lookupAppLocalizations(const Locale('zh'));
|
||||
expect(l10n.preNoticeCountdown(30), '30分钟后开始');
|
||||
});
|
||||
|
||||
test('Hindi returns localized sentence', () {
|
||||
final l10n = lookupAppLocalizations(const Locale('hi'));
|
||||
expect(l10n.preNoticeCountdown(30), '30 मिनट में शुरू होगा');
|
||||
});
|
||||
|
||||
test('Bengali returns localized sentence', () {
|
||||
final l10n = lookupAppLocalizations(const Locale('bn'));
|
||||
expect(l10n.preNoticeCountdown(30), '30 মিনিটে শুরু হবে');
|
||||
});
|
||||
|
||||
test('Indonesian returns localized sentence', () {
|
||||
final l10n = lookupAppLocalizations(const Locale('id'));
|
||||
expect(l10n.preNoticeCountdown(30), 'Mulai dalam 30 menit');
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import 'package:pluriwave/estado/estado_grabacion.dart';
|
||||
import 'package:pluriwave/estado/estado_idioma.dart';
|
||||
import 'package:pluriwave/estado/estado_radio.dart';
|
||||
import 'package:pluriwave/l10n/gen/app_localizations.dart';
|
||||
import 'package:pluriwave/modelos/dispositivo_audio.dart';
|
||||
import 'package:pluriwave/modelos/preset_ecualizador.dart';
|
||||
import 'package:pluriwave/pantallas/pantalla_ajustes.dart';
|
||||
import 'package:pluriwave/servicios/servicio_grabacion_radio.dart';
|
||||
@@ -92,6 +93,42 @@ void main() {
|
||||
await tester.pumpAndSettle(const Duration(milliseconds: 100));
|
||||
}
|
||||
|
||||
// Also update crearEstado to support nombresDispositivos
|
||||
Future<EstadoRadio> crearEstadoConNombres({
|
||||
bool eqMultiDeviceEnabled = true,
|
||||
Map<String, PresetEcualizador>? presetsDispositivo,
|
||||
Map<String, String>? nombresDispositivos,
|
||||
String? activeDeviceId,
|
||||
}) async {
|
||||
final fakeDispositivo = activeDeviceId != null
|
||||
? (FakeServicioDispositivoAudio()
|
||||
..emitirDispositivo(
|
||||
DispositivoAudio(
|
||||
id: activeDeviceId,
|
||||
tipo: TipoDispositivo.bluetoothA2dp,
|
||||
nombre: 'BT Speaker',
|
||||
),
|
||||
))
|
||||
: null;
|
||||
final estado = EstadoRadio(
|
||||
audio: FakeServicioAudio(),
|
||||
favoritos: FakeServicioFavoritos(),
|
||||
radio: FakeServicioRadio(),
|
||||
servicioEcualizador: FakeServicioEcualizador(
|
||||
eqMultiDeviceEnabled: eqMultiDeviceEnabled,
|
||||
presetsDispositivo: presetsDispositivo ??
|
||||
{'bt_a2dp:AA:BB': PresetEcualizador.rock},
|
||||
nombresDispositivos: nombresDispositivos ?? {},
|
||||
),
|
||||
servicioGrabacion: _FakeGrabacion(),
|
||||
resolverArchivoCustom: _archivoCustomVacio,
|
||||
iniciarAutomaticamente: false,
|
||||
dispositivoAudio: fakeDispositivo,
|
||||
);
|
||||
await estado.ecualizador.cargarPersistido();
|
||||
return estado;
|
||||
}
|
||||
|
||||
// ── Phase 7 tests ──────────────────────────────────────────────────────────
|
||||
|
||||
group('_SeccionEcualizadorAvanzado (Phase 7)', () {
|
||||
@@ -194,6 +231,155 @@ void main() {
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
// ── Phase 3 (eq-device-autoswitch-ux): connection indicator + modal ──────────
|
||||
|
||||
group('SeccionEcualizadorAvanzado connection indicator Phase 3', () {
|
||||
// 3.1 RED — active device row shows green connection dot
|
||||
testWidgets('3.1 active device row shows green connection indicator', (tester) async {
|
||||
setLargeSurface(tester);
|
||||
_suppressListTileInkAssertion();
|
||||
const activeId = 'bt_a2dp:AA:BB';
|
||||
final estado = await crearEstadoConNombres(
|
||||
eqMultiDeviceEnabled: true,
|
||||
presetsDispositivo: {
|
||||
activeId: PresetEcualizador.rock,
|
||||
'wired_headset': PresetEcualizador.jazz,
|
||||
},
|
||||
activeDeviceId: activeId,
|
||||
);
|
||||
addTearDown(estado.dispose);
|
||||
|
||||
await tester.pumpWidget(buildAjustes(estado));
|
||||
await pumpStable(tester);
|
||||
|
||||
await tester.scrollUntilVisible(
|
||||
find.text('Known audio devices'),
|
||||
300,
|
||||
scrollable: find.byType(Scrollable).first,
|
||||
);
|
||||
await pumpStable(tester);
|
||||
|
||||
// Green connection dot should be present (Icon with green color for active device)
|
||||
final greenIcons = tester.widgetList<Icon>(find.byType(Icon)).where(
|
||||
(icon) => icon.color == Colors.green,
|
||||
);
|
||||
// At least one green icon present
|
||||
expect(greenIcons, isNotEmpty);
|
||||
});
|
||||
|
||||
// 3.2 RED — tapping device row opens bottom sheet with TextField and EcualizadorWidget
|
||||
testWidgets('3.2 tapping device row opens modal with TextField', (tester) async {
|
||||
setLargeSurface(tester);
|
||||
_suppressListTileInkAssertion();
|
||||
final estado = await crearEstadoConNombres(
|
||||
presetsDispositivo: {'bt_a2dp:AA:BB': PresetEcualizador.rock},
|
||||
);
|
||||
addTearDown(estado.dispose);
|
||||
|
||||
await tester.pumpWidget(buildAjustes(estado));
|
||||
await pumpStable(tester);
|
||||
|
||||
await tester.scrollUntilVisible(
|
||||
find.text('Known audio devices'),
|
||||
300,
|
||||
scrollable: find.byType(Scrollable).first,
|
||||
);
|
||||
await pumpStable(tester);
|
||||
|
||||
// Tap the device row (tap the edit icon or the row itself)
|
||||
final editIcons = find.byIcon(Icons.edit_rounded);
|
||||
expect(editIcons, findsWidgets);
|
||||
await tester.tap(editIcons.first);
|
||||
await pumpStable(tester);
|
||||
|
||||
// Bottom sheet should appear with a TextField
|
||||
expect(find.byType(TextField), findsWidgets);
|
||||
});
|
||||
|
||||
// 3.6 RED — renaming in modal and confirming calls renombrarDispositivo
|
||||
testWidgets('3.6 confirming rename in modal updates device name', (tester) async {
|
||||
setLargeSurface(tester);
|
||||
_suppressListTileInkAssertion();
|
||||
const deviceId = 'bt_a2dp:AA:BB';
|
||||
final estado = await crearEstadoConNombres(
|
||||
presetsDispositivo: {deviceId: PresetEcualizador.rock},
|
||||
);
|
||||
addTearDown(estado.dispose);
|
||||
|
||||
await tester.pumpWidget(buildAjustes(estado));
|
||||
await pumpStable(tester);
|
||||
|
||||
await tester.scrollUntilVisible(
|
||||
find.text('Known audio devices'),
|
||||
300,
|
||||
scrollable: find.byType(Scrollable).first,
|
||||
);
|
||||
await pumpStable(tester);
|
||||
|
||||
// Open modal
|
||||
await tester.tap(find.byIcon(Icons.edit_rounded).first);
|
||||
await pumpStable(tester);
|
||||
|
||||
// Enter a new name
|
||||
final textField = find.byType(TextField).first;
|
||||
await tester.enterText(textField, 'My Living Room Speaker');
|
||||
await pumpStable(tester);
|
||||
|
||||
// Tap confirm/save button
|
||||
final saveButton = find.byIcon(Icons.save_rounded);
|
||||
expect(saveButton, findsWidgets);
|
||||
await tester.tap(saveButton.first);
|
||||
await pumpStable(tester);
|
||||
|
||||
// Device should now have the new name
|
||||
expect(
|
||||
estado.ecualizador.obtenerNombreDispositivo(deviceId),
|
||||
equals('My Living Room Speaker'),
|
||||
);
|
||||
});
|
||||
|
||||
// 3.7 RED — dismissing modal without confirming leaves name unchanged
|
||||
testWidgets('3.7 dismissing modal without confirming leaves name unchanged', (tester) async {
|
||||
setLargeSurface(tester);
|
||||
_suppressListTileInkAssertion();
|
||||
const deviceId = 'bt_a2dp:AA:BB';
|
||||
final estado = await crearEstadoConNombres(
|
||||
presetsDispositivo: {deviceId: PresetEcualizador.rock},
|
||||
nombresDispositivos: {deviceId: 'Original Name'},
|
||||
);
|
||||
addTearDown(estado.dispose);
|
||||
|
||||
await tester.pumpWidget(buildAjustes(estado));
|
||||
await pumpStable(tester);
|
||||
|
||||
await tester.scrollUntilVisible(
|
||||
find.text('Known audio devices'),
|
||||
300,
|
||||
scrollable: find.byType(Scrollable).first,
|
||||
);
|
||||
await pumpStable(tester);
|
||||
|
||||
// Open modal
|
||||
await tester.tap(find.byIcon(Icons.edit_rounded).first);
|
||||
await pumpStable(tester);
|
||||
|
||||
// Change the text but do NOT confirm
|
||||
final textField = find.byType(TextField).first;
|
||||
await tester.enterText(textField, 'New Name Not Saved');
|
||||
await pumpStable(tester);
|
||||
|
||||
// Dismiss by pressing back/escape
|
||||
await tester.tapAt(const Offset(100, 100)); // tap outside bottom sheet
|
||||
await pumpStable(tester);
|
||||
|
||||
// Name should remain unchanged
|
||||
expect(
|
||||
estado.ecualizador.obtenerNombreDispositivo(deviceId),
|
||||
equals('Original Name'),
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ── Infrastructure ──────────────────────────────────────────────────────────
|
||||
|
||||
@@ -0,0 +1,279 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.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/modelos/emisora.dart';
|
||||
import 'package:pluriwave/pantallas/pantalla_alarma_sonando.dart';
|
||||
import 'package:pluriwave/servicios/servicio_alarmas.dart';
|
||||
import 'package:pluriwave/servicios/servicio_audio.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import '../helpers/fakes.dart';
|
||||
import '../helpers/fakes_alarmas.dart';
|
||||
|
||||
// Tracks SystemNavigator.pop() calls via the platform channel mock.
|
||||
class _SystemNavigatorSpy {
|
||||
int popCalls = 0;
|
||||
|
||||
void install() {
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||
.setMockMethodCallHandler(
|
||||
SystemChannels.platform,
|
||||
(call) async {
|
||||
if (call.method == 'SystemNavigator.pop') {
|
||||
popCalls++;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
void uninstall() {
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||
.setMockMethodCallHandler(SystemChannels.platform, null);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _montarComoRaiz(
|
||||
WidgetTester tester, {
|
||||
required FakePuertoAlarmasAndroid android,
|
||||
required EstadoAlarmas estadoAlarmas,
|
||||
required EstadoRadio radio,
|
||||
}) async {
|
||||
tester.view.physicalSize = const Size(1440, 3200);
|
||||
tester.view.devicePixelRatio = 1.0;
|
||||
addTearDown(tester.view.resetPhysicalSize);
|
||||
addTearDown(tester.view.resetDevicePixelRatio);
|
||||
|
||||
// Mount alarm screen as ROOT route — simulates dead-app FSI launch.
|
||||
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: PantallaAlarmaSonando(
|
||||
alarma: estadoAlarmas.alarmas.single,
|
||||
audioPrearrancado: true,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
Future<void> _montarConHistorial(
|
||||
WidgetTester tester, {
|
||||
required FakePuertoAlarmasAndroid android,
|
||||
required EstadoAlarmas estadoAlarmas,
|
||||
required EstadoRadio radio,
|
||||
}) async {
|
||||
tester.view.physicalSize = const Size(1440, 3200);
|
||||
tester.view.devicePixelRatio = 1.0;
|
||||
addTearDown(tester.view.resetPhysicalSize);
|
||||
addTearDown(tester.view.resetDevicePixelRatio);
|
||||
|
||||
// Mount with a previous route so canPop() returns true.
|
||||
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 SizedBox.shrink(),
|
||||
),
|
||||
),
|
||||
);
|
||||
final navigator = tester.state<NavigatorState>(find.byType(Navigator));
|
||||
unawaited(
|
||||
navigator.push(
|
||||
MaterialPageRoute<void>(
|
||||
builder: (_) => PantallaAlarmaSonando(
|
||||
alarma: estadoAlarmas.alarmas.single,
|
||||
audioPrearrancado: true,
|
||||
),
|
||||
fullscreenDialog: true,
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
Future<_Env> _buildEnv() async {
|
||||
final audio = FakeServicioAudio();
|
||||
audio.emitirEstado(EstadoReproduccion.reproduciendo);
|
||||
final radio = EstadoRadio(
|
||||
audio: audio,
|
||||
favoritos: FakeServicioFavoritos(),
|
||||
radio: FakeServicioRadio(),
|
||||
servicioEcualizador: FakeServicioEcualizador(),
|
||||
servicioGrabacion: FakeServicioGrabacionRadioInactiva(),
|
||||
iniciarAutomaticamente: false,
|
||||
);
|
||||
final android = FakePuertoAlarmasAndroid();
|
||||
final estadoAlarmas = EstadoAlarmas(
|
||||
servicio: ServicioAlarmas(reloj: () => DateTime(2026, 6, 11, 7, 0)),
|
||||
android: android,
|
||||
iniciarAutomaticamente: false,
|
||||
);
|
||||
await estadoAlarmas.guardarAlarma(
|
||||
AlarmaMusical(
|
||||
id: 'dismiss-test',
|
||||
nombre: 'Despertar',
|
||||
hora: 7,
|
||||
minuto: 30,
|
||||
tipoProgramacion: TipoProgramacionAlarma.diaria,
|
||||
diasSemana: const [],
|
||||
snoozeMinutos: 5,
|
||||
emisora: const Emisora(
|
||||
uuid: 'e1',
|
||||
nombre: 'Radio Uno',
|
||||
url: 'https://radio.example/stream',
|
||||
),
|
||||
),
|
||||
);
|
||||
return _Env(radio: radio, android: android, estadoAlarmas: estadoAlarmas);
|
||||
}
|
||||
|
||||
class _Env {
|
||||
_Env({
|
||||
required this.radio,
|
||||
required this.android,
|
||||
required this.estadoAlarmas,
|
||||
});
|
||||
final EstadoRadio radio;
|
||||
final FakePuertoAlarmasAndroid android;
|
||||
final EstadoAlarmas estadoAlarmas;
|
||||
|
||||
void dispose() {
|
||||
estadoAlarmas.dispose();
|
||||
android.dispose();
|
||||
radio.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
void main() {
|
||||
final l10n = lookupAppLocalizations(const Locale('es'));
|
||||
|
||||
setUp(() {
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
});
|
||||
|
||||
group('PantallaAlarmaSonando dismiss guard (Phase 5)', () {
|
||||
testWidgets(
|
||||
'posponer: cuando canPop es true, Navigator.pop es llamado y SystemNavigator.pop NO (S5-R1-A)',
|
||||
(tester) async {
|
||||
final env = await _buildEnv();
|
||||
addTearDown(env.dispose);
|
||||
final spy = _SystemNavigatorSpy()..install();
|
||||
addTearDown(spy.uninstall);
|
||||
|
||||
await _montarConHistorial(
|
||||
tester,
|
||||
android: env.android,
|
||||
estadoAlarmas: env.estadoAlarmas,
|
||||
radio: env.radio,
|
||||
);
|
||||
|
||||
// Verify the alarm screen is on top of a stack (canPop == true)
|
||||
expect(find.byType(PantallaAlarmaSonando), findsOneWidget);
|
||||
|
||||
await tester.tap(find.text(l10n.alarmSnoozeOptionLabel(5)));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Screen should be dismissed via Navigator.pop (stack pop)
|
||||
expect(find.byType(PantallaAlarmaSonando), findsNothing);
|
||||
// SystemNavigator.pop must NOT have been called
|
||||
expect(spy.popCalls, 0,
|
||||
reason: 'SystemNavigator.pop must not be called when canPop is true');
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets(
|
||||
'posponer: cuando canPop es false (root), SystemNavigator.pop es llamado (S5-R1-B)',
|
||||
(tester) async {
|
||||
final env = await _buildEnv();
|
||||
addTearDown(env.dispose);
|
||||
final spy = _SystemNavigatorSpy()..install();
|
||||
addTearDown(spy.uninstall);
|
||||
|
||||
await _montarComoRaiz(
|
||||
tester,
|
||||
android: env.android,
|
||||
estadoAlarmas: env.estadoAlarmas,
|
||||
radio: env.radio,
|
||||
);
|
||||
|
||||
expect(find.byType(PantallaAlarmaSonando), findsOneWidget);
|
||||
|
||||
await tester.tap(find.text(l10n.alarmSnoozeOptionLabel(5)));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// SystemNavigator.pop must be called exactly once
|
||||
expect(spy.popCalls, 1,
|
||||
reason: 'SystemNavigator.pop must be called when canPop is false');
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets(
|
||||
'detener: cuando canPop es true, Navigator.pop es llamado y SystemNavigator.pop NO (S5-R1-A)',
|
||||
(tester) async {
|
||||
final env = await _buildEnv();
|
||||
addTearDown(env.dispose);
|
||||
final spy = _SystemNavigatorSpy()..install();
|
||||
addTearDown(spy.uninstall);
|
||||
|
||||
await _montarConHistorial(
|
||||
tester,
|
||||
android: env.android,
|
||||
estadoAlarmas: env.estadoAlarmas,
|
||||
radio: env.radio,
|
||||
);
|
||||
|
||||
await tester.tap(find.text(l10n.stopAlarmAction));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byType(PantallaAlarmaSonando), findsNothing);
|
||||
expect(spy.popCalls, 0,
|
||||
reason: 'SystemNavigator.pop must not be called when canPop is true');
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets(
|
||||
'detener: cuando canPop es false (root), SystemNavigator.pop es llamado (S5-R1-B)',
|
||||
(tester) async {
|
||||
final env = await _buildEnv();
|
||||
addTearDown(env.dispose);
|
||||
final spy = _SystemNavigatorSpy()..install();
|
||||
addTearDown(spy.uninstall);
|
||||
|
||||
await _montarComoRaiz(
|
||||
tester,
|
||||
android: env.android,
|
||||
estadoAlarmas: env.estadoAlarmas,
|
||||
radio: env.radio,
|
||||
);
|
||||
|
||||
await tester.tap(find.text(l10n.stopAlarmAction));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(spy.popCalls, 1,
|
||||
reason: 'SystemNavigator.pop must be called when canPop is false');
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:pluriwave/modelos/alarma_musical.dart';
|
||||
import 'package:pluriwave/servicios/servicio_alarmas_android.dart';
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
const channel = MethodChannel('pluriwave/alarm_scheduler');
|
||||
late List<MethodCall> llamadas;
|
||||
|
||||
setUp(() {
|
||||
llamadas = [];
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||
.setMockMethodCallHandler(channel, (call) async {
|
||||
llamadas.add(call);
|
||||
if (call.method == 'scheduleAlarm') return true;
|
||||
return null;
|
||||
});
|
||||
});
|
||||
|
||||
tearDown(() {
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||
.setMockMethodCallHandler(channel, null);
|
||||
});
|
||||
|
||||
test(
|
||||
'programar includes preNoticeTemplate with {minutes} placeholder in MethodChannel call',
|
||||
() async {
|
||||
final servicio = ServicioAlarmasAndroid(channel: channel);
|
||||
final alarma = AlarmaMusical(
|
||||
id: 'test-alarm',
|
||||
nombre: 'Morning alarm',
|
||||
hora: 7,
|
||||
minuto: 0,
|
||||
tipoProgramacion: TipoProgramacionAlarma.diaria,
|
||||
diasSemana: const [],
|
||||
proximaEjecucion: DateTime(2099, 1, 1, 7, 0),
|
||||
);
|
||||
|
||||
await servicio.programar(alarma);
|
||||
|
||||
final llamada = llamadas.singleWhere((c) => c.method == 'scheduleAlarm');
|
||||
final args = llamada.arguments as Map<Object?, Object?>;
|
||||
expect(args.containsKey('preNoticeTemplate'), isTrue,
|
||||
reason: 'preNoticeTemplate must be present in scheduleAlarm args');
|
||||
final template = args['preNoticeTemplate'] as String?;
|
||||
expect(template, isNotNull,
|
||||
reason: 'preNoticeTemplate must not be null');
|
||||
expect(template, contains('{minutes}'),
|
||||
reason: 'preNoticeTemplate must contain the {minutes} placeholder');
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'programar preNoticeTemplate uses default locale fallback when no l10n configured',
|
||||
() async {
|
||||
// ServicioAlarmasAndroid falls back to es locale when no l10n is configured
|
||||
final servicio = ServicioAlarmasAndroid(channel: channel);
|
||||
final alarma = AlarmaMusical(
|
||||
id: 'test-alarm-2',
|
||||
nombre: 'Alarm',
|
||||
hora: 8,
|
||||
minuto: 30,
|
||||
tipoProgramacion: TipoProgramacionAlarma.diaria,
|
||||
diasSemana: const [],
|
||||
proximaEjecucion: DateTime(2099, 1, 2, 8, 30),
|
||||
);
|
||||
|
||||
await servicio.programar(alarma);
|
||||
|
||||
final llamada = llamadas.singleWhere((c) => c.method == 'scheduleAlarm');
|
||||
final args = llamada.arguments as Map<Object?, Object?>;
|
||||
final template = args['preNoticeTemplate'] as String?;
|
||||
// The template must contain the literal placeholder string
|
||||
expect(template, contains('{minutes}'));
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -169,4 +169,64 @@ void main() {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Phase 1 (eq-device-autoswitch-ux): nombresDispositivos persistence
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
group('ServicioEcualizador — nombresDispositivos (eq-device-autoswitch-ux)', () {
|
||||
// 1.1 RED — cargarNombresDispositivos returns empty map when SP key absent
|
||||
test('1.1 cargar returns empty nombresDispositivos when SP key absent', () async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final servicio = ServicioEcualizador(prefs: prefs);
|
||||
final config = await servicio.cargar();
|
||||
expect(config.nombresDispositivos, isEmpty);
|
||||
});
|
||||
|
||||
// 1.2 RED — guardarNombresDispositivos writes to eq_nombres_dispositivos_v1 SP key
|
||||
test('1.2 guardarNombresDispositivos persists names and cargar restores them', () async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final servicio = ServicioEcualizador(prefs: prefs);
|
||||
|
||||
await servicio.guardarNombresDispositivos({'bt_a2dp:AA:BB': 'Living Room BT'});
|
||||
final config = await servicio.cargar();
|
||||
|
||||
expect(config.nombresDispositivos['bt_a2dp:AA:BB'], equals('Living Room BT'));
|
||||
});
|
||||
|
||||
// 1.3 RED — round-trip: save → load returns same map
|
||||
test('1.3 round-trip save → cargar preserves all entries', () async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final servicio = ServicioEcualizador(prefs: prefs);
|
||||
const nombres = {
|
||||
'bt_a2dp:AA:BB': 'Living Room BT',
|
||||
'wired_headset': 'Office Headset',
|
||||
'builtin_speaker': 'Built-in',
|
||||
};
|
||||
|
||||
await servicio.guardarNombresDispositivos(nombres);
|
||||
final config = await servicio.cargar();
|
||||
|
||||
expect(config.nombresDispositivos, equals(nombres));
|
||||
});
|
||||
|
||||
// guardarConfiguracion also persists nombresDispositivos
|
||||
test('1.7 guardarConfiguracion persists nombresDispositivos round-trip', () async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final servicio = ServicioEcualizador(prefs: prefs);
|
||||
final config = ConfiguracionEcualizador(
|
||||
principal: PresetEcualizador.flat,
|
||||
porEmisora: {},
|
||||
eqMultiDeviceEnabled: true,
|
||||
presetsDispositivo: {},
|
||||
presetsMatriz: {},
|
||||
nombresDispositivos: {'bt_a2dp:AA:BB': 'My Speaker'},
|
||||
);
|
||||
|
||||
await servicio.guardarConfiguracion(config);
|
||||
final restored = await servicio.cargar();
|
||||
|
||||
expect(restored.nombresDispositivos['bt_a2dp:AA:BB'], equals('My Speaker'));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user