diff --git a/lib/estado/estado_alarmas.dart b/lib/estado/estado_alarmas.dart index 47269d4..8cdb7c3 100644 --- a/lib/estado/estado_alarmas.dart +++ b/lib/estado/estado_alarmas.dart @@ -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 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 diff --git a/lib/estado/estado_radio.dart b/lib/estado/estado_radio.dart index 5d8a57f..5bf7e08 100644 --- a/lib/estado/estado_radio.dart +++ b/lib/estado/estado_radio.dart @@ -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 = [ 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 _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; + _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 _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; diff --git a/lib/l10n/app_ar.arb b/lib/l10n/app_ar.arb index 9c9be9c..92199c0 100644 --- a/lib/l10n/app_ar.arb +++ b/lib/l10n/app_ar.arb @@ -468,6 +468,9 @@ "deleteRangeTooltip": "حذف النطاق", "vacationsDefaultName": "إجازات", "newVacationRangeTitle": "نطاق إجازة جديد", + "editVacationRangeTitle": "تعديل نطاق الإجازة", + "vacationDeleteConfirmTitle": "هل تريد حذف نطاق الإجازة؟", + "vacationDeleteConfirmMessage": "لا يمكن التراجع عن هذا الإجراء.", "startField": "البداية", "endField": "النهاية", "saveRangeAction": "حفظ النطاق", diff --git a/lib/l10n/app_bn.arb b/lib/l10n/app_bn.arb index b0dfc1d..ec8b0f0 100644 --- a/lib/l10n/app_bn.arb +++ b/lib/l10n/app_bn.arb @@ -468,6 +468,9 @@ "deleteRangeTooltip": "পরিসর মুছুন", "vacationsDefaultName": "ছুটি", "newVacationRangeTitle": "নতুন ছুটির পরিসর", + "editVacationRangeTitle": "ছুটির পরিসর সম্পাদনা করুন", + "vacationDeleteConfirmTitle": "ছুটির পরিসর মুছবেন?", + "vacationDeleteConfirmMessage": "এই পদক্ষেপ ফিরিয়ে নেওয়া যাবে না।", "startField": "শুরু", "endField": "শেষ", "saveRangeAction": "পরিসর সংরক্ষণ করুন", diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index e7d9daa..12ddab6 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -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", diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index f9f3cd1..f57b44d 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -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", diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb index ba95b6e..835e4ac 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -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", diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index f8390dd..bb4d7a9 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -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", diff --git a/lib/l10n/app_hi.arb b/lib/l10n/app_hi.arb index d42f3f3..b9e4a9c 100644 --- a/lib/l10n/app_hi.arb +++ b/lib/l10n/app_hi.arb @@ -468,6 +468,9 @@ "deleteRangeTooltip": "अवधि हटाएँ", "vacationsDefaultName": "छुट्टियाँ", "newVacationRangeTitle": "नई छुट्टी अवधि", + "editVacationRangeTitle": "छुट्टी अवधि संपादित करें", + "vacationDeleteConfirmTitle": "छुट्टी अवधि हटाएं?", + "vacationDeleteConfirmMessage": "इसे वापस नहीं लिया जा सकता।", "startField": "शुरुआत", "endField": "समाप्ति", "saveRangeAction": "अवधि सहेजें", diff --git a/lib/l10n/app_id.arb b/lib/l10n/app_id.arb index ea625ca..b16c871 100644 --- a/lib/l10n/app_id.arb +++ b/lib/l10n/app_id.arb @@ -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", diff --git a/lib/l10n/app_it.arb b/lib/l10n/app_it.arb index a4a8174..8e26513 100644 --- a/lib/l10n/app_it.arb +++ b/lib/l10n/app_it.arb @@ -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", diff --git a/lib/l10n/app_ja.arb b/lib/l10n/app_ja.arb index 3a4256f..432b52d 100644 --- a/lib/l10n/app_ja.arb +++ b/lib/l10n/app_ja.arb @@ -468,6 +468,9 @@ "deleteRangeTooltip": "期間を削除", "vacationsDefaultName": "休暇", "newVacationRangeTitle": "新しい休暇期間", + "editVacationRangeTitle": "休暇期間を編集", + "vacationDeleteConfirmTitle": "休暇期間を削除しますか?", + "vacationDeleteConfirmMessage": "この操作は元に戻せません。", "startField": "開始", "endField": "終了", "saveRangeAction": "期間を保存", diff --git a/lib/l10n/app_pt.arb b/lib/l10n/app_pt.arb index 8ded454..545a927 100644 --- a/lib/l10n/app_pt.arb +++ b/lib/l10n/app_pt.arb @@ -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", diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index d14cc19..98166f2 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -468,6 +468,9 @@ "deleteRangeTooltip": "Удалить период", "vacationsDefaultName": "Отпуск", "newVacationRangeTitle": "Новый период отпуска", + "editVacationRangeTitle": "Изменить период отпуска", + "vacationDeleteConfirmTitle": "Удалить период отпуска?", + "vacationDeleteConfirmMessage": "Это действие нельзя отменить.", "startField": "Начало", "endField": "Конец", "saveRangeAction": "Сохранить период", diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 37fc3ce..dc2925c 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -468,6 +468,9 @@ "deleteRangeTooltip": "删除范围", "vacationsDefaultName": "假期", "newVacationRangeTitle": "新的假期范围", + "editVacationRangeTitle": "编辑假期范围", + "vacationDeleteConfirmTitle": "删除假期范围?", + "vacationDeleteConfirmMessage": "此操作无法撤销。", "startField": "开始", "endField": "结束", "saveRangeAction": "保存范围", diff --git a/lib/l10n/gen/app_localizations.dart b/lib/l10n/gen/app_localizations.dart index 4620764..1f62743 100644 --- a/lib/l10n/gen/app_localizations.dart +++ b/lib/l10n/gen/app_localizations.dart @@ -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: diff --git a/lib/l10n/gen/app_localizations_ar.dart b/lib/l10n/gen/app_localizations_ar.dart index 68d5a15..722038b 100644 --- a/lib/l10n/gen/app_localizations_ar.dart +++ b/lib/l10n/gen/app_localizations_ar.dart @@ -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 => 'البداية'; diff --git a/lib/l10n/gen/app_localizations_bn.dart b/lib/l10n/gen/app_localizations_bn.dart index 0790578..5627416 100644 --- a/lib/l10n/gen/app_localizations_bn.dart +++ b/lib/l10n/gen/app_localizations_bn.dart @@ -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 => 'শুরু'; diff --git a/lib/l10n/gen/app_localizations_de.dart b/lib/l10n/gen/app_localizations_de.dart index dd17ae6..4c63b22 100644 --- a/lib/l10n/gen/app_localizations_de.dart +++ b/lib/l10n/gen/app_localizations_de.dart @@ -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'; diff --git a/lib/l10n/gen/app_localizations_en.dart b/lib/l10n/gen/app_localizations_en.dart index df75acf..a50957d 100644 --- a/lib/l10n/gen/app_localizations_en.dart +++ b/lib/l10n/gen/app_localizations_en.dart @@ -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'; diff --git a/lib/l10n/gen/app_localizations_es.dart b/lib/l10n/gen/app_localizations_es.dart index 4b436bf..55857ea 100644 --- a/lib/l10n/gen/app_localizations_es.dart +++ b/lib/l10n/gen/app_localizations_es.dart @@ -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'; diff --git a/lib/l10n/gen/app_localizations_fr.dart b/lib/l10n/gen/app_localizations_fr.dart index f546287..1232e33 100644 --- a/lib/l10n/gen/app_localizations_fr.dart +++ b/lib/l10n/gen/app_localizations_fr.dart @@ -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'; diff --git a/lib/l10n/gen/app_localizations_hi.dart b/lib/l10n/gen/app_localizations_hi.dart index eae2adb..3655468 100644 --- a/lib/l10n/gen/app_localizations_hi.dart +++ b/lib/l10n/gen/app_localizations_hi.dart @@ -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 => 'शुरुआत'; diff --git a/lib/l10n/gen/app_localizations_id.dart b/lib/l10n/gen/app_localizations_id.dart index 430f546..a374b37 100644 --- a/lib/l10n/gen/app_localizations_id.dart +++ b/lib/l10n/gen/app_localizations_id.dart @@ -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'; diff --git a/lib/l10n/gen/app_localizations_it.dart b/lib/l10n/gen/app_localizations_it.dart index 16ba528..d1c3a1d 100644 --- a/lib/l10n/gen/app_localizations_it.dart +++ b/lib/l10n/gen/app_localizations_it.dart @@ -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'; diff --git a/lib/l10n/gen/app_localizations_ja.dart b/lib/l10n/gen/app_localizations_ja.dart index 7fb19ca..1a01bd9 100644 --- a/lib/l10n/gen/app_localizations_ja.dart +++ b/lib/l10n/gen/app_localizations_ja.dart @@ -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 => '開始'; diff --git a/lib/l10n/gen/app_localizations_pt.dart b/lib/l10n/gen/app_localizations_pt.dart index b49f45c..d6c92e1 100644 --- a/lib/l10n/gen/app_localizations_pt.dart +++ b/lib/l10n/gen/app_localizations_pt.dart @@ -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'; diff --git a/lib/l10n/gen/app_localizations_ru.dart b/lib/l10n/gen/app_localizations_ru.dart index 919297f..01054c2 100644 --- a/lib/l10n/gen/app_localizations_ru.dart +++ b/lib/l10n/gen/app_localizations_ru.dart @@ -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 => 'Начало'; diff --git a/lib/l10n/gen/app_localizations_zh.dart b/lib/l10n/gen/app_localizations_zh.dart index 55a8635..fc7826b 100644 --- a/lib/l10n/gen/app_localizations_zh.dart +++ b/lib/l10n/gen/app_localizations_zh.dart @@ -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 => '开始'; diff --git a/lib/pantallas/ajustes/widgets/fila_ajuste.dart b/lib/pantallas/ajustes/widgets/fila_ajuste.dart index 75f52bf..964a629 100644 --- a/lib/pantallas/ajustes/widgets/fila_ajuste.dart +++ b/lib/pantallas/ajustes/widgets/fila_ajuste.dart @@ -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), diff --git a/lib/pantallas/pantalla_buscar.dart b/lib/pantallas/pantalla_buscar.dart index 940ab34..fec8a65 100644 --- a/lib/pantallas/pantalla_buscar.dart +++ b/lib/pantallas/pantalla_buscar.dart @@ -543,8 +543,17 @@ class _PantallaBuscarState extends State { 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 { // 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 { 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) { diff --git a/lib/pantallas/pantalla_favoritos.dart b/lib/pantallas/pantalla_favoritos.dart index 471f27b..3753a0b 100644 --- a/lib/pantallas/pantalla_favoritos.dart +++ b/lib/pantallas/pantalla_favoritos.dart @@ -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); diff --git a/lib/pantallas/pantalla_inicio.dart b/lib/pantallas/pantalla_inicio.dart index 4a0ecf6..b52608d 100644 --- a/lib/pantallas/pantalla_inicio.dart +++ b/lib/pantallas/pantalla_inicio.dart @@ -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); } diff --git a/lib/pantallas/pantalla_reproductor.dart b/lib/pantallas/pantalla_reproductor.dart index 78d210a..451f9a4 100644 --- a/lib/pantallas/pantalla_reproductor.dart +++ b/lib/pantallas/pantalla_reproductor.dart @@ -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 diff --git a/lib/pantallas/pantalla_vacaciones.dart b/lib/pantallas/pantalla_vacaciones.dart index 3acb755..3fb2d97 100644 --- a/lib/pantallas/pantalla_vacaciones.dart +++ b/lib/pantallas/pantalla_vacaciones.dart @@ -82,13 +82,78 @@ class PantallaVacaciones extends StatelessWidget { ); } - Future _abrirAlta(BuildContext context) async { - await showModalBottomSheet( - context: context, - isScrollControlled: true, - useSafeArea: true, - backgroundColor: Colors.transparent, - builder: (_) => const _EditorVacacionesSheet(), + Future _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 _abrirEditorVacaciones( + BuildContext context, { + RangoVacaciones? rango, +}) async { + await showModalBottomSheet( + 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 _confirmarEliminarRango( + BuildContext context, + AppLocalizations l10n, +) async { + final confirmado = await showDialog( + 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(); + // 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 _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 _guardar() async { final estado = context.read(); - 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); } } diff --git a/lib/widgets/fila_emisora_plana.dart b/lib/widgets/fila_emisora_plana.dart index 3ef4615..2d5ee6f 100644 --- a/lib/widgets/fila_emisora_plana.dart +++ b/lib/widgets/fila_emisora_plana.dart @@ -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); } diff --git a/lib/widgets/mini_reproductor.dart b/lib/widgets/mini_reproductor.dart index a7e126c..9a0fc2f 100644 --- a/lib/widgets/mini_reproductor.dart +++ b/lib/widgets/mini_reproductor.dart @@ -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); } diff --git a/lib/widgets/pluri_bottom_navigation.dart b/lib/widgets/pluri_bottom_navigation.dart index 40d1fd1..9aec675 100644 --- a/lib/widgets/pluri_bottom_navigation.dart +++ b/lib/widgets/pluri_bottom_navigation.dart @@ -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 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( + duration: motion.normal, + curve: Curves.easeOutCubic, + tween: Tween(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, + ), + ), + ], ], - ], + ), ), ), ), diff --git a/lib/widgets/pluri_sleep_timer_sheet.dart b/lib/widgets/pluri_sleep_timer_sheet.dart index 577a466..44c6fdb 100644 --- a/lib/widgets/pluri_sleep_timer_sheet.dart +++ b/lib/widgets/pluri_sleep_timer_sheet.dart @@ -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( @@ -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`, + // 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); }, ), ], diff --git a/lib/widgets/pluri_station_art_fallback.dart b/lib/widgets/pluri_station_art_fallback.dart new file mode 100644 index 0000000..f4cf67f --- /dev/null +++ b/lib/widgets/pluri_station_art_fallback.dart @@ -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(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(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, + ), + ), + ], + ); + } +} diff --git a/lib/widgets/tarjeta_emisora.dart b/lib/widgets/tarjeta_emisora.dart index 40af058..40a6e41 100644 --- a/lib/widgets/tarjeta_emisora.dart +++ b/lib/widgets/tarjeta_emisora.dart @@ -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 { ); } + // 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(0, (a, b) => a + b) % arts.length; - return arts[index]; + return PluriStationArtFallback(seed: widget.emisora.uuid, iconSize: size); } } diff --git a/test/estado/estado_radio_test.dart b/test/estado/estado_radio_test.dart index 96ffa72..f8d2f76 100644 --- a/test/estado/estado_radio_test.dart +++ b/test/estado/estado_radio_test.dart @@ -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.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.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.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.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 diff --git a/test/pantallas/ajustes/widgets/fila_ajuste_test.dart b/test/pantallas/ajustes/widgets/fila_ajuste_test.dart index 61a34b8..ae328a0 100644 --- a/test/pantallas/ajustes/widgets/fila_ajuste_test.dart +++ b/test/pantallas/ajustes/widgets/fila_ajuste_test.dart @@ -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(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)', diff --git a/test/pantallas/pantalla_buscar_test.dart b/test/pantallas/pantalla_buscar_test.dart index 5d418be..5358510 100644 --- a/test/pantallas/pantalla_buscar_test.dart +++ b/test/pantallas/pantalla_buscar_test.dart @@ -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( + 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 { diff --git a/test/pantallas/pantalla_favoritos_test.dart b/test/pantallas/pantalla_favoritos_test.dart index 0d14502..623a606 100644 --- a/test/pantallas/pantalla_favoritos_test.dart +++ b/test/pantallas/pantalla_favoritos_test.dart @@ -472,6 +472,24 @@ void main() { expect(find.text('Move to list'), findsOneWidget); expect(find.text('Remove from favorites'), findsOneWidget); expect(find.byType(PopupMenuItem), 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).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', + ); }, ); diff --git a/test/pantallas/pantalla_inicio_test.dart b/test/pantallas/pantalla_inicio_test.dart index 038f1bd..a7a980d 100644 --- a/test/pantallas/pantalla_inicio_test.dart +++ b/test/pantallas/pantalla_inicio_test.dart @@ -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 diff --git a/test/pantallas/pantalla_reproductor_test.dart b/test/pantallas/pantalla_reproductor_test.dart index ab9d3fc..3d00439 100644 --- a/test/pantallas/pantalla_reproductor_test.dart +++ b/test/pantallas/pantalla_reproductor_test.dart @@ -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', + ); + }, + ); + }); } diff --git a/test/pantallas/pantalla_vacaciones_test.dart b/test/pantallas/pantalla_vacaciones_test.dart index 539bcf6..f0e15bd 100644 --- a/test/pantallas/pantalla_vacaciones_test.dart +++ b/test/pantallas/pantalla_vacaciones_test.dart @@ -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( + 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); + }, + ); + }); } diff --git a/test/widgets/fila_emisora_plana_test.dart b/test/widgets/fila_emisora_plana_test.dart index dd9298a..2c18f35 100644 --- a/test/widgets/fila_emisora_plana_test.dart +++ b/test/widgets/fila_emisora_plana_test.dart @@ -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 { diff --git a/test/widgets/mini_reproductor_barra_test.dart b/test/widgets/mini_reproductor_barra_test.dart index bfa8fd0..d3c00bd 100644 --- a/test/widgets/mini_reproductor_barra_test.dart +++ b/test/widgets/mini_reproductor_barra_test.dart @@ -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 { diff --git a/test/widgets/pluri_bottom_navigation_test.dart b/test/widgets/pluri_bottom_navigation_test.dart index 29aa31e..b5a171c 100644 --- a/test/widgets/pluri_bottom_navigation_test.dart +++ b/test/widgets/pluri_bottom_navigation_test.dart @@ -96,13 +96,17 @@ void main() { ); expect(inactiveIcon.color, isNull); - final dimmed = tester.widget( + // 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( 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)); diff --git a/test/widgets/pluri_sleep_timer_sheet_test.dart b/test/widgets/pluri_sleep_timer_sheet_test.dart new file mode 100644 index 0000000..8f0bbcd --- /dev/null +++ b/test/widgets/pluri_sleep_timer_sheet_test.dart @@ -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.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( + 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( + 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( + 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(); + }, + ); +} diff --git a/test/widgets/pluri_station_art_fallback_test.dart b/test/widgets/pluri_station_art_fallback_test.dart new file mode 100644 index 0000000..2dddf39 --- /dev/null +++ b/test/widgets/pluri_station_art_fallback_test.dart @@ -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(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(find.byType(PluriIcon)); + expect(icono.glyph, PluriIconGlyph.player); + expect(icono.variant, PluriIconVariant.activeGlow); + expect(icono.size, 22); + }, + ); +} diff --git a/test/widgets/tarjeta_emisora_thumbnail_test.dart b/test/widgets/tarjeta_emisora_thumbnail_test.dart index 34eda32..52f5dab 100644 --- a/test/widgets/tarjeta_emisora_thumbnail_test.dart +++ b/test/widgets/tarjeta_emisora_thumbnail_test.dart @@ -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(