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