2 Commits
Author SHA1 Message Date
FreeTLab 4ffd73d136 fix(alarm): localize pre-notice countdown and fix snooze dismiss
Build & Deploy PluriWave / Análisis de código (push) Successful in 39s
Build & Deploy PluriWave / Build APK + AAB release (push) Successful in 2m33s
Replace hardcoded Spanish pre-notice text with computed remaining
minutes using l10n template passed via MethodChannel. Fix snooze
dismiss in dead-app state with canPop guard and SystemNavigator.pop
fallback.
2026-06-28 11:55:15 +02:00
FreeTLab 58922de6fc fix(eq): seed device ID at startup and add device management UI
Fix multi-device EQ auto-switching by calling obtenerDispositivoActual()
during cargarPersistido() to seed the initial device ID. Add device
management modal with rename support, EQ preset editing, and connection
status indicator. Translate device UI keys to all 13 locales.
2026-06-28 11:55:15 +02:00
42 changed files with 1971 additions and 64 deletions
@@ -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
+77
View File
@@ -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
View File
@@ -627,5 +627,18 @@
"placeholders": {
"presetName": {}
}
}
}
},
"preNoticeCountdown": "يبدأ خلال {minutes} دقيقة",
"@preNoticeCountdown": {
"placeholders": {
"minutes": {
"type": "int"
}
}
},
"eqDeviceEditTitle": "تعديل الجهاز",
"eqDeviceNameLabel": "اسم الجهاز",
"eqDeviceNameHint": "مثال: مكبر صوت غرفة المعيشة",
"eqDeviceNameConfirm": "حفظ",
"eqDeviceConnected": "متصل"
}
+15 -2
View File
@@ -627,5 +627,18 @@
"placeholders": {
"presetName": {}
}
}
}
},
"preNoticeCountdown": "{minutes} মিনিটে শুরু হবে",
"@preNoticeCountdown": {
"placeholders": {
"minutes": {
"type": "int"
}
}
},
"eqDeviceEditTitle": "ডিভাইস সম্পাদনা করুন",
"eqDeviceNameLabel": "ডিভাইসের নাম",
"eqDeviceNameHint": "যেমন: লিভিং রুমের স্পিকার",
"eqDeviceNameConfirm": "সংরক্ষণ করুন",
"eqDeviceConnected": "সংযুক্ত"
}
+15 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -627,5 +627,18 @@
"placeholders": {
"presetName": {}
}
}
}
},
"preNoticeCountdown": "{minutes} मिनट में शुरू होगा",
"@preNoticeCountdown": {
"placeholders": {
"minutes": {
"type": "int"
}
}
},
"eqDeviceEditTitle": "डिवाइस संपादित करें",
"eqDeviceNameLabel": "डिवाइस का नाम",
"eqDeviceNameHint": "उदा. लिविंग रूम स्पीकर",
"eqDeviceNameConfirm": "सहेजें",
"eqDeviceConnected": "कनेक्टेड"
}
+15 -2
View File
@@ -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
View File
@@ -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
View File
@@ -627,5 +627,18 @@
"placeholders": {
"presetName": {}
}
}
}
},
"preNoticeCountdown": "{minutes}分後に開始",
"@preNoticeCountdown": {
"placeholders": {
"minutes": {
"type": "int"
}
}
},
"eqDeviceEditTitle": "デバイスを編集",
"eqDeviceNameLabel": "デバイス名",
"eqDeviceNameHint": "例:リビングのスピーカー",
"eqDeviceNameConfirm": "保存",
"eqDeviceConnected": "接続中"
}
+15 -2
View File
@@ -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
View File
@@ -627,5 +627,18 @@
"placeholders": {
"presetName": {}
}
}
}
},
"preNoticeCountdown": "Начнётся через {minutes} мин",
"@preNoticeCountdown": {
"placeholders": {
"minutes": {
"type": "int"
}
}
},
"eqDeviceEditTitle": "Изменить устройство",
"eqDeviceNameLabel": "Название устройства",
"eqDeviceNameHint": "Напр.: Колонка в гостиной",
"eqDeviceNameConfirm": "Сохранить",
"eqDeviceConnected": "Подключено"
}
+15 -2
View File
@@ -627,5 +627,18 @@
"placeholders": {
"presetName": {}
}
}
}
},
"preNoticeCountdown": "{minutes}分钟后开始",
"@preNoticeCountdown": {
"placeholders": {
"minutes": {
"type": "int"
}
}
},
"eqDeviceEditTitle": "编辑设备",
"eqDeviceNameLabel": "设备名称",
"eqDeviceNameHint": "例:客厅音箱",
"eqDeviceNameConfirm": "保存",
"eqDeviceConnected": "已连接"
}
+36
View File
@@ -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
+20
View File
@@ -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 => 'متصل';
}
+20
View File
@@ -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 => 'সংযুক্ত';
}
+20
View File
@@ -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';
}
+20
View File
@@ -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';
}
+20
View File
@@ -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';
}
+20
View File
@@ -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é';
}
+20
View File
@@ -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 => 'कनेक्टेड';
}
+20
View File
@@ -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';
}
+20
View File
@@ -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';
}
+20
View File
@@ -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 => '接続中';
}
+20
View File
@@ -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';
}
+20
View File
@@ -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 => 'Подключено';
}
+20
View File
@@ -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 => '已连接';
}
+160 -27
View File
@@ -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();
+18 -4
View File
@@ -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
+44
View File
@@ -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) {
+404
View File
@@ -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'));
},
);
});
}
+41
View File
@@ -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.
+74
View File
@@ -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');
});
});
}
+186
View File
@@ -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'));
});
});
}