fix: address the issues found in on-device testing
Build & Deploy PluriWave / Análisis de código (push) Successful in 28s
Build & Deploy PluriWave / Build APK + AAB release (push) Successful in 2m18s

Seven of the eight points reported after the first real build.

- Favourites overflow menu was clipped to one letter per item by a
  constraints property that sizes the popup, not the button
- Bottom bar painted a square ink splash over the icon, and its lift,
  dim, icon size and label snapped while the balloon slid
- Station artwork fallback is now shared by every surface instead of the
  flat rows painting a plain coloured square
- Vacation ranges can be edited and deleted
- Settings row titles no longer wrap into cut lines
- Sleep timer sheet shows the live countdown
- The last-played station survives a restart, shown stopped

Spacing review is done for Buscar only; the rest of the app is still
outstanding.

Tests: 903 -> 926.
This commit is contained in:
2026-07-30 21:47:13 +02:00
54 changed files with 1887 additions and 500 deletions
+13
View File
@@ -354,6 +354,19 @@ class EstadoAlarmas extends ChangeNotifier {
await guardarVacaciones(nuevos); await guardarVacaciones(nuevos);
} }
/// Issue 1 (feedback-pruebas): replaces the range with the same [id] in
/// place -- the counterpart `crearRangoVacaciones`/`eliminarRangoVacaciones`
/// were missing before this fix, leaving no way to fix a mistake in an
/// already-saved range (including the currently ACTIVE one, since a
/// freshly created range starts active immediately).
Future<void> editarRangoVacaciones(RangoVacaciones rango) async {
final nuevos = [
for (final actual in _vacaciones)
if (actual.id == rango.id) rango else actual,
];
await guardarVacaciones(nuevos);
}
// ── Vacation queries (design ADR-6, WU9) ────────────────────────────── // ── Vacation queries (design ADR-6, WU9) ──────────────────────────────
// Four PURE queries: none writes, none reschedules, none touches the // Four PURE queries: none writes, none reschedules, none touches the
// native bridge. Read-only over `_alarmas`/`_vacaciones`. Every method // native bridge. Read-only over `_alarmas`/`_vacaciones`. Every method
+55
View File
@@ -173,6 +173,10 @@ class EstadoRadio extends ChangeNotifier {
static const _keyEmisoraPreferida = 'emisora_preferida_uuid_v1'; static const _keyEmisoraPreferida = 'emisora_preferida_uuid_v1';
static const _keyOrdenListas = 'orden_listas_emisoras_v1'; static const _keyOrdenListas = 'orden_listas_emisoras_v1';
static const _keyTimerSuenoPresets = 'timer_sueno_presets_segundos_v1'; static const _keyTimerSuenoPresets = 'timer_sueno_presets_segundos_v1';
// Issue 4 (feedback-pruebas): last-played station, so the Escuchar hero
// keeps showing "what I was listening to" (stopped, not playing) after a
// full app restart instead of going empty.
static const _keyUltimaEmisora = 'ultima_emisora_v1';
static const _timerSuenoPresetsDefecto = <int>[ static const _timerSuenoPresetsDefecto = <int>[
180, 180,
300, 300,
@@ -300,6 +304,50 @@ class EstadoRadio extends ChangeNotifier {
_cargarEmisorasCustom(), _cargarEmisorasCustom(),
]); ]);
await _normalizarEmisoraPreferida(); await _normalizarEmisoraPreferida();
await _restaurarUltimaEmisora();
}
/// Issue 4 (feedback-pruebas): restores the last-played station as a
/// STOPPED `emisoraActual` on a cold start. Only fills the gap — if
/// something is ALREADY selected (a real play already ran concurrently),
/// this is a no-op. Never touches `audio`: no playback starts, no network
/// request is made, `estadoStream`/`estaSonando` stay at their fresh
/// "detenido" default, exactly like every other consumer of
/// `emisoraActual` already expects (they gate "is it playing" on the
/// separate playback-status stream, never on `emisoraActual != null`).
Future<void> _restaurarUltimaEmisora() async {
if (_emisoraSeleccionada != null || audio.emisoraActual != null) return;
try {
final prefs = await _resolverPrefs();
final raw = prefs.getString(_keyUltimaEmisora);
if (raw == null) return;
final mapa = jsonDecode(raw) as Map<String, dynamic>;
_emisoraSeleccionada = Emisora.fromMap(mapa);
} catch (e) {
registrarSaltoPersistencia(
subsistema: 'ultima_emisora',
detalle: 'restaurar',
razon: e.toString(),
);
}
}
/// Best-effort remembers [emisora] as the last used station (issue 4) so
/// [_restaurarUltimaEmisora] can bring it back after a restart. Fire-and-
/// forget, same treatment [reproducir] already gives other non-critical
/// side effects (e.g. `radio.registrarClick`) — a failed write here must
/// never block or fail actual playback.
Future<void> _persistirUltimaEmisora(Emisora emisora) async {
try {
final prefs = await _resolverPrefs();
await prefs.setString(_keyUltimaEmisora, jsonEncode(emisora.toMap()));
} catch (e) {
registrarSaltoPersistencia(
subsistema: 'ultima_emisora',
detalle: 'persistir ${emisora.uuid}',
razon: e.toString(),
);
}
} }
/// Escucha el stream de estado del audio y gestiona errores de reproducción. /// Escucha el stream de estado del audio y gestiona errores de reproducción.
@@ -321,6 +369,9 @@ class EstadoRadio extends ChangeNotifier {
final actual = audio.emisoraActual; final actual = audio.emisoraActual;
if (actual != null && actual.uuid != _emisoraSeleccionada?.uuid) { if (actual != null && actual.uuid != _emisoraSeleccionada?.uuid) {
_emisoraSeleccionada = actual; _emisoraSeleccionada = actual;
// Issue 4: an Android-Auto-initiated selection is a real station
// change too — remember it the same way `reproducir` does.
unawaited(_persistirUltimaEmisora(actual));
} }
notifyListeners(); notifyListeners();
}); });
@@ -508,6 +559,10 @@ class EstadoRadio extends ChangeNotifier {
} }
_emisoraSeleccionada = emisora; _emisoraSeleccionada = emisora;
notifyListeners(); notifyListeners();
// Issue 4: remembers the station the user just picked so it survives a
// restart — fire-and-forget, same treatment as `radio.registrarClick`
// below (a persistence failure here must never block playback).
unawaited(_persistirUltimaEmisora(emisora));
try { try {
await audio.reproducir(emisora); await audio.reproducir(emisora);
if (revision != _revisionReproduccion) return; if (revision != _revisionReproduccion) return;
+3
View File
@@ -468,6 +468,9 @@
"deleteRangeTooltip": "حذف النطاق", "deleteRangeTooltip": "حذف النطاق",
"vacationsDefaultName": "إجازات", "vacationsDefaultName": "إجازات",
"newVacationRangeTitle": "نطاق إجازة جديد", "newVacationRangeTitle": "نطاق إجازة جديد",
"editVacationRangeTitle": "تعديل نطاق الإجازة",
"vacationDeleteConfirmTitle": "هل تريد حذف نطاق الإجازة؟",
"vacationDeleteConfirmMessage": "لا يمكن التراجع عن هذا الإجراء.",
"startField": "البداية", "startField": "البداية",
"endField": "النهاية", "endField": "النهاية",
"saveRangeAction": "حفظ النطاق", "saveRangeAction": "حفظ النطاق",
+3
View File
@@ -468,6 +468,9 @@
"deleteRangeTooltip": "পরিসর মুছুন", "deleteRangeTooltip": "পরিসর মুছুন",
"vacationsDefaultName": "ছুটি", "vacationsDefaultName": "ছুটি",
"newVacationRangeTitle": "নতুন ছুটির পরিসর", "newVacationRangeTitle": "নতুন ছুটির পরিসর",
"editVacationRangeTitle": "ছুটির পরিসর সম্পাদনা করুন",
"vacationDeleteConfirmTitle": "ছুটির পরিসর মুছবেন?",
"vacationDeleteConfirmMessage": "এই পদক্ষেপ ফিরিয়ে নেওয়া যাবে না।",
"startField": "শুরু", "startField": "শুরু",
"endField": "শেষ", "endField": "শেষ",
"saveRangeAction": "পরিসর সংরক্ষণ করুন", "saveRangeAction": "পরিসর সংরক্ষণ করুন",
+3
View File
@@ -468,6 +468,9 @@
"deleteRangeTooltip": "Zeitraum löschen", "deleteRangeTooltip": "Zeitraum löschen",
"vacationsDefaultName": "Ferien", "vacationsDefaultName": "Ferien",
"newVacationRangeTitle": "Neuer Ferienzeitraum", "newVacationRangeTitle": "Neuer Ferienzeitraum",
"editVacationRangeTitle": "Ferienzeitraum bearbeiten",
"vacationDeleteConfirmTitle": "Ferienzeitraum löschen?",
"vacationDeleteConfirmMessage": "Dies kann nicht rückgängig gemacht werden.",
"startField": "Beginn", "startField": "Beginn",
"endField": "Ende", "endField": "Ende",
"saveRangeAction": "Zeitraum speichern", "saveRangeAction": "Zeitraum speichern",
+3
View File
@@ -580,6 +580,9 @@
"vacationNoActiveRangeHint": "No active vacation range right now.", "vacationNoActiveRangeHint": "No active vacation range right now.",
"vacationsDefaultName": "Vacation", "vacationsDefaultName": "Vacation",
"newVacationRangeTitle": "New vacation range", "newVacationRangeTitle": "New vacation range",
"editVacationRangeTitle": "Edit vacation range",
"vacationDeleteConfirmTitle": "Delete vacation range?",
"vacationDeleteConfirmMessage": "This can't be undone.",
"startField": "Start", "startField": "Start",
"endField": "End", "endField": "End",
"saveRangeAction": "Save range", "saveRangeAction": "Save range",
+3
View File
@@ -580,6 +580,9 @@
"vacationNoActiveRangeHint": "No hay un rango de vacaciones activo ahora mismo.", "vacationNoActiveRangeHint": "No hay un rango de vacaciones activo ahora mismo.",
"vacationsDefaultName": "Vacaciones", "vacationsDefaultName": "Vacaciones",
"newVacationRangeTitle": "Nuevo rango de vacaciones", "newVacationRangeTitle": "Nuevo rango de vacaciones",
"editVacationRangeTitle": "Editar rango de vacaciones",
"vacationDeleteConfirmTitle": "¿Eliminar rango de vacaciones?",
"vacationDeleteConfirmMessage": "Esta acción no se puede deshacer.",
"startField": "Inicio", "startField": "Inicio",
"endField": "Fin", "endField": "Fin",
"saveRangeAction": "Guardar rango", "saveRangeAction": "Guardar rango",
+3
View File
@@ -468,6 +468,9 @@
"deleteRangeTooltip": "Supprimer la période", "deleteRangeTooltip": "Supprimer la période",
"vacationsDefaultName": "Vacances", "vacationsDefaultName": "Vacances",
"newVacationRangeTitle": "Nouvelle période de vacances", "newVacationRangeTitle": "Nouvelle période de vacances",
"editVacationRangeTitle": "Modifier la période de vacances",
"vacationDeleteConfirmTitle": "Supprimer la période de vacances ?",
"vacationDeleteConfirmMessage": "Cette action est irréversible.",
"startField": "Début", "startField": "Début",
"endField": "Fin", "endField": "Fin",
"saveRangeAction": "Enregistrer la période", "saveRangeAction": "Enregistrer la période",
+3
View File
@@ -468,6 +468,9 @@
"deleteRangeTooltip": "अवधि हटाएँ", "deleteRangeTooltip": "अवधि हटाएँ",
"vacationsDefaultName": "छुट्टियाँ", "vacationsDefaultName": "छुट्टियाँ",
"newVacationRangeTitle": "नई छुट्टी अवधि", "newVacationRangeTitle": "नई छुट्टी अवधि",
"editVacationRangeTitle": "छुट्टी अवधि संपादित करें",
"vacationDeleteConfirmTitle": "छुट्टी अवधि हटाएं?",
"vacationDeleteConfirmMessage": "इसे वापस नहीं लिया जा सकता।",
"startField": "शुरुआत", "startField": "शुरुआत",
"endField": "समाप्ति", "endField": "समाप्ति",
"saveRangeAction": "अवधि सहेजें", "saveRangeAction": "अवधि सहेजें",
+3
View File
@@ -468,6 +468,9 @@
"deleteRangeTooltip": "Hapus rentang", "deleteRangeTooltip": "Hapus rentang",
"vacationsDefaultName": "Liburan", "vacationsDefaultName": "Liburan",
"newVacationRangeTitle": "Rentang liburan baru", "newVacationRangeTitle": "Rentang liburan baru",
"editVacationRangeTitle": "Edit rentang liburan",
"vacationDeleteConfirmTitle": "Hapus rentang liburan?",
"vacationDeleteConfirmMessage": "Tindakan ini tidak dapat dibatalkan.",
"startField": "Mulai", "startField": "Mulai",
"endField": "Akhir", "endField": "Akhir",
"saveRangeAction": "Simpan rentang", "saveRangeAction": "Simpan rentang",
+3
View File
@@ -468,6 +468,9 @@
"deleteRangeTooltip": "Elimina periodo", "deleteRangeTooltip": "Elimina periodo",
"vacationsDefaultName": "Vacanze", "vacationsDefaultName": "Vacanze",
"newVacationRangeTitle": "Nuovo periodo di vacanza", "newVacationRangeTitle": "Nuovo periodo di vacanza",
"editVacationRangeTitle": "Modifica periodo di vacanza",
"vacationDeleteConfirmTitle": "Eliminare il periodo di vacanza?",
"vacationDeleteConfirmMessage": "Questa azione non può essere annullata.",
"startField": "Inizio", "startField": "Inizio",
"endField": "Fine", "endField": "Fine",
"saveRangeAction": "Salva periodo", "saveRangeAction": "Salva periodo",
+3
View File
@@ -468,6 +468,9 @@
"deleteRangeTooltip": "期間を削除", "deleteRangeTooltip": "期間を削除",
"vacationsDefaultName": "休暇", "vacationsDefaultName": "休暇",
"newVacationRangeTitle": "新しい休暇期間", "newVacationRangeTitle": "新しい休暇期間",
"editVacationRangeTitle": "休暇期間を編集",
"vacationDeleteConfirmTitle": "休暇期間を削除しますか?",
"vacationDeleteConfirmMessage": "この操作は元に戻せません。",
"startField": "開始", "startField": "開始",
"endField": "終了", "endField": "終了",
"saveRangeAction": "期間を保存", "saveRangeAction": "期間を保存",
+3
View File
@@ -468,6 +468,9 @@
"deleteRangeTooltip": "Excluir período", "deleteRangeTooltip": "Excluir período",
"vacationsDefaultName": "Férias", "vacationsDefaultName": "Férias",
"newVacationRangeTitle": "Novo período de férias", "newVacationRangeTitle": "Novo período de férias",
"editVacationRangeTitle": "Editar período de férias",
"vacationDeleteConfirmTitle": "Excluir período de férias?",
"vacationDeleteConfirmMessage": "Esta ação não pode ser desfeita.",
"startField": "Início", "startField": "Início",
"endField": "Fim", "endField": "Fim",
"saveRangeAction": "Salvar período", "saveRangeAction": "Salvar período",
+3
View File
@@ -468,6 +468,9 @@
"deleteRangeTooltip": "Удалить период", "deleteRangeTooltip": "Удалить период",
"vacationsDefaultName": "Отпуск", "vacationsDefaultName": "Отпуск",
"newVacationRangeTitle": "Новый период отпуска", "newVacationRangeTitle": "Новый период отпуска",
"editVacationRangeTitle": "Изменить период отпуска",
"vacationDeleteConfirmTitle": "Удалить период отпуска?",
"vacationDeleteConfirmMessage": "Это действие нельзя отменить.",
"startField": "Начало", "startField": "Начало",
"endField": "Конец", "endField": "Конец",
"saveRangeAction": "Сохранить период", "saveRangeAction": "Сохранить период",
+3
View File
@@ -468,6 +468,9 @@
"deleteRangeTooltip": "删除范围", "deleteRangeTooltip": "删除范围",
"vacationsDefaultName": "假期", "vacationsDefaultName": "假期",
"newVacationRangeTitle": "新的假期范围", "newVacationRangeTitle": "新的假期范围",
"editVacationRangeTitle": "编辑假期范围",
"vacationDeleteConfirmTitle": "删除假期范围?",
"vacationDeleteConfirmMessage": "此操作无法撤销。",
"startField": "开始", "startField": "开始",
"endField": "结束", "endField": "结束",
"saveRangeAction": "保存范围", "saveRangeAction": "保存范围",
+18
View File
@@ -2078,6 +2078,24 @@ abstract class AppLocalizations {
/// **'Nuevo rango de vacaciones'** /// **'Nuevo rango de vacaciones'**
String get newVacationRangeTitle; String get newVacationRangeTitle;
/// No description provided for @editVacationRangeTitle.
///
/// In es, this message translates to:
/// **'Editar rango de vacaciones'**
String get editVacationRangeTitle;
/// No description provided for @vacationDeleteConfirmTitle.
///
/// In es, this message translates to:
/// **'¿Eliminar rango de vacaciones?'**
String get vacationDeleteConfirmTitle;
/// No description provided for @vacationDeleteConfirmMessage.
///
/// In es, this message translates to:
/// **'Esta acción no se puede deshacer.'**
String get vacationDeleteConfirmMessage;
/// No description provided for @startField. /// No description provided for @startField.
/// ///
/// In es, this message translates to: /// In es, this message translates to:
+9
View File
@@ -1133,6 +1133,15 @@ class AppLocalizationsAr extends AppLocalizations {
@override @override
String get newVacationRangeTitle => 'نطاق إجازة جديد'; String get newVacationRangeTitle => 'نطاق إجازة جديد';
@override
String get editVacationRangeTitle => 'تعديل نطاق الإجازة';
@override
String get vacationDeleteConfirmTitle => 'هل تريد حذف نطاق الإجازة؟';
@override
String get vacationDeleteConfirmMessage => 'لا يمكن التراجع عن هذا الإجراء.';
@override @override
String get startField => 'البداية'; String get startField => 'البداية';
+10
View File
@@ -1140,6 +1140,16 @@ class AppLocalizationsBn extends AppLocalizations {
@override @override
String get newVacationRangeTitle => 'নতুন ছুটির পরিসর'; String get newVacationRangeTitle => 'নতুন ছুটির পরিসর';
@override
String get editVacationRangeTitle => 'ছুটির পরিসর সম্পাদনা করুন';
@override
String get vacationDeleteConfirmTitle => 'ছুটির পরিসর মুছবেন?';
@override
String get vacationDeleteConfirmMessage =>
'এই পদক্ষেপ ফিরিয়ে নেওয়া যাবে না।';
@override @override
String get startField => 'শুরু'; String get startField => 'শুরু';
+10
View File
@@ -1142,6 +1142,16 @@ class AppLocalizationsDe extends AppLocalizations {
@override @override
String get newVacationRangeTitle => 'Neuer Ferienzeitraum'; String get newVacationRangeTitle => 'Neuer Ferienzeitraum';
@override
String get editVacationRangeTitle => 'Ferienzeitraum bearbeiten';
@override
String get vacationDeleteConfirmTitle => 'Ferienzeitraum löschen?';
@override
String get vacationDeleteConfirmMessage =>
'Dies kann nicht rückgängig gemacht werden.';
@override @override
String get startField => 'Beginn'; String get startField => 'Beginn';
+9
View File
@@ -1133,6 +1133,15 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get newVacationRangeTitle => 'New vacation range'; String get newVacationRangeTitle => 'New vacation range';
@override
String get editVacationRangeTitle => 'Edit vacation range';
@override
String get vacationDeleteConfirmTitle => 'Delete vacation range?';
@override
String get vacationDeleteConfirmMessage => 'This can\'t be undone.';
@override @override
String get startField => 'Start'; String get startField => 'Start';
+10
View File
@@ -1139,6 +1139,16 @@ class AppLocalizationsEs extends AppLocalizations {
@override @override
String get newVacationRangeTitle => 'Nuevo rango de vacaciones'; String get newVacationRangeTitle => 'Nuevo rango de vacaciones';
@override
String get editVacationRangeTitle => 'Editar rango de vacaciones';
@override
String get vacationDeleteConfirmTitle => '¿Eliminar rango de vacaciones?';
@override
String get vacationDeleteConfirmMessage =>
'Esta acción no se puede deshacer.';
@override @override
String get startField => 'Inicio'; String get startField => 'Inicio';
+9
View File
@@ -1145,6 +1145,15 @@ class AppLocalizationsFr extends AppLocalizations {
@override @override
String get newVacationRangeTitle => 'Nouvelle période de vacances'; String get newVacationRangeTitle => 'Nouvelle période de vacances';
@override
String get editVacationRangeTitle => 'Modifier la période de vacances';
@override
String get vacationDeleteConfirmTitle => 'Supprimer la période de vacances ?';
@override
String get vacationDeleteConfirmMessage => 'Cette action est irréversible.';
@override @override
String get startField => 'Début'; String get startField => 'Début';
+9
View File
@@ -1134,6 +1134,15 @@ class AppLocalizationsHi extends AppLocalizations {
@override @override
String get newVacationRangeTitle => 'नई छुट्टी अवधि'; String get newVacationRangeTitle => 'नई छुट्टी अवधि';
@override
String get editVacationRangeTitle => 'छुट्टी अवधि संपादित करें';
@override
String get vacationDeleteConfirmTitle => 'छुट्टी अवधि हटाएं?';
@override
String get vacationDeleteConfirmMessage => 'इसे वापस नहीं लिया जा सकता।';
@override @override
String get startField => 'शुरुआत'; String get startField => 'शुरुआत';
+10
View File
@@ -1139,6 +1139,16 @@ class AppLocalizationsId extends AppLocalizations {
@override @override
String get newVacationRangeTitle => 'Rentang liburan baru'; String get newVacationRangeTitle => 'Rentang liburan baru';
@override
String get editVacationRangeTitle => 'Edit rentang liburan';
@override
String get vacationDeleteConfirmTitle => 'Hapus rentang liburan?';
@override
String get vacationDeleteConfirmMessage =>
'Tindakan ini tidak dapat dibatalkan.';
@override @override
String get startField => 'Mulai'; String get startField => 'Mulai';
+10
View File
@@ -1144,6 +1144,16 @@ class AppLocalizationsIt extends AppLocalizations {
@override @override
String get newVacationRangeTitle => 'Nuovo periodo di vacanza'; String get newVacationRangeTitle => 'Nuovo periodo di vacanza';
@override
String get editVacationRangeTitle => 'Modifica periodo di vacanza';
@override
String get vacationDeleteConfirmTitle => 'Eliminare il periodo di vacanza?';
@override
String get vacationDeleteConfirmMessage =>
'Questa azione non può essere annullata.';
@override @override
String get startField => 'Inizio'; String get startField => 'Inizio';
+9
View File
@@ -1101,6 +1101,15 @@ class AppLocalizationsJa extends AppLocalizations {
@override @override
String get newVacationRangeTitle => '新しい休暇期間'; String get newVacationRangeTitle => '新しい休暇期間';
@override
String get editVacationRangeTitle => '休暇期間を編集';
@override
String get vacationDeleteConfirmTitle => '休暇期間を削除しますか?';
@override
String get vacationDeleteConfirmMessage => 'この操作は元に戻せません。';
@override @override
String get startField => '開始'; String get startField => '開始';
+9
View File
@@ -1138,6 +1138,15 @@ class AppLocalizationsPt extends AppLocalizations {
@override @override
String get newVacationRangeTitle => 'Novo período de férias'; String get newVacationRangeTitle => 'Novo período de férias';
@override
String get editVacationRangeTitle => 'Editar período de férias';
@override
String get vacationDeleteConfirmTitle => 'Excluir período de férias?';
@override
String get vacationDeleteConfirmMessage => 'Esta ação não pode ser desfeita.';
@override @override
String get startField => 'Início'; String get startField => 'Início';
+9
View File
@@ -1140,6 +1140,15 @@ class AppLocalizationsRu extends AppLocalizations {
@override @override
String get newVacationRangeTitle => 'Новый период отпуска'; String get newVacationRangeTitle => 'Новый период отпуска';
@override
String get editVacationRangeTitle => 'Изменить период отпуска';
@override
String get vacationDeleteConfirmTitle => 'Удалить период отпуска?';
@override
String get vacationDeleteConfirmMessage => 'Это действие нельзя отменить.';
@override @override
String get startField => 'Начало'; String get startField => 'Начало';
+9
View File
@@ -1097,6 +1097,15 @@ class AppLocalizationsZh extends AppLocalizations {
@override @override
String get newVacationRangeTitle => '新的假期范围'; String get newVacationRangeTitle => '新的假期范围';
@override
String get editVacationRangeTitle => '编辑假期范围';
@override
String get vacationDeleteConfirmTitle => '删除假期范围?';
@override
String get vacationDeleteConfirmMessage => '此操作无法撤销。';
@override @override
String get startField => '开始'; String get startField => '开始';
+25 -2
View File
@@ -93,6 +93,14 @@ class FilaAjuste extends StatelessWidget {
/// "no accent" — the icon renders exactly as before. /// "no accent" — the icon renders exactly as before.
final Color? iconColor; final Color? iconColor;
/// Issue 5 (feedback-pruebas): caps how much width the trailing current
/// value can claim. `ListTile` gives `trailing` as much width as it wants
/// before handing the title whatever is left — an unbounded value (e.g. a
/// real, arbitrarily long station name for "Emisora preferida") could
/// squeeze the title down to almost nothing, forcing it to wrap across
/// several lines that then get cut short by the row's fixed height.
static const _anchoMaximoValor = 108.0;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final type = context.pluriType; final type = context.pluriType;
@@ -106,18 +114,33 @@ class FilaAjuste extends StatelessWidget {
// 14px/w700 (t4 line 514); cardTitle is 14.5/w700 — a one-off // 14px/w700 (t4 line 514); cardTitle is 14.5/w700 — a one-off
// override, not a new PluriWaveTypography style (mirrors the // override, not a new PluriWaveTypography style (mirrors the
// precedent set for the ringing screen's station name, audit 9.7). // precedent set for the ringing screen's station name, audit 9.7).
title: Text(titulo, style: type.cardTitle.copyWith(fontSize: 14)), // Issue 5: constrained to one line, ellipsizing instead of wrapping —
// labels must wrap as little as possible and never render visibly
// truncated (a multi-line wrap inside this fixed-height row cuts the
// last line short, which reads as broken, not as intentional).
title: Text(
titulo,
style: type.cardTitle.copyWith(fontSize: 14),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
trailing: Row( trailing: Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
if (valorActual != null) ...[ if (valorActual != null) ...[
Text( ConstrainedBox(
constraints: const BoxConstraints(maxWidth: _anchoMaximoValor),
child: Text(
valorActual, valorActual,
// bodyStrong is already 13/w600, matching the prototype's row // bodyStrong is already 13/w600, matching the prototype's row
// value spec exactly — only the colour needs overriding. // value spec exactly — only the colour needs overriding.
style: type.bodyStrong.copyWith( style: type.bodyStrong.copyWith(
color: const Color(0xFFF2F7FA).withValues(alpha: 0.55), color: const Color(0xFFF2F7FA).withValues(alpha: 0.55),
), ),
maxLines: 1,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.end,
),
), ),
const SizedBox(width: 6), const SizedBox(width: 6),
], ],
+31 -3
View File
@@ -543,8 +543,17 @@ class _PantallaBuscarState extends State<PantallaBuscar> {
if (estado.cargando) { if (estado.cargando) {
// S5-R6: shimmer placeholders instead of a bare spinner, consistent // S5-R6: shimmer placeholders instead of a bare spinner, consistent
// with the loading pattern used by the home grid. // with the loading pattern used by the home grid.
// Issue 3 (feedback-pruebas): row tier (12), not card tier (16) --
// these are background-less row placeholders, same tier as the real
// results below; the top inset is the standard section gap rather
// than the horizontal constant reused for a vertical axis.
return Padding( return Padding(
padding: const EdgeInsets.all(PluriLayout.horizontal), padding: const EdgeInsets.fromLTRB(
PluriLayout.rowHorizontal,
PluriLayout.sectionGap,
PluriLayout.rowHorizontal,
PluriLayout.rowHorizontal,
),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
@@ -579,8 +588,17 @@ class _PantallaBuscarState extends State<PantallaBuscar> {
// across the app -- favorites, the discovery grid -- which this // across the app -- favorites, the discovery grid -- which this
// item does not touch). // item does not touch).
final query = _controller.text.trim(); final query = _controller.text.trim();
// Issue 3 (feedback-pruebas): this card-tier state had NO top gap at
// all against the filter row above it -- the standard section gap
// now matches the other two mutually-exclusive results-area states
// (loading, populated) above.
return Padding( return Padding(
padding: const EdgeInsets.symmetric(horizontal: PluriLayout.horizontal), padding: const EdgeInsets.fromLTRB(
PluriLayout.horizontal,
PluriLayout.sectionGap,
PluriLayout.horizontal,
0,
),
child: _TarjetaSinResultados( child: _TarjetaSinResultados(
titulo: titulo:
sinFiltros sinFiltros
@@ -616,7 +634,17 @@ class _PantallaBuscarState extends State<PantallaBuscar> {
return ListView.builder( return ListView.builder(
shrinkWrap: true, shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(), physics: const NeverScrollableScrollPhysics(),
padding: const EdgeInsets.all(PluriLayout.horizontal), // Issue 3 (feedback-pruebas): row tier (12), not card tier (16) --
// `FilaEmisoraPlana` rows are documented as "flat, background-less"
// (audit 6.5) but this padding never got updated to match when Tier 1
// introduced the 3-tier scale. The top inset is the standard section
// gap, not the horizontal constant reused for a vertical axis.
padding: const EdgeInsets.fromLTRB(
PluriLayout.rowHorizontal,
PluriLayout.sectionGap,
PluriLayout.rowHorizontal,
PluriLayout.rowHorizontal,
),
itemCount: total, itemCount: total,
itemBuilder: (context, i) { itemBuilder: (context, i) {
if (i >= resultados.length) { if (i >= resultados.length) {
+7 -1
View File
@@ -465,7 +465,13 @@ class _FilaFavorito extends StatelessWidget {
context, context,
).colorScheme.onSurface.withValues(alpha: 0.45), ).colorScheme.onSurface.withValues(alpha: 0.45),
), ),
constraints: const BoxConstraints.tightFor(width: 38, height: 42), // NO `constraints:` here. That property sizes the POPUP MENU, not
// the button — a tightFor(38x42) clipped every menu item down to
// its first letter ("M" for "Mover a lista", "E" for "Eliminar de
// favoritos"), which is what users actually saw. Constrain the
// tap target instead.
padding: EdgeInsets.zero,
iconSize: 20,
onSelected: (accion) { onSelected: (accion) {
if (accion == 'assign') _asignar(context); if (accion == 'assign') _asignar(context);
if (accion == 'remove') _eliminar(context); if (accion == 'remove') _eliminar(context);
+15 -20
View File
@@ -15,6 +15,7 @@ import '../widgets/pluri_glass_surface.dart';
import '../widgets/pluri_layout.dart'; import '../widgets/pluri_layout.dart';
import '../widgets/pluri_premium_widgets.dart'; import '../widgets/pluri_premium_widgets.dart';
import '../widgets/pluri_sleep_timer_sheet.dart'; import '../widgets/pluri_sleep_timer_sheet.dart';
import '../widgets/pluri_station_art_fallback.dart';
import '../widgets/visualizador_audio.dart'; import '../widgets/visualizador_audio.dart';
import 'pantalla_reproductor.dart'; import 'pantalla_reproductor.dart';
@@ -568,9 +569,9 @@ class _ArteEscuchar extends StatelessWidget {
imageUrl: emisora.favicon!, imageUrl: emisora.favicon!,
fit: BoxFit.cover, fit: BoxFit.cover,
placeholder: (_, __) => _shimmerCuadrado(theme), placeholder: (_, __) => _shimmerCuadrado(theme),
errorWidget: (_, __, ___) => _iconoFallback(theme), errorWidget: (_, __, ___) => _iconoFallback(),
) )
: _iconoFallback(theme), : _iconoFallback(),
), ),
), ),
); );
@@ -582,14 +583,11 @@ class _ArteEscuchar extends StatelessWidget {
child: Container(color: theme.colorScheme.surfaceContainerHighest), child: Container(color: theme.colorScheme.surfaceContainerHighest),
); );
Widget _iconoFallback(ThemeData theme) => Container( // Issue 6 (feedback-pruebas): was a flat `primaryContainer` square with a
color: theme.colorScheme.primaryContainer, // bare `radio_rounded` icon — now the same shared fallback every other
child: Icon( // surface uses.
Icons.radio_rounded, Widget _iconoFallback() =>
size: 36, PluriStationArtFallback(seed: emisora.uuid, iconSize: 36);
color: theme.colorScheme.onPrimaryContainer,
),
);
} }
/// The hero's transport row — favorite / EQ toggle / stop / play-pause /// The hero's transport row — favorite / EQ toggle / stop / play-pause
@@ -909,10 +907,10 @@ class _CeldaTusEmisoras extends StatelessWidget {
imageUrl: emisora.favicon!, imageUrl: emisora.favicon!,
fit: BoxFit.cover, fit: BoxFit.cover,
placeholder: (_, __) => _shimmer(theme), placeholder: (_, __) => _shimmer(theme),
errorWidget: (_, __, ___) => _iconoFallback(theme), errorWidget: (_, __, ___) => _iconoFallback(),
); );
} }
return _iconoFallback(theme); return _iconoFallback();
} }
Widget _shimmer(ThemeData theme) => shimmer.Shimmer.fromColors( Widget _shimmer(ThemeData theme) => shimmer.Shimmer.fromColors(
@@ -921,12 +919,9 @@ class _CeldaTusEmisoras extends StatelessWidget {
child: Container(color: theme.colorScheme.surfaceContainerHighest), child: Container(color: theme.colorScheme.surfaceContainerHighest),
); );
Widget _iconoFallback(ThemeData theme) => Container( // Issue 6 (feedback-pruebas): was a flat `primaryContainer` square with a
color: theme.colorScheme.primaryContainer, // bare `radio_rounded` icon — now the same shared fallback every other
child: Icon( // surface uses.
Icons.radio_rounded, Widget _iconoFallback() =>
size: 20, PluriStationArtFallback(seed: emisora.uuid, iconSize: 20);
color: theme.colorScheme.onPrimaryContainer,
),
);
} }
+8 -10
View File
@@ -20,6 +20,7 @@ import '../widgets/ecualizador_widget.dart';
import '../widgets/pluri_glass_surface.dart'; import '../widgets/pluri_glass_surface.dart';
import '../widgets/pluri_premium_widgets.dart'; import '../widgets/pluri_premium_widgets.dart';
import '../widgets/pluri_push_scaffold.dart'; import '../widgets/pluri_push_scaffold.dart';
import '../widgets/pluri_station_art_fallback.dart';
import '../widgets/visualizador_audio.dart'; import '../widgets/visualizador_audio.dart';
/// WU14: restructured onto [PluriPushScaffold] (design ADR-2) — this screen /// WU14: restructured onto [PluriPushScaffold] (design ADR-2) — this screen
@@ -291,10 +292,10 @@ class _ArteReproductor extends StatelessWidget {
imageUrl: emisora.favicon!, imageUrl: emisora.favicon!,
fit: BoxFit.cover, fit: BoxFit.cover,
placeholder: (_, __) => _shimmer(theme), placeholder: (_, __) => _shimmer(theme),
errorWidget: (_, __, ___) => _iconoFallback(theme), errorWidget: (_, __, ___) => _iconoFallback(),
) )
else else
_iconoFallback(theme), _iconoFallback(),
if (cargando) if (cargando)
Container( Container(
color: Colors.black45, color: Colors.black45,
@@ -333,14 +334,11 @@ class _ArteReproductor extends StatelessWidget {
child: Container(color: theme.colorScheme.surfaceContainerHighest), child: Container(color: theme.colorScheme.surfaceContainerHighest),
); );
Widget _iconoFallback(ThemeData theme) => Container( // Issue 6 (feedback-pruebas): was a flat `primaryContainer` square with a
color: theme.colorScheme.primaryContainer, // bare `radio_rounded` icon — now the same shared fallback every other
child: Icon( // surface uses.
Icons.radio_rounded, Widget _iconoFallback() =>
size: 80, PluriStationArtFallback(seed: emisora.uuid, iconSize: 80);
color: theme.colorScheme.onPrimaryContainer,
),
);
} }
/// Audit 2.2 (t4 lines 108-109): a full-bleed blurred backdrop of the /// Audit 2.2 (t4 lines 108-109): a full-bleed blurred backdrop of the
+186 -19
View File
@@ -82,13 +82,78 @@ class PantallaVacaciones extends StatelessWidget {
); );
} }
Future<void> _abrirAlta(BuildContext context) async { Future<void> _abrirAlta(BuildContext context) =>
_abrirEditorVacaciones(context);
}
/// Issue 1 (feedback-pruebas): the ONE sheet-opener both the header/CTA
/// "create" entry points and every range's own "tap to edit" affordance call
/// -- passing [rango] switches the sheet from create to edit mode (mirrors
/// `pantalla_alarmas.dart`'s `_abrirEditor`/`_EditorAlarmaSheet` split).
Future<void> _abrirEditorVacaciones(
BuildContext context, {
RangoVacaciones? rango,
}) async {
await showModalBottomSheet<void>( await showModalBottomSheet<void>(
context: context, context: context,
isScrollControlled: true, isScrollControlled: true,
useSafeArea: true, useSafeArea: true,
backgroundColor: Colors.transparent, backgroundColor: Colors.transparent,
builder: (_) => const _EditorVacacionesSheet(), builder: (_) => _EditorVacacionesSheet(rango: rango),
);
}
/// Issue 1 (feedback-pruebas): mirrors `pantalla_alarmas.dart`'s
/// `_confirmarEliminarAlarma` exactly -- same AlertDialog shape, same
/// generic delete/cancel actions, only the copy is vacation-specific.
Future<bool> _confirmarEliminarRango(
BuildContext context,
AppLocalizations l10n,
) async {
final confirmado = await showDialog<bool>(
context: context,
builder:
(ctx) => AlertDialog(
title: Text(l10n.vacationDeleteConfirmTitle),
content: Text(l10n.vacationDeleteConfirmMessage),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: Text(l10n.cancelAction),
),
FilledButton(
onPressed: () => Navigator.pop(ctx, true),
child: Text(l10n.deleteAction),
),
],
),
);
return confirmado ?? false;
}
/// Swipe-to-delete reveal shown on both sides, mirroring
/// `pantalla_alarmas.dart`'s `_FondoSwipeEliminarAlarma` -- duplicated
/// rather than shared, matching this codebase's own precedent for tiny
/// per-screen chrome (see this file's `_DashedBorderPainter` doc comment).
class _FondoSwipeEliminarRango extends StatelessWidget {
const _FondoSwipeEliminarRango({required this.alignment});
final Alignment alignment;
@override
Widget build(BuildContext context) {
final tokens = context.pluriTokens;
return Container(
alignment: alignment,
padding: const EdgeInsets.symmetric(horizontal: 24),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.error,
borderRadius: BorderRadius.circular(tokens.radiusMd),
),
child: Icon(
Icons.delete_outline_rounded,
color: Theme.of(context).colorScheme.onError,
),
); );
} }
} }
@@ -242,24 +307,51 @@ class _HeroRangoActivo extends StatelessWidget {
final diasRestantes = rango.finDia.difference(hoyDia).inDays; final diasRestantes = rango.finDia.difference(hoyDia).inDays;
final impacto = estado.impactoDeRango(rango); final impacto = estado.impactoDeRango(rango);
final type = context.pluriType; final type = context.pluriType;
final tokens = context.pluriTokens;
return PluriGlassSurface( // Issue 1 (feedback-pruebas): a range starts ACTIVE the instant it's
glowColor: context.pluriTokens.electricMagenta.withValues(alpha: 0.24), // created (today .. today+2), so this hero is the ONLY place a
// brand-new range ever renders until it either becomes "programado" in
// the future or "pasado" once it ends. Without tap/swipe here, the
// very first range a user creates could never be fixed or removed.
return Dismissible(
key: ValueKey('vacaciones-tarjeta-${rango.id}'),
direction: DismissDirection.horizontal,
background: const _FondoSwipeEliminarRango(
alignment: Alignment.centerLeft,
),
secondaryBackground: const _FondoSwipeEliminarRango(
alignment: Alignment.centerRight,
),
confirmDismiss: (_) => _confirmarEliminarRango(context, l10n),
onDismissed: (_) => estado.eliminarRangoVacaciones(rango.id),
child: PluriGlassSurface(
glowColor: tokens.electricMagenta.withValues(alpha: 0.24),
padding: EdgeInsets.zero,
child: Material(
type: MaterialType.transparency,
child: InkWell(
borderRadius: BorderRadius.circular(tokens.radiusMd),
onTap: () => _abrirEditorVacaciones(context, rango: rango),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text( Text(
localizedVacationName(l10n, rango.nombre), localizedVacationName(l10n, rango.nombre),
style: Theme.of( style: Theme.of(context).textTheme.titleLarge?.copyWith(
context, fontWeight: FontWeight.w900,
).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.w900), ),
), ),
const SizedBox(height: 4), const SizedBox(height: 4),
// Item 21 / audit 9b.4 (t4:454): the "active now" caption is a // Item 21 / audit 9b.4 (t4:454): the "active now" caption is a
// teal eyebrow, not default body text. // teal eyebrow, not default body text.
Text( Text(
l10n.vacationSummaryActiveCountdown(diasRestantes), l10n.vacationSummaryActiveCountdown(diasRestantes),
style: type.eyebrowLabel.copyWith(color: PluriWaveTokens.brand), style: type.eyebrowLabel.copyWith(
color: PluriWaveTokens.brand,
),
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
// Item 21 / audit 9b.4 (t4:451-462): the screen's signature // Item 21 / audit 9b.4 (t4:451-462): the screen's signature
@@ -273,16 +365,24 @@ class _HeroRangoActivo extends StatelessWidget {
), ),
if (impacto.pausadas.isNotEmpty) ...[ if (impacto.pausadas.isNotEmpty) ...[
const SizedBox(height: 12), const SizedBox(height: 12),
Text(l10n.vacationImpactPausedLabel(_horas(impacto.pausadas))), Text(
l10n.vacationImpactPausedLabel(_horas(impacto.pausadas)),
),
], ],
if (impacto.noAfectadas.isNotEmpty) ...[ if (impacto.noAfectadas.isNotEmpty) ...[
const SizedBox(height: 4), const SizedBox(height: 4),
Text( Text(
l10n.vacationImpactContinuesLabel(_horas(impacto.noAfectadas)), l10n.vacationImpactContinuesLabel(
_horas(impacto.noAfectadas),
),
), ),
], ],
], ],
), ),
),
),
),
),
); );
} }
@@ -452,12 +552,33 @@ class _TarjetaRangoVacaciones extends StatelessWidget {
final l10n = AppLocalizations.of(context); final l10n = AppLocalizations.of(context);
final t = context.pluriTokens; final t = context.pluriTokens;
final type = context.pluriType; final type = context.pluriType;
return DecoratedBox( final estado = context.read<EstadoAlarmas>();
// Issue 1 (feedback-pruebas): tap = edit, swipe = delete (with
// confirmation) — same interaction `pantalla_alarmas.dart`'s
// `_TarjetaAlarma` already uses for the same concept, applied here to
// BOTH the "programados" and "pasados" sections (this card backs both).
return Dismissible(
key: ValueKey('vacaciones-tarjeta-${rango.id}'),
direction: DismissDirection.horizontal,
background: const _FondoSwipeEliminarRango(
alignment: Alignment.centerLeft,
),
secondaryBackground: const _FondoSwipeEliminarRango(
alignment: Alignment.centerRight,
),
confirmDismiss: (_) => _confirmarEliminarRango(context, l10n),
onDismissed: (_) => estado.eliminarRangoVacaciones(rango.id),
child: DecoratedBox(
decoration: BoxDecoration( decoration: BoxDecoration(
color: t.listSurface, color: t.listSurface,
borderRadius: BorderRadius.circular(20), borderRadius: BorderRadius.circular(20),
border: Border.all(color: Colors.white.withValues(alpha: 0.08)), border: Border.all(color: Colors.white.withValues(alpha: 0.08)),
), ),
child: Material(
type: MaterialType.transparency,
child: InkWell(
borderRadius: BorderRadius.circular(20),
onTap: () => _abrirEditorVacaciones(context, rango: rango),
child: Padding( child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 15), padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 15),
child: Column( child: Column(
@@ -494,7 +615,9 @@ class _TarjetaRangoVacaciones extends StatelessWidget {
Expanded( Expanded(
child: Text( child: Text(
localizedVacationName(l10n, rango.nombre), localizedVacationName(l10n, rango.nombre),
style: Theme.of(context).textTheme.bodyMedium?.copyWith( style: Theme.of(
context,
).textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
color: Theme.of( color: Theme.of(
context, context,
@@ -509,6 +632,9 @@ class _TarjetaRangoVacaciones extends StatelessWidget {
], ],
), ),
), ),
),
),
),
); );
} }
} }
@@ -609,11 +735,17 @@ class _BloqueFecha extends StatelessWidget {
} }
} }
/// Add-range form. Moved verbatim from `pantalla_alarmas.dart` (WU8's /// Add/edit-range form. Moved verbatim from `pantalla_alarmas.dart` (WU8's
/// `_PantallaVacacionesTemporal` used it as a placeholder push target; now /// `_PantallaVacacionesTemporal` used it as a placeholder push target; now
/// this screen is the one real consumer). Behaviour unchanged. /// this screen is the one real consumer). Create behaviour unchanged; issue
/// 1 (feedback-pruebas) adds the edit half via the optional [rango] — the
/// SAME sheet, mirroring `pantalla_alarmas.dart`'s `_EditorAlarmaSheet`
/// (`alarma == null` -> create, non-null -> edit; one shared save button
/// either way).
class _EditorVacacionesSheet extends StatefulWidget { class _EditorVacacionesSheet extends StatefulWidget {
const _EditorVacacionesSheet(); const _EditorVacacionesSheet({this.rango});
final RangoVacaciones? rango;
@override @override
State<_EditorVacacionesSheet> createState() => _EditorVacacionesSheetState(); State<_EditorVacacionesSheet> createState() => _EditorVacacionesSheetState();
@@ -629,16 +761,29 @@ class _EditorVacacionesSheetState extends State<_EditorVacacionesSheet> {
@override @override
void initState() { void initState() {
super.initState(); super.initState();
final rango = widget.rango;
if (rango != null) {
_inicio = rango.inicioDia;
_fin = rango.finDia;
} else {
final hoy = DateTime.now(); final hoy = DateTime.now();
_inicio = DateTime(hoy.year, hoy.month, hoy.day); _inicio = DateTime(hoy.year, hoy.month, hoy.day);
_fin = _inicio.add(const Duration(days: 2)); _fin = _inicio.add(const Duration(days: 2));
} }
}
@override @override
void didChangeDependencies() { void didChangeDependencies() {
super.didChangeDependencies(); super.didChangeDependencies();
final rango = widget.rango;
_nombreController ??= TextEditingController( _nombreController ??= TextEditingController(
text: AppLocalizations.of(context).vacationsDefaultName, text:
rango != null
? localizedVacationName(
AppLocalizations.of(context),
rango.nombre,
)
: AppLocalizations.of(context).vacationsDefaultName,
); );
} }
@@ -662,7 +807,9 @@ class _EditorVacacionesSheetState extends State<_EditorVacacionesSheet> {
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text( Text(
l10n.newVacationRangeTitle, widget.rango != null
? l10n.editVacationRangeTitle
: l10n.newVacationRangeTitle,
style: Theme.of( style: Theme.of(
context, context,
).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.w900), ).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.w900),
@@ -709,10 +856,16 @@ class _EditorVacacionesSheetState extends State<_EditorVacacionesSheet> {
Future<void> _elegirFecha({required bool esInicio}) async { Future<void> _elegirFecha({required bool esInicio}) async {
final actual = esInicio ? _inicio : _fin; final actual = esInicio ? _inicio : _fin;
final hoy = DateTime.now(); final hoy = DateTime.now();
final hoyDia = DateTime(hoy.year, hoy.month, hoy.day);
// Issue 1 (feedback-pruebas): editing a PAST range (reachable from the
// "Rangos pasados" section) must not force its dates into the future —
// `firstDate` only floors at today for a range that starts there or
// later; an already-past range keeps its own start as the floor.
final primerDiaPermitido = _inicio.isBefore(hoyDia) ? _inicio : hoyDia;
final seleccion = await showDatePicker( final seleccion = await showDatePicker(
context: context, context: context,
initialDate: actual, initialDate: actual,
firstDate: DateTime(hoy.year, hoy.month, hoy.day), firstDate: primerDiaPermitido,
lastDate: hoy.add(const Duration(days: 1460)), lastDate: hoy.add(const Duration(days: 1460)),
); );
if (seleccion == null) return; if (seleccion == null) return;
@@ -728,12 +881,26 @@ class _EditorVacacionesSheetState extends State<_EditorVacacionesSheet> {
Future<void> _guardar() async { Future<void> _guardar() async {
final estado = context.read<EstadoAlarmas>(); final estado = context.read<EstadoAlarmas>();
final nombre = _nombreController?.text.trim() ?? '';
final existente = widget.rango;
if (existente != null) {
await estado.editarRangoVacaciones(
RangoVacaciones(
id: existente.id,
nombre: nombre,
inicio: _inicio,
fin: _fin,
activo: existente.activo,
),
);
} else {
final rango = estado.servicio.crearRangoVacaciones( final rango = estado.servicio.crearRangoVacaciones(
inicio: _inicio, inicio: _inicio,
fin: _fin, fin: _fin,
nombre: _nombreController?.text.trim() ?? '', nombre: nombre,
); );
await estado.crearRangoVacaciones(rango); await estado.crearRangoVacaciones(rango);
}
if (mounted) Navigator.pop(context); if (mounted) Navigator.pop(context);
} }
} }
+8 -10
View File
@@ -8,6 +8,7 @@ import '../l10n/display_names.dart';
import '../l10n/gen/app_localizations.dart'; import '../l10n/gen/app_localizations.dart';
import '../modelos/emisora.dart'; import '../modelos/emisora.dart';
import '../tema/pluriwave_tokens.dart'; import '../tema/pluriwave_tokens.dart';
import 'pluri_station_art_fallback.dart';
/// Item 23 / audit 4.3 + 6.5 (t4:226-232, 302-306): a flat, background-less /// Item 23 / audit 4.3 + 6.5 (t4:226-232, 302-306): a flat, background-less
/// station row — square thumbnail, name, meta line, and a caller-supplied /// station row — square thumbnail, name, meta line, and a caller-supplied
@@ -234,10 +235,10 @@ class _ArteFilaEmisora extends StatelessWidget {
imageUrl: emisora.favicon!, imageUrl: emisora.favicon!,
fit: BoxFit.cover, fit: BoxFit.cover,
placeholder: (_, __) => _shimmer(theme), placeholder: (_, __) => _shimmer(theme),
errorWidget: (_, __, ___) => _iconoFallback(theme), errorWidget: (_, __, ___) => _iconoFallback(),
); );
} }
return _iconoFallback(theme); return _iconoFallback();
} }
Widget _shimmer(ThemeData theme) => shimmer.Shimmer.fromColors( Widget _shimmer(ThemeData theme) => shimmer.Shimmer.fromColors(
@@ -246,12 +247,9 @@ class _ArteFilaEmisora extends StatelessWidget {
child: Container(color: theme.colorScheme.surfaceContainerHighest), child: Container(color: theme.colorScheme.surfaceContainerHighest),
); );
Widget _iconoFallback(ThemeData theme) => Container( // Issue 6 (feedback-pruebas): was a flat `primaryContainer` square with a
color: theme.colorScheme.primaryContainer, // bare `radio_rounded` icon — now the same shared fallback every other
child: Icon( // surface uses.
Icons.radio_rounded, Widget _iconoFallback() =>
size: 22, PluriStationArtFallback(seed: emisora.uuid, iconSize: 22);
color: theme.colorScheme.onPrimaryContainer,
),
);
} }
+8 -10
View File
@@ -10,6 +10,7 @@ import '../modelos/emisora.dart';
import '../pantallas/pantalla_reproductor.dart'; import '../pantallas/pantalla_reproductor.dart';
import '../servicios/servicio_audio.dart'; import '../servicios/servicio_audio.dart';
import '../tema/pluriwave_theme.dart'; import '../tema/pluriwave_theme.dart';
import 'pluri_station_art_fallback.dart';
/// Barra inferior persistente con controles básicos de reproducción. /// Barra inferior persistente con controles básicos de reproducción.
/// Toca la barra para abrir PantallaReproductor completa. /// Toca la barra para abrir PantallaReproductor completa.
@@ -318,9 +319,9 @@ class _ArteMiniReproductor extends StatelessWidget {
imageUrl: emisora.favicon!, imageUrl: emisora.favicon!,
fit: BoxFit.cover, fit: BoxFit.cover,
placeholder: (_, __) => _shimmer(theme), placeholder: (_, __) => _shimmer(theme),
errorWidget: (_, __, ___) => _iconoFallback(theme), errorWidget: (_, __, ___) => _iconoFallback(),
) )
: _iconoFallback(theme), : _iconoFallback(),
), ),
); );
} }
@@ -331,12 +332,9 @@ class _ArteMiniReproductor extends StatelessWidget {
child: Container(color: theme.colorScheme.surfaceContainerHighest), child: Container(color: theme.colorScheme.surfaceContainerHighest),
); );
Widget _iconoFallback(ThemeData theme) => Container( // Issue 6 (feedback-pruebas): was a flat `primaryContainer` square with a
color: theme.colorScheme.primaryContainer, // bare `radio_rounded` icon — now the same shared fallback every other
child: Icon( // surface uses.
Icons.radio_rounded, Widget _iconoFallback() =>
size: 20, PluriStationArtFallback(seed: emisora.uuid, iconSize: 20);
color: theme.colorScheme.onPrimaryContainer,
),
);
} }
+42 -12
View File
@@ -192,7 +192,10 @@ class PluriBottomNavigation extends StatelessWidget {
/// the balloon's opaque fill already covers the seam where the bar's own /// the balloon's opaque fill already covers the seam where the bar's own
/// shadow would otherwise show through. /// shadow would otherwise show through.
List<BoxShadow> get _shellShadows => [ List<BoxShadow> get _shellShadows => [
BoxShadow(color: Colors.white.withValues(alpha: 0.17), offset: const Offset(0, -1.5)), BoxShadow(
color: Colors.white.withValues(alpha: 0.17),
offset: const Offset(0, -1.5),
),
BoxShadow( BoxShadow(
color: Colors.black.withValues(alpha: 0.5), color: Colors.black.withValues(alpha: 0.5),
offset: const Offset(0, 14), offset: const Offset(0, 14),
@@ -214,6 +217,7 @@ class _PluriNavButton extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final t = context.pluriTokens; final t = context.pluriTokens;
final motion = context.pluriMotion;
return Semantics( return Semantics(
button: true, button: true,
selected: selected, selected: selected,
@@ -222,6 +226,11 @@ class _PluriNavButton extends StatelessWidget {
type: MaterialType.transparency, type: MaterialType.transparency,
child: InkWell( child: InkWell(
onTap: onTap, onTap: onTap,
// Without a shape the ink splash and hover highlight paint as a
// full-bleed RECTANGLE over the cell — a hard square sitting on
// top of the icon, which is what users reported. The whole bar is
// built on 999-radius pills; the splash has to follow.
borderRadius: BorderRadius.circular(999),
child: Align( child: Align(
alignment: Alignment.bottomCenter, alignment: Alignment.bottomCenter,
// t4/4a spec: the items row itself is 52px tall — this inner // t4/4a spec: the items row itself is 52px tall — this inner
@@ -238,13 +247,27 @@ class _PluriNavButton extends StatelessWidget {
// Flutter joins merged labels with `\n`, so screen readers // Flutter joins merged labels with `\n`, so screen readers
// would announce "Alarmas\nAlarmas" instead of "Alarmas". // would announce "Alarmas\nAlarmas" instead of "Alarmas".
child: ExcludeSemantics( child: ExcludeSemantics(
child: Transform.translate( // t4/4a spec: active item lift `translateY(-15px)`. Every
// t4/4a spec: active item lift `translateY(-15px)`. // property here used to change INSTANTLY while the balloon
offset: Offset(0, selected ? -15 : 0), // behind it slid with AnimatedPositioned — the balloon
// glided and its contents teleported, which read as a
// broken transition. All four now share the balloon's own
// duration and curve so the whole tab moves as one.
child: AnimatedSlide(
duration: motion.normal,
curve: Curves.easeOutCubic,
// Slide is expressed in fractions of the child's size;
// the icon column is ~40 tall, so -15px is about -0.375.
offset: Offset(0, selected ? -0.375 : 0),
child: AnimatedSize(
duration: motion.normal,
curve: Curves.easeOutCubic,
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
Opacity( AnimatedOpacity(
duration: motion.normal,
curve: Curves.easeOutCubic,
// t4/4a spec: active icon full colour; inactive // t4/4a spec: active icon full colour; inactive
// `rgba(242,247,250,.46)` — .46 applied here as // `rgba(242,247,250,.46)` — .46 applied here as
// uniform opacity dims both the fallback Icon // uniform opacity dims both the fallback Icon
@@ -252,19 +275,25 @@ class _PluriNavButton extends StatelessWidget {
// PluriIconVariant.filled) and the real raster // PluriIconVariant.filled) and the real raster
// badge asset identically. // badge asset identically.
opacity: selected ? 1 : 0.46, opacity: selected ? 1 : 0.46,
child: PluriIcon( child: TweenAnimationBuilder<double>(
duration: motion.normal,
curve: Curves.easeOutCubic,
tween: Tween<double>(end: selected ? 25 : 23),
builder:
(context, size, _) => PluriIcon(
glyph: item.glyph, glyph: item.glyph,
variant: PluriIconVariant.filled, variant: PluriIconVariant.filled,
// t4/4a spec: icon `font-size:25px`/`23px`. // t4/4a spec: `font-size:25px`/`23px`.
size: selected ? 25 : 23, size: size,
color: selected ? t.electricMagenta : null, color: selected ? t.electricMagenta : null,
// Same ARB string the outer Semantics already // Same ARB string the outer Semantics
// uses — passing it explicitly skips // already uses — passing it explicitly
// PluriIcon's own AppLocalizations.of lookup // skips PluriIcon's own
// (excluded from the tree above regardless). // AppLocalizations.of lookup.
semanticLabel: item.label, semanticLabel: item.label,
), ),
), ),
),
if (selected) ...[ if (selected) ...[
const SizedBox(height: 3), const SizedBox(height: 3),
Text( Text(
@@ -294,6 +323,7 @@ class _PluriNavButton extends StatelessWidget {
), ),
), ),
), ),
),
); );
} }
} }
+19 -6
View File
@@ -14,6 +14,13 @@ import 'pluri_layout.dart';
void showPluriSleepTimerSheet(BuildContext context) { void showPluriSleepTimerSheet(BuildContext context) {
showModalBottomSheet( showModalBottomSheet(
context: context, context: context,
// Issue 2 (feedback-pruebas): without this, the sheet is capped to a
// FRACTION of the screen height. That never mattered while the sheet
// always closed immediately after picking a duration (see the removed
// `Navigator.pop` calls below) -- now that the countdown view actually
// stays open, its title + description + headline-sized remaining-time
// text can overflow that capped height on a real phone width.
isScrollControlled: true,
showDragHandle: true, showDragHandle: true,
builder: builder:
(ctx) => Consumer<EstadoRadio>( (ctx) => Consumer<EstadoRadio>(
@@ -79,12 +86,17 @@ void showPluriSleepTimerSheet(BuildContext context) {
Duration(seconds: segundos), Duration(seconds: segundos),
), ),
), ),
onPressed: () { // Issue 2 (feedback-pruebas): no longer pops
estado.iniciarTimerDuracion( // the sheet -- `estado.iniciarTimerDuracion`
// notifies this `Consumer<EstadoRadio>`,
// which swaps straight to the countdown
// view above so the user actually SEES the
// remaining time instead of the sheet just
// closing with no feedback.
onPressed:
() => estado.iniciarTimerDuracion(
Duration(seconds: segundos), Duration(seconds: segundos),
); ),
Navigator.pop(ctx);
},
), ),
ActionChip( ActionChip(
avatar: const Icon(Icons.tune_rounded, size: 18), avatar: const Icon(Icons.tune_rounded, size: 18),
@@ -93,8 +105,9 @@ void showPluriSleepTimerSheet(BuildContext context) {
final duracion = final duracion =
await _pedirDuracionPersonalizada(ctx); await _pedirDuracionPersonalizada(ctx);
if (duracion == null || !ctx.mounted) return; if (duracion == null || !ctx.mounted) return;
// Issue 2: same as above -- stays open on
// the countdown view rather than closing.
estado.iniciarTimerDuracion(duracion); estado.iniciarTimerDuracion(duracion);
Navigator.pop(ctx);
}, },
), ),
], ],
@@ -0,0 +1,92 @@
import 'package:flutter/material.dart';
import '../l10n/gen/app_localizations.dart';
import '../tema/pluriwave_theme.dart';
import 'pluri_icon.dart';
/// Shared station-art fallback (feedback-pruebas issue 6).
///
/// Before this widget existed, `TarjetaEmisora` had the only good fallback —
/// a deterministic pick from 4 bundled illustrations (`_fallbackArtFor`),
/// with a gradient + player glyph as a last resort if the asset itself ever
/// fails to decode. Every OTHER surface that can render a station without
/// artwork (`FilaEmisoraPlana`'s flat rows, the Escuchar hero, the "Tus
/// emisoras" grid cell, the mini player, the full player) had its own,
/// separate, much poorer copy: a flat `primaryContainer`-coloured square
/// with a bare `radio_rounded` icon. This widget is the ONE shared
/// implementation every one of those call sites now uses instead.
///
/// The selection formula is pinned EXACTLY as `TarjetaEmisora` originally
/// had it (`seed.codeUnits.fold<int>(0, (a, b) => a + b) % 4`, asset order
/// aurora/cosmic/pulse/nova) — `lib/servicios/navegacion_auto.dart` (a
/// protected, empty-diff file) independently mirrors this SAME formula for
/// Android Auto's own drawable-resource rotation
/// (`test/servicios/navegacion_auto_test.dart`'s `indiceArtePara`/
/// `artUriPara` tests assert it inline, not by importing this widget) —
/// changing the order or the modulo here would silently desync the two
/// without either test suite noticing until an actual device compared them
/// side by side.
class PluriStationArtFallback extends StatelessWidget {
const PluriStationArtFallback({
super.key,
required this.seed,
this.iconSize = 22,
});
/// Typically the station's `uuid` — the seed that deterministically picks
/// one of the 4 bundled arts below.
final String seed;
/// Size of the centred player glyph drawn over the art.
final double iconSize;
static const _arts = [
'assets/images/station_art_aurora.png',
'assets/images/station_art_cosmic.png',
'assets/images/station_art_pulse.png',
'assets/images/station_art_nova.png',
];
/// Exposed so other call sites (and tests) can assert which asset a given
/// seed resolves to without needing to render the widget.
static String artFor(String seed) {
final index = seed.codeUnits.fold<int>(0, (a, b) => a + b) % _arts.length;
return _arts[index];
}
@override
Widget build(BuildContext context) {
return Stack(
fit: StackFit.expand,
children: [
Image.asset(
artFor(seed),
fit: BoxFit.cover,
errorBuilder:
(_, __, ___) => DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [
context.pluriTokens.deepViolet,
context.pluriTokens.electricMagenta.withValues(
alpha: 0.8,
),
],
),
),
),
),
Center(
child: PluriIcon(
glyph: PluriIconGlyph.player,
variant: PluriIconVariant.activeGlow,
size: iconSize,
semanticLabel: AppLocalizations.of(context).stationIconLabel,
),
),
],
);
}
}
+6 -44
View File
@@ -10,6 +10,7 @@ import '../modelos/emisora.dart';
import '../tema/pluriwave_theme.dart'; import '../tema/pluriwave_theme.dart';
import 'pluri_glass_surface.dart'; import 'pluri_glass_surface.dart';
import 'pluri_icon.dart'; import 'pluri_icon.dart';
import 'pluri_station_art_fallback.dart';
/// Tarjeta compacta para mostrar una emisora en listas y grids. /// Tarjeta compacta para mostrar una emisora en listas y grids.
/// Incluye botón de favorito visible en ambos modos. /// Incluye botón de favorito visible en ambos modos.
@@ -302,51 +303,12 @@ class _TarjetaEmisoraState extends State<TarjetaEmisora> {
); );
} }
// Issue 6 (feedback-pruebas): extracted into the shared
// `PluriStationArtFallback` so every surface that can render a station
// without artwork shows the SAME fallback — see that widget's own doc
// comment for why the selection formula must never drift.
Widget _iconoFallback(double size) { Widget _iconoFallback(double size) {
final art = _fallbackArtFor(widget.emisora.uuid); return PluriStationArtFallback(seed: widget.emisora.uuid, iconSize: size);
return Stack(
fit: StackFit.expand,
children: [
Image.asset(
art,
fit: BoxFit.cover,
errorBuilder:
(_, __, ___) => DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [
context.pluriTokens.deepViolet,
context.pluriTokens.electricMagenta.withValues(
alpha: 0.8,
),
],
),
),
),
),
Center(
child: PluriIcon(
glyph: PluriIconGlyph.player,
variant: PluriIconVariant.activeGlow,
size: size,
semanticLabel: AppLocalizations.of(context).stationIconLabel,
),
),
],
);
}
String _fallbackArtFor(String seed) {
const arts = [
'assets/images/station_art_aurora.png',
'assets/images/station_art_cosmic.png',
'assets/images/station_art_pulse.png',
'assets/images/station_art_nova.png',
];
final index = seed.codeUnits.fold<int>(0, (a, b) => a + b) % arts.length;
return arts[index];
} }
} }
+122 -67
View File
@@ -412,14 +412,10 @@ void main() {
); );
}); });
group( group('EstadoRadio — emisoras custom: lectura tolerante y guardia de '
'EstadoRadio — emisoras custom: lectura tolerante y guardia de ' 'degradacion (persistence-resilience)', () {
'degradacion (persistence-resilience)', test('entradas invalidas se omiten sin perder las validas ni fabricar '
() { 'uuid (D5 parcial)', () async {
test(
'entradas invalidas se omiten sin perder las validas ni fabricar '
'uuid (D5 parcial)',
() async {
final archivo = await _crearArchivoCustomRaw( final archivo = await _crearArchivoCustomRaw(
jsonEncode([ jsonEncode([
{'uuid': 'custom-1', 'nombre': 'Valida Uno', 'url': 'http://a'}, {'uuid': 'custom-1', 'nombre': 'Valida Uno', 'url': 'http://a'},
@@ -446,13 +442,10 @@ void main() {
'custom-1', 'custom-1',
'custom-2', 'custom-2',
}); });
}, });
);
test( test('si resolver la ruta del archivo custom falla, la inicializacion '
'si resolver la ruta del archivo custom falla, la inicializacion ' 'sobrevive y las cargas hermanas no se pierden (D5 IO-fail)', () async {
'sobrevive y las cargas hermanas no se pierden (D5 IO-fail)',
() async {
final estado = EstadoRadio( final estado = EstadoRadio(
audio: FakeServicioAudio(), audio: FakeServicioAudio(),
favoritos: FakeServicioFavoritos(), favoritos: FakeServicioFavoritos(),
@@ -470,13 +463,10 @@ void main() {
await estado.inicializar(); await estado.inicializar();
expect(estado.emisorasCustom, isEmpty); expect(estado.emisorasCustom, isEmpty);
}, });
);
test( test('JSON invalido al nivel superior pone en cuarentena el archivo '
'JSON invalido al nivel superior pone en cuarentena el archivo ' 'original (D5 parse-fail)', () async {
'original (D5 parse-fail)',
() async {
final archivo = await _crearArchivoCustomRaw('{bad'); final archivo = await _crearArchivoCustomRaw('{bad');
final estado = EstadoRadio( final estado = EstadoRadio(
audio: FakeServicioAudio(), audio: FakeServicioAudio(),
@@ -494,13 +484,10 @@ void main() {
expect(await sidecar.exists(), isTrue); expect(await sidecar.exists(), isTrue);
expect(await sidecar.readAsString(), '{bad'); expect(await sidecar.readAsString(), '{bad');
expect(await archivo.exists(), isFalse); expect(await archivo.exists(), isFalse);
}, });
);
test( test('agregar tras la cuarentena escribe solo la nueva emisora y no '
'agregar tras la cuarentena escribe solo la nueva emisora y no ' 'toca el sidecar (D5, autoridad de escritura restaurada)', () async {
'toca el sidecar (D5, autoridad de escritura restaurada)',
() async {
final archivo = await _crearArchivoCustomRaw('{bad'); final archivo = await _crearArchivoCustomRaw('{bad');
final estado = EstadoRadio( final estado = EstadoRadio(
audio: FakeServicioAudio(), audio: FakeServicioAudio(),
@@ -518,25 +505,20 @@ void main() {
await estado.agregarEmisoraCustom(nueva); await estado.agregarEmisoraCustom(nueva);
expect(estado.emisorasCustom.map((e) => e.uuid), ['nueva-1']); expect(estado.emisorasCustom.map((e) => e.uuid), ['nueva-1']);
final contenidoVivo = final contenidoVivo = jsonDecode(await archivo.readAsString()) as List;
jsonDecode(await archivo.readAsString()) as List;
expect(contenidoVivo, hasLength(1)); expect(contenidoVivo, hasLength(1));
expect((contenidoVivo.single as Map)['uuid'], 'nueva-1'); expect((contenidoVivo.single as Map)['uuid'], 'nueva-1');
expect(await sidecar.readAsString(), sidecarPrevio); expect(await sidecar.readAsString(), sidecarPrevio);
}, });
);
test( test('fallo de IO al leer suprime la escritura y no se restaura con un '
'fallo de IO al leer suprime la escritura y no se restaura con un ' 'alta explicita (D5 IO-fail)', () async {
'alta explicita (D5 IO-fail)',
() async {
final espia = _ArchivoEspia( final espia = _ArchivoEspia(
path: '/fake/emisoras_custom.json', path: '/fake/emisoras_custom.json',
exists: () async => true, exists: () async => true,
readAsString: readAsString:
() async => throw const FileSystemException( () async =>
'fallo simulado de lectura', throw const FileSystemException('fallo simulado de lectura'),
),
); );
final estado = EstadoRadio( final estado = EstadoRadio(
audio: FakeServicioAudio(), audio: FakeServicioAudio(),
@@ -554,18 +536,12 @@ void main() {
emisoraDemo(uuid: 'nueva-x', nombre: 'X'), emisoraDemo(uuid: 'nueva-x', nombre: 'X'),
); );
expect( expect(estado.emisorasCustom.map((e) => e.uuid), contains('nueva-x'));
estado.emisorasCustom.map((e) => e.uuid),
contains('nueva-x'),
);
expect(espia.writeAsStringCalls, 0); expect(espia.writeAsStringCalls, 0);
}, });
);
test( test('si ya existe un sidecar .corrupt no lo pisa, solo limpia el '
'si ya existe un sidecar .corrupt no lo pisa, solo limpia el ' 'archivo vivo (D5)', () async {
'archivo vivo (D5)',
() async {
final archivo = await _crearArchivoCustomRaw('{bad-nuevo'); final archivo = await _crearArchivoCustomRaw('{bad-nuevo');
final sidecar = File('${archivo.path}.corrupt'); final sidecar = File('${archivo.path}.corrupt');
await sidecar.writeAsString('contenido-previo-X'); await sidecar.writeAsString('contenido-previo-X');
@@ -584,19 +560,13 @@ void main() {
expect(await sidecar.readAsString(), 'contenido-previo-X'); expect(await sidecar.readAsString(), 'contenido-previo-X');
expect(await archivo.exists(), isFalse); expect(await archivo.exists(), isFalse);
expect(estado.emisorasCustom, isEmpty); expect(estado.emisorasCustom, isEmpty);
}, });
); });
},
);
group( group('EstadoRadio — Android Auto: snapshot en vivo y reconciliación '
'EstadoRadio — Android Auto: snapshot en vivo y reconciliación ' '(android-auto-media)', () {
'(android-auto-media)', test('empuja un snapshot actualizado a la fuente registrada cuando '
() { 'cambian favoritos/custom/populares', () async {
test(
'empuja un snapshot actualizado a la fuente registrada cuando '
'cambian favoritos/custom/populares',
() async {
final fuenteAuto = _FuenteEmisorasAutoEspia(); final fuenteAuto = _FuenteEmisorasAutoEspia();
final archivo = await _crearArchivoCustom([ final archivo = await _crearArchivoCustom([
emisoraDemo(uuid: 'custom-auto-1', nombre: 'Custom Auto'), emisoraDemo(uuid: 'custom-auto-1', nombre: 'Custom Auto'),
@@ -635,13 +605,10 @@ void main() {
fuenteAuto.ultimoFavoritos?.map((e) => e.uuid), fuenteAuto.ultimoFavoritos?.map((e) => e.uuid),
contains('fav-auto-1'), contains('fav-auto-1'),
); );
}, });
);
test( test('reconcilia _emisoraSeleccionada cuando la selección viene desde '
'reconcilia _emisoraSeleccionada cuando la selección viene desde ' 'el auto (no via reproducir())', () async {
'el auto (no via reproducir())',
() async {
final audio = _AudioControlado(); final audio = _AudioControlado();
final estado = EstadoRadio( final estado = EstadoRadio(
audio: audio, audio: audio,
@@ -661,10 +628,98 @@ void main() {
await Future<void>.delayed(Duration.zero); await Future<void>.delayed(Duration.zero);
expect(estado.emisoraActual?.uuid, desdeCoche.uuid); expect(estado.emisoraActual?.uuid, desdeCoche.uuid);
}, });
});
group('EstadoRadio — última emisora reproducida (feedback-pruebas #4)', () {
test('la última emisora reproducida sobrevive a una nueva instancia '
'(reinicio) como emisoraActual DETENIDA -- no arranca audio, no '
'reproduce, sólo queda seleccionada', () async {
final emisora = emisoraDemo(uuid: 'last-1', nombre: 'Ultima FM');
final estadoUno = EstadoRadio(
audio: FakeServicioAudio(),
favoritos: FakeServicioFavoritos(),
radio: FakeServicioRadio(),
servicioEcualizador: FakeServicioEcualizador(),
resolverArchivoCustom: _archivoCustomVacio,
iniciarAutomaticamente: false,
); );
}, await estadoUno.inicializar();
await estadoUno.reproducir(emisora);
await estadoUno.detenerReproduccion();
// Lets the fire-and-forget persistence write settle before
// spinning up the "restart" instance.
await Future<void>.delayed(Duration.zero);
final audioDos = FakeServicioAudio();
final estadoDos = EstadoRadio(
audio: audioDos,
favoritos: FakeServicioFavoritos(),
radio: FakeServicioRadio(),
servicioEcualizador: FakeServicioEcualizador(),
resolverArchivoCustom: _archivoCustomVacio,
iniciarAutomaticamente: false,
); );
await estadoDos.inicializar();
expect(estadoDos.emisoraActual?.uuid, emisora.uuid);
expect(estadoDos.emisoraActual?.nombre, emisora.nombre);
expect(
audioDos.estaSonando,
isFalse,
reason: 'restoring the last station must never auto-start audio',
);
});
test('sin ninguna emisora previamente reproducida, emisoraActual sigue '
'siendo null tras inicializar (instalación nueva)', () async {
final estado = EstadoRadio(
audio: FakeServicioAudio(),
favoritos: FakeServicioFavoritos(),
radio: FakeServicioRadio(),
servicioEcualizador: FakeServicioEcualizador(),
resolverArchivoCustom: _archivoCustomVacio,
iniciarAutomaticamente: false,
);
await estado.inicializar();
expect(estado.emisoraActual, isNull);
});
test('una emisora seleccionada desde el auto (fuera de reproducir()) '
'también se recuerda para la próxima instancia', () async {
final audio = _AudioControlado();
final estadoUno = EstadoRadio(
audio: audio,
favoritos: FakeServicioFavoritos(),
radio: FakeServicioRadio(),
servicioEcualizador: FakeServicioEcualizador(),
resolverArchivoCustom: _archivoCustomVacio,
iniciarAutomaticamente: false,
);
await estadoUno.inicializar();
final desdeCoche = emisoraDemo(
uuid: 'auto-remembered',
nombre: 'Recordada desde el auto',
);
audio.seleccionarDesdeAuto(desdeCoche);
await Future<void>.delayed(Duration.zero);
final estadoDos = EstadoRadio(
audio: FakeServicioAudio(),
favoritos: FakeServicioFavoritos(),
radio: FakeServicioRadio(),
servicioEcualizador: FakeServicioEcualizador(),
resolverArchivoCustom: _archivoCustomVacio,
iniciarAutomaticamente: false,
);
await estadoDos.inicializar();
expect(estadoDos.emisoraActual?.uuid, desdeCoche.uuid);
});
});
} }
/// Spy [FuenteEmisorasAuto] that only records the last snapshot pushed by /// Spy [FuenteEmisorasAuto] that only records the last snapshot pushed by
@@ -98,6 +98,75 @@ void main() {
}, },
); );
testWidgets(
'issue 5 (feedback-pruebas): a long trailing value does not squeeze the '
'title into wrapping across multiple lines -- the title stays on ONE '
'line, ellipsizing instead',
(tester) async {
const titulo = 'Emisora preferida';
const valorLargo = 'Radio Nacional Clasica Internacional FM Stereo HD';
await tester.pumpWidget(
host(
SizedBox(
width: 360,
child: FilaAjuste(
icon: Icons.radio_rounded,
titulo: titulo,
valor: valorLargo,
onTap: () {},
),
),
),
);
await tester.pump();
expect(
tester.takeException(),
isNull,
reason: 'a squeezed row must not overflow either',
);
final tituloWidget = tester.widget<Text>(find.text(titulo));
expect(
tituloWidget.maxLines,
1,
reason: 'issue 5: the title must be constrained to a single line',
);
expect(tituloWidget.overflow, TextOverflow.ellipsis);
// Measured, not just `find.text` (a wrapped-but-still-present Text
// would still satisfy a bare `find.text` match, per the known
// "find.text can't catch visual wrap" trap) -- compare the rendered
// height against the SAME style/width rendered with a title that is
// guaranteed to fit on one line.
final alturaConValorLargo = tester.getSize(find.text(titulo)).height;
await tester.pumpWidget(
host(
SizedBox(
width: 360,
child: FilaAjuste(
icon: Icons.radio_rounded,
titulo: titulo,
onTap: () {},
),
),
),
);
await tester.pump();
final alturaReferencia = tester.getSize(find.text(titulo)).height;
expect(
alturaConValorLargo,
closeTo(alturaReferencia, 1.0),
reason:
'the title rendered taller with a long value present -- it '
'wrapped instead of staying on a single ellipsized line',
);
},
);
testWidgets( testWidgets(
'visual fidelity (audit S10): GrupoAjustes insets its row divider by ' 'visual fidelity (audit S10): GrupoAjustes insets its row divider by '
'47px, not full-bleed (t4 line 516)', '47px, not full-bleed (t4 line 516)',
+73
View File
@@ -15,6 +15,7 @@ import 'package:pluriwave/servicios/servicio_audio.dart';
import 'package:pluriwave/tema/pluriwave_theme.dart'; import 'package:pluriwave/tema/pluriwave_theme.dart';
import 'package:pluriwave/tema/pluriwave_tokens.dart'; import 'package:pluriwave/tema/pluriwave_tokens.dart';
import 'package:pluriwave/widgets/fila_emisora_plana.dart'; import 'package:pluriwave/widgets/fila_emisora_plana.dart';
import 'package:pluriwave/widgets/pluri_layout.dart';
import 'package:pluriwave/widgets/tarjeta_emisora.dart'; import 'package:pluriwave/widgets/tarjeta_emisora.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
@@ -835,6 +836,78 @@ void main() {
); );
}); });
testWidgets('issue 3 (feedback-pruebas): the results list uses row-tier '
'horizontal padding (12), not the card-tier constant this "flat, '
'background-less row" was documented as needing but never got', (
tester,
) async {
_setLargeSurfaceSize(tester);
final estado = _crearEstado(
radio: FakeServicioRadio(
busqueda: [emisoraDemo(uuid: 'r-1', nombre: 'Radio Uno')],
),
);
addTearDown(estado.dispose);
await tester.runAsync(estado.inicializar);
await tester.pumpWidget(_conProviders(estado, _testApp()));
await _pumpStableFrame(tester);
await tester.enterText(find.byType(SearchBar), 'radio');
await tester.testTextInput.receiveAction(TextInputAction.done);
await _pumpStableFrame(tester);
final fila = find.byType(FilaEmisoraPlana);
expect(
tester.getTopLeft(fila).dx,
PluriLayout.rowHorizontal,
reason:
'issue 3: background-less rows are row tier (12), not card '
'tier (16)',
);
});
testWidgets('issue 3 (feedback-pruebas): the results list is topped by the '
'standard section gap, not the horizontal-inset constant reused for '
'a vertical axis', (tester) async {
_setLargeSurfaceSize(tester);
final estado = _crearEstado(
radio: FakeServicioRadio(
busqueda: [emisoraDemo(uuid: 'r-1', nombre: 'Radio Uno')],
),
);
addTearDown(estado.dispose);
await tester.runAsync(estado.inicializar);
await tester.pumpWidget(_conProviders(estado, _testApp()));
await _pumpStableFrame(tester);
await tester.enterText(find.byType(SearchBar), 'radio');
await tester.testTextInput.receiveAction(TextInputAction.done);
await _pumpStableFrame(tester);
// Reads the structural padding directly, rather than measuring a
// gap between two rendered widgets — the count row's own height is
// dictated by its taller PopupMenuButton (48dp touch target), so a
// position-based gap measurement against the count TEXT specifically
// would be thrown off by that unrelated vertical centring.
//
// Scoped to `shrinkWrap: true` — the OUTER page ListView is ALSO an
// ancestor of every `FilaEmisoraPlana`, but only `_resultados`'s OWN
// inner `ListView.builder` sets `shrinkWrap`.
final listaResultados = tester.widget<ListView>(
find.byWidgetPredicate((w) => w is ListView && w.shrinkWrap),
);
final padding = listaResultados.padding as EdgeInsets;
expect(
padding.top,
PluriLayout.sectionGap,
reason:
'issue 3: the results list must use the dedicated vertical '
'section gap above its first row, not the horizontal (16) '
'constant reused for a vertical axis',
);
});
testWidgets( testWidgets(
'tapping the favourite toggle on a search result adds it to favorites', 'tapping the favourite toggle on a search result adds it to favorites',
(tester) async { (tester) async {
@@ -472,6 +472,24 @@ void main() {
expect(find.text('Move to list'), findsOneWidget); expect(find.text('Move to list'), findsOneWidget);
expect(find.text('Remove from favorites'), findsOneWidget); expect(find.text('Remove from favorites'), findsOneWidget);
expect(find.byType(PopupMenuItem<String>), findsNWidgets(2)); expect(find.byType(PopupMenuItem<String>), findsNWidgets(2));
// Regression guard for a real user-reported bug: the button carried
// `constraints: BoxConstraints.tightFor(width: 38, height: 42)`,
// which sizes the POPUP MENU rather than the button. Every item was
// clipped to its first letter — users saw "M" and "E", not the
// labels. The three assertions above all PASSED throughout, because
// find.text matches a Text widget in the tree whether or not it is
// visually clipped. Only measuring the laid-out width catches it.
final anchoItem = tester.getSize(
find.byType(PopupMenuItem<String>).first,
);
expect(
anchoItem.width,
greaterThan(100),
reason:
'a menu item narrower than its label means the popup is being '
'constrained and the text is clipped',
);
}, },
); );
+71
View File
@@ -11,6 +11,7 @@ import 'package:pluriwave/l10n/gen/app_localizations.dart';
import 'package:pluriwave/modelos/emisora.dart'; import 'package:pluriwave/modelos/emisora.dart';
import 'package:pluriwave/pantallas/pantalla_inicio.dart'; import 'package:pluriwave/pantallas/pantalla_inicio.dart';
import 'package:pluriwave/widgets/pluri_root_header.dart'; import 'package:pluriwave/widgets/pluri_root_header.dart';
import 'package:pluriwave/widgets/pluri_station_art_fallback.dart';
import 'package:pluriwave/widgets/visualizador_audio.dart'; import 'package:pluriwave/widgets/visualizador_audio.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
@@ -698,6 +699,76 @@ void main() {
); );
}, },
); );
testWidgets('issue 6 (feedback-pruebas): the Escuchar hero shows the shared '
'PluriStationArtFallback for a station with no favicon, not a flat '
'coloured square', (tester) async {
_setLargeSurfaceSize(tester);
final favoritos = FakeServicioFavoritos();
final estado = EstadoRadio(
audio: FakeServicioAudio(),
favoritos: favoritos,
radio: FakeServicioRadio(),
servicioEcualizador: FakeServicioEcualizador(),
servicioGrabacion: FakeServicioGrabacionRadio(),
resolverArchivoCustom: _archivoCustomVacio,
iniciarAutomaticamente: false,
);
addTearDown(estado.dispose);
await tester.runAsync(estado.inicializar);
final sonando = emisoraDemo(uuid: 'sin-arte', nombre: 'Sin Arte');
await tester.runAsync(() => estado.reproducir(sonando));
await tester.pumpWidget(
_conProviders(estado, _testApp(const PantallaInicio())),
);
await _pumpBounded(tester);
expect(
find.byType(PluriStationArtFallback),
findsWidgets,
reason:
'issue 6: the hero must reach the shared fallback, not its own '
'flat primaryContainer square',
);
});
testWidgets(
'issue 6 (feedback-pruebas): a "Tus emisoras" grid cell shows the shared '
'PluriStationArtFallback for a favourite with no favicon',
(tester) async {
_setLargeSurfaceSize(tester);
final favoritos = FakeServicioFavoritos();
final estado = EstadoRadio(
audio: FakeServicioAudio(),
favoritos: favoritos,
radio: FakeServicioRadio(),
servicioEcualizador: FakeServicioEcualizador(),
servicioGrabacion: FakeServicioGrabacionRadio(),
resolverArchivoCustom: _archivoCustomVacio,
iniciarAutomaticamente: false,
);
addTearDown(estado.dispose);
await tester.runAsync(estado.inicializar);
await favoritos.agregar(
emisoraDemo(uuid: 'grid-sin-arte', nombre: 'Grid Sin Arte'),
);
await estado.cargarFavoritos();
await tester.pumpWidget(
_conProviders(estado, _testApp(const PantallaInicio())),
);
await _pumpStableFrame(tester);
expect(
find.byType(PluriStationArtFallback),
findsWidgets,
reason:
'issue 6: the grid cell must reach the shared fallback, not its '
'own flat primaryContainer square',
);
},
);
} }
/// Mirrors the app.dart wiring: EstadoRadio owns the domain notifiers and /// Mirrors the app.dart wiring: EstadoRadio owns the domain notifiers and
@@ -12,6 +12,7 @@ import 'package:pluriwave/pantallas/pantalla_reproductor.dart';
import 'package:pluriwave/tema/pluriwave_tokens.dart'; import 'package:pluriwave/tema/pluriwave_tokens.dart';
import 'package:pluriwave/widgets/ecualizador_widget.dart'; import 'package:pluriwave/widgets/ecualizador_widget.dart';
import 'package:pluriwave/widgets/pluri_glass_surface.dart'; import 'package:pluriwave/widgets/pluri_glass_surface.dart';
import 'package:pluriwave/widgets/pluri_station_art_fallback.dart';
import 'package:pluriwave/widgets/visualizador_audio.dart'; import 'package:pluriwave/widgets/visualizador_audio.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
@@ -704,4 +705,30 @@ void main() {
}, },
); );
}); });
group('issue 6 (feedback-pruebas): shared station-art fallback', () {
testWidgets(
'a station with no favicon shows the shared PluriStationArtFallback, '
'not a flat coloured square',
(tester) async {
// `emisora` (the file-level fixture) never sets a favicon.
final estado = crearEstado();
addTearDown(estado.dispose);
await montarPantalla(tester, estado);
final arte = find.byKey(const Key('player-hero-art'));
expect(
find.descendant(
of: arte,
matching: find.byType(PluriStationArtFallback),
),
findsOneWidget,
reason:
'issue 6: the full player must reach the shared fallback, '
'not its own flat primaryContainer square',
);
},
);
});
} }
@@ -445,4 +445,173 @@ void main() {
}, },
); );
}); });
group('issue 1 (feedback-pruebas): editar y eliminar rangos', () {
testWidgets(
'tocar la tarjeta de un rango programado abre el editor precargado '
'con su nombre y fechas',
(tester) async {
final estado = await _crearEstado(
vacaciones: [
RangoVacaciones(
id: 'f2',
nombre: 'Verano',
inicio: _hoyDia.add(const Duration(days: 20)),
fin: _hoyDia.add(const Duration(days: 25)),
),
],
);
addTearDown(estado.dispose);
await tester.pumpWidget(_buildScreen(estado));
await _pumpEstable(tester);
final l10n = AppLocalizations.of(
tester.element(find.byType(PantallaVacaciones)),
);
await tester.tap(find.byKey(const ValueKey('vacaciones-tarjeta-f2')));
await _pumpEstable(tester);
expect(find.text(l10n.editVacationRangeTitle), findsOneWidget);
// Scoped to the TextField specifically -- the original card's OWN
// "Verano" label is still (offstage, behind the modal) in the tree,
// so a bare `find.text('Verano')` would ambiguously match both.
expect(find.widgetWithText(TextField, 'Verano'), findsOneWidget);
},
);
testWidgets(
'guardar el editor abierto por tap actualiza el rango existente (no '
'crea uno nuevo)',
(tester) async {
final estado = await _crearEstado(
vacaciones: [
RangoVacaciones(
id: 'f2',
nombre: 'Verano',
inicio: _hoyDia.add(const Duration(days: 20)),
fin: _hoyDia.add(const Duration(days: 25)),
),
],
);
addTearDown(estado.dispose);
await tester.pumpWidget(_buildScreen(estado));
await _pumpEstable(tester);
final l10n = AppLocalizations.of(
tester.element(find.byType(PantallaVacaciones)),
);
await tester.tap(find.byKey(const ValueKey('vacaciones-tarjeta-f2')));
await _pumpEstable(tester);
await tester.enterText(find.byType(TextField), 'Verano renombrado');
final boton = tester.widget<FilledButton>(
find.widgetWithText(FilledButton, l10n.saveRangeAction),
);
boton.onPressed!();
await _pumpEstable(tester);
expect(estado.vacaciones, hasLength(1));
expect(estado.vacaciones.single.id, 'f2');
expect(estado.vacaciones.single.nombre, 'Verano renombrado');
},
);
testWidgets(
'deslizar la tarjeta de un rango pide confirmacion; cancelar la '
'conserva y confirmar la elimina',
(tester) async {
final estado = await _crearEstado(
vacaciones: [
RangoVacaciones(
id: 'f2',
nombre: 'Verano',
inicio: _hoyDia.add(const Duration(days: 20)),
fin: _hoyDia.add(const Duration(days: 25)),
),
],
);
addTearDown(estado.dispose);
await tester.pumpWidget(_buildScreen(estado));
await _pumpEstable(tester);
final l10n = AppLocalizations.of(
tester.element(find.byType(PantallaVacaciones)),
);
// Cancelar: el rango se conserva.
await tester.drag(
find.byKey(const ValueKey('vacaciones-tarjeta-f2')),
const Offset(-600, 0),
);
await _pumpEstable(tester);
expect(find.text(l10n.vacationDeleteConfirmTitle), findsOneWidget);
await tester.tap(find.text(l10n.cancelAction));
await _pumpEstable(tester);
expect(estado.vacaciones, hasLength(1));
// Confirmar: el rango se elimina.
await tester.drag(
find.byKey(const ValueKey('vacaciones-tarjeta-f2')),
const Offset(-600, 0),
);
await _pumpEstable(tester);
expect(find.text(l10n.vacationDeleteConfirmTitle), findsOneWidget);
await tester.tap(find.text(l10n.deleteAction));
await _pumpEstable(tester);
expect(estado.vacaciones, isEmpty);
},
);
testWidgets(
'el rango ACTIVO (mostrado en el hero) tambien se puede editar (tap) '
'y eliminar (swipe) -- un rango recien creado siempre esta activo y '
'nunca aparece en las listas programado/pasado',
(tester) async {
final estado = await _crearEstado(
vacaciones: [
RangoVacaciones(
id: 'v1',
nombre: 'Julio activo',
inicio: _hoyDia.subtract(const Duration(days: 3)),
fin: _hoyDia.add(const Duration(days: 5)),
),
],
);
addTearDown(estado.dispose);
await tester.pumpWidget(_buildScreen(estado));
await _pumpEstable(tester);
final l10n = AppLocalizations.of(
tester.element(find.byType(PantallaVacaciones)),
);
await tester.tap(find.byKey(const ValueKey('vacaciones-tarjeta-v1')));
await _pumpEstable(tester);
expect(find.text(l10n.editVacationRangeTitle), findsOneWidget);
// Scoped to the TextField specifically -- the hero's OWN "Julio
// activo" label is still (offstage, behind the modal) in the tree.
expect(find.widgetWithText(TextField, 'Julio activo'), findsOneWidget);
// Dismiss the editor sheet (no explicit close button -- same as the
// pre-existing "Anadir rango" sheet, dismissible via the standard
// modal-bottom-sheet Navigator.pop) before interacting with the
// list underneath it.
Navigator.of(tester.element(find.byType(PantallaVacaciones))).pop();
await _pumpEstable(tester);
await tester.drag(
find.byKey(const ValueKey('vacaciones-tarjeta-v1')),
const Offset(-600, 0),
);
await _pumpEstable(tester);
await tester.tap(find.text(l10n.deleteAction));
await _pumpEstable(tester);
expect(estado.vacaciones, isEmpty);
expect(find.text(l10n.vacationNoActiveRangeHint), findsOneWidget);
},
);
});
} }
+27
View File
@@ -4,6 +4,7 @@ import 'package:pluriwave/estado/estado_radio.dart';
import 'package:pluriwave/l10n/gen/app_localizations.dart'; import 'package:pluriwave/l10n/gen/app_localizations.dart';
import 'package:pluriwave/tema/pluriwave_tokens.dart'; import 'package:pluriwave/tema/pluriwave_tokens.dart';
import 'package:pluriwave/widgets/fila_emisora_plana.dart'; import 'package:pluriwave/widgets/fila_emisora_plana.dart';
import 'package:pluriwave/widgets/pluri_station_art_fallback.dart';
import 'package:pluriwave/widgets/tarjeta_emisora.dart'; import 'package:pluriwave/widgets/tarjeta_emisora.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
@@ -77,6 +78,32 @@ void main() {
); );
}); });
testWidgets(
'issue 6 (feedback-pruebas): a station with no favicon shows the shared '
'PluriStationArtFallback, not a flat coloured square',
(tester) async {
final estado = _estado();
addTearDown(estado.dispose);
final emisora = emisoraDemo(uuid: 'sin-arte', nombre: 'Sin Arte');
await tester.pumpWidget(
_host(estado, FilaEmisoraPlana(emisora: emisora, meta: '')),
);
await tester.pump();
expect(
find.descendant(
of: find.byKey(const ValueKey('fila-emisora-plana-arte')),
matching: find.byType(PluriStationArtFallback),
),
findsOneWidget,
reason:
'issue 6: the flat row must reach the shared fallback, not its '
'own flat primaryContainer square',
);
},
);
testWidgets('omits the meta line entirely when empty (no stray gap)', ( testWidgets('omits the meta line entirely when empty (no stray gap)', (
tester, tester,
) async { ) async {
@@ -5,6 +5,7 @@ import 'package:pluriwave/l10n/gen/app_localizations.dart';
import 'package:pluriwave/tema/pluriwave_theme.dart'; import 'package:pluriwave/tema/pluriwave_theme.dart';
import 'package:pluriwave/tema/pluriwave_tokens.dart'; import 'package:pluriwave/tema/pluriwave_tokens.dart';
import 'package:pluriwave/widgets/mini_reproductor.dart'; import 'package:pluriwave/widgets/mini_reproductor.dart';
import 'package:pluriwave/widgets/pluri_station_art_fallback.dart';
import 'package:pluriwave/widgets/visualizador_audio.dart'; import 'package:pluriwave/widgets/visualizador_audio.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
@@ -75,6 +76,33 @@ void main() {
}, },
); );
testWidgets(
'issue 6 (feedback-pruebas): a station with no favicon shows the shared '
'PluriStationArtFallback, not a flat coloured square',
(tester) async {
final estado = _estadoConEmisora();
addTearDown(estado.dispose);
await estado.reproducir(
emisoraDemo(uuid: 'sin-arte', nombre: 'Sin Arte'),
);
await tester.pumpWidget(_hostFor(estado));
await tester.pump();
final arte = find.byKey(const ValueKey('mini-reproductor-arte'));
expect(
find.descendant(
of: arte,
matching: find.byType(PluriStationArtFallback),
),
findsOneWidget,
reason:
'issue 6: the mini player must reach the shared fallback, not '
'its own flat primaryContainer square',
);
},
);
testWidgets( testWidgets(
'is opaque -- no BackdropFilter -- unlike the former glass pill', 'is opaque -- no BackdropFilter -- unlike the former glass pill',
(tester) async { (tester) async {
@@ -96,13 +96,17 @@ void main() {
); );
expect(inactiveIcon.color, isNull); expect(inactiveIcon.color, isNull);
final dimmed = tester.widget<Opacity>( // AnimatedOpacity, not a plain Opacity: the dim now transitions with the
// balloon instead of snapping. Users reported the bar's contents
// teleporting while the balloon slid — the lift, dim, icon size and
// label all animate together now.
final dimmed = tester.widget<AnimatedOpacity>(
find.ancestor( find.ancestor(
of: find.descendant( of: find.descendant(
of: find.byKey(PluriBottomNavigation.itemKey(1)), of: find.byKey(PluriBottomNavigation.itemKey(1)),
matching: find.byType(PluriIcon), matching: find.byType(PluriIcon),
), ),
matching: find.byType(Opacity), matching: find.byType(AnimatedOpacity),
), ),
); );
expect(dimmed.opacity, closeTo(0.46, 0.001)); expect(dimmed.opacity, closeTo(0.46, 0.001));
@@ -0,0 +1,184 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:pluriwave/estado/estado_radio.dart';
import 'package:pluriwave/l10n/gen/app_localizations.dart';
import 'package:pluriwave/widgets/pluri_sleep_timer_sheet.dart';
import 'package:provider/provider.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../helpers/fakes.dart';
import '../helpers/fakes_alarmas.dart';
/// Issue 2 (feedback-pruebas): "Timer de sueño" opened from Escuchar (and
/// every other root header) showed a bottom sheet with no visible
/// countdown. `ServicioTimer.tiempoRestanteStream`/`tiempoRestante` already
/// existed and this sheet already had a `StreamBuilder` countdown branch —
/// but every preset/custom-duration action popped the sheet immediately
/// after starting the timer, so the countdown never had a chance to render
/// in the primary flow. Zero test coverage existed for this file before.
EstadoRadio _estado() => EstadoRadio(
audio: FakeServicioAudio(),
favoritos: FakeServicioFavoritos(),
radio: FakeServicioRadio(),
servicioEcualizador: FakeServicioEcualizador(),
servicioGrabacion: FakeServicioGrabacionRadioInactiva(),
iniciarAutomaticamente: false,
);
Widget _host(EstadoRadio estado) {
return ChangeNotifierProvider<EstadoRadio>.value(
value: estado,
child: MaterialApp(
locale: const Locale('es'),
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: Scaffold(
body: Builder(
builder:
(context) => TextButton(
onPressed: () => showPluriSleepTimerSheet(context),
child: const Text('abrir'),
),
),
),
),
);
}
void main() {
setUp(() {
SharedPreferences.setMockInitialValues({});
});
// The default 800x600 test viewport is shorter than a typical phone —
// same fix `pantalla_reproductor_test.dart`/`pantalla_inicio_test.dart`
// already established for other bottom-sheet/full-bleed content.
void ajustarSuperficieRealista(WidgetTester tester) {
tester.view.physicalSize = const Size(390, 844);
tester.view.devicePixelRatio = 1.0;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
}
testWidgets(
'selecting a preset keeps the sheet open and switches it to the live '
'remaining-time countdown, instead of closing with no feedback',
(tester) async {
ajustarSuperficieRealista(tester);
final estado = _estado();
addTearDown(estado.dispose);
await tester.pumpWidget(_host(estado));
await tester.tap(find.text('abrir'));
await tester.pumpAndSettle();
final l10n = AppLocalizations.of(tester.element(find.text('abrir')));
expect(estado.timer.activo, isFalse);
expect(find.byType(ActionChip), findsWidgets);
final chip = tester.widget<ActionChip>(
find.widgetWithText(ActionChip, l10n.durationMinutesOnly(5)),
);
chip.onPressed!();
await tester.pump();
expect(estado.timer.activo, isTrue);
expect(
find.byType(ActionChip),
findsNothing,
reason: 'issue 2: the picker is replaced by the countdown view',
);
expect(find.text(l10n.cancelTimer), findsOneWidget);
expect(
find.text(l10n.durationMinutesOnly(5)),
findsOneWidget,
reason: 'issue 2: the remaining time is now surfaced live',
);
// `ServicioTimer` starts a REAL periodic Timer -- `addTearDown`
// callbacks run too late to satisfy the "no pending timers" check
// (a well-known flutter_test ordering quirk), so it must be
// cancelled here, inside the test body, before it ends.
await estado.timer.cancelar();
},
);
testWidgets('reopening the sheet while a timer is already active shows the '
'countdown immediately, not the duration picker', (tester) async {
ajustarSuperficieRealista(tester);
final estado = _estado();
addTearDown(estado.dispose);
estado.iniciarTimerDuracion(const Duration(minutes: 10));
await tester.pumpWidget(_host(estado));
await tester.tap(find.text('abrir'));
await tester.pumpAndSettle();
final l10n = AppLocalizations.of(tester.element(find.text('abrir')));
expect(find.byType(ActionChip), findsNothing);
expect(find.text(l10n.cancelTimer), findsOneWidget);
expect(find.text(l10n.durationMinutesOnly(10)), findsOneWidget);
// See the comment in the previous test — cancel before the body ends.
await estado.timer.cancelar();
});
testWidgets(
'cancelling from the countdown view stops the timer and closes the '
'sheet',
(tester) async {
ajustarSuperficieRealista(tester);
final estado = _estado();
addTearDown(estado.dispose);
estado.iniciarTimerDuracion(const Duration(minutes: 10));
await tester.pumpWidget(_host(estado));
await tester.tap(find.text('abrir'));
await tester.pumpAndSettle();
final l10n = AppLocalizations.of(tester.element(find.text('abrir')));
final boton = tester.widget<FilledButton>(
find.widgetWithText(FilledButton, l10n.cancelTimer),
);
boton.onPressed!();
await tester.pumpAndSettle();
expect(estado.timer.activo, isFalse);
expect(find.text(l10n.cancelTimer), findsNothing);
},
);
testWidgets(
'starting a custom duration ALSO keeps the sheet open on the countdown '
'view, not just the presets',
(tester) async {
ajustarSuperficieRealista(tester);
final estado = _estado();
addTearDown(estado.dispose);
await tester.pumpWidget(_host(estado));
await tester.tap(find.text('abrir'));
await tester.pumpAndSettle();
final l10n = AppLocalizations.of(tester.element(find.text('abrir')));
await tester.tap(find.text(l10n.optionOther));
await tester.pumpAndSettle();
// Confirm the custom-duration sub-sheet directly (default prefilled
// value is already "15" minutes -- see _TimerPersonalizadoSheetState).
final confirmar = tester.widget<FilledButton>(
find.widgetWithText(FilledButton, l10n.startTimer),
);
confirmar.onPressed!();
await tester.pumpAndSettle();
expect(estado.timer.activo, isTrue);
expect(find.byType(ActionChip), findsNothing);
expect(find.text(l10n.cancelTimer), findsOneWidget);
// See the comment in the first test — cancel before the body ends.
await estado.timer.cancelar();
},
);
}
@@ -0,0 +1,86 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:pluriwave/l10n/gen/app_localizations.dart';
import 'package:pluriwave/widgets/pluri_icon.dart';
import 'package:pluriwave/widgets/pluri_station_art_fallback.dart';
/// Issue 6 (feedback-pruebas): the shared station-art fallback extracted
/// from `TarjetaEmisora`'s original `_fallbackArtFor`. The formula and asset
/// order are pinned exactly — `navegacion_auto_test.dart`'s
/// `indiceArtePara`/`artUriPara` tests mirror the SAME formula independently
/// (protected file, empty diff vs main) and must keep agreeing with this
/// widget without either suite importing the other.
void main() {
Widget host(Widget child) {
return MaterialApp(
locale: const Locale('en'),
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: Scaffold(body: SizedBox(width: 60, height: 60, child: child)),
);
}
test('artFor reproduces the canonical aurora/cosmic/pulse/nova order '
'(same formula as navegacion_auto.dart\'s indiceArtePara)', () {
// Single-letter seeds whose codeUnit % 4 covers all 4 indices, mirroring
// navegacion_auto_test.dart's own fixture exactly: 'd'(100)->0 aurora,
// 'a'(97)->1 cosmic, 'b'(98)->2 pulse, 'c'(99)->3 nova.
expect(
PluriStationArtFallback.artFor('d'),
'assets/images/station_art_aurora.png',
);
expect(
PluriStationArtFallback.artFor('a'),
'assets/images/station_art_cosmic.png',
);
expect(
PluriStationArtFallback.artFor('b'),
'assets/images/station_art_pulse.png',
);
expect(
PluriStationArtFallback.artFor('c'),
'assets/images/station_art_nova.png',
);
});
test('artFor is deterministic for a given seed (same station always picks '
'the same art)', () {
const seed = 'uuid-1234-abcd-real-looking';
expect(
PluriStationArtFallback.artFor(seed),
PluriStationArtFallback.artFor(seed),
);
});
testWidgets(
'renders the deterministic station-art asset plus a centred player glyph',
(tester) async {
await tester.pumpWidget(
host(const PluriStationArtFallback(seed: 'd', iconSize: 22)),
);
await tester.pump();
// Scoped by asset-name prefix, not `find.byType(Image).first` — the
// player glyph (`PluriIcon`) ALSO renders via its own `Image.asset`
// internally, so a bare type match would be ambiguous.
final arte = tester
.widgetList<Image>(find.byType(Image))
.firstWhere(
(img) =>
img.image is AssetImage &&
(img.image as AssetImage).assetName.startsWith(
'assets/images/station_art_',
),
);
expect(
(arte.image as AssetImage).assetName,
'assets/images/station_art_aurora.png',
);
final icono = tester.widget<PluriIcon>(find.byType(PluriIcon));
expect(icono.glyph, PluriIconGlyph.player);
expect(icono.variant, PluriIconVariant.activeGlow);
expect(icono.size, 22);
},
);
}
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:pluriwave/estado/estado_radio.dart'; import 'package:pluriwave/estado/estado_radio.dart';
import 'package:pluriwave/l10n/gen/app_localizations.dart'; import 'package:pluriwave/l10n/gen/app_localizations.dart';
import 'package:pluriwave/widgets/pluri_station_art_fallback.dart';
import 'package:pluriwave/widgets/tarjeta_emisora.dart'; import 'package:pluriwave/widgets/tarjeta_emisora.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
@@ -73,6 +74,24 @@ void main() {
expect(tester.getSize(thumbnailClip), const Size(48, 48)); expect(tester.getSize(thumbnailClip), const Size(48, 48));
}); });
testWidgets(
'issue 6 (feedback-pruebas): a station with no favicon renders via the '
'shared PluriStationArtFallback (extracted from this exact fallback)',
(tester) async {
await tester.pumpWidget(
host(
TarjetaEmisora(
emisora: emisoraDemo(uuid: 'sin-arte', nombre: 'Sin Arte'),
esCompacta: true,
),
),
);
await tester.pump();
expect(find.byType(PluriStationArtFallback), findsOneWidget);
},
);
testWidgets('esCompacta shimmer placeholder is a square block, not a ' testWidgets('esCompacta shimmer placeholder is a square block, not a '
'circle', (tester) async { 'circle', (tester) async {
await tester.pumpWidget( await tester.pumpWidget(