From c7e1a212cac7ef609b44e3d9b7e1258d82fb4239 Mon Sep 17 00:00:00 2001 From: freetlab Date: Thu, 30 Jul 2026 19:05:28 +0200 Subject: [PATCH] fix(vacaciones): edit and delete vacation ranges Vacaciones ranges could be created but never edited or removed -- EstadoAlarmas already had crearRangoVacaciones/eliminarRangoVacaciones with no UI affordance reaching them, and no update path at all. Add EstadoAlarmas.editarRangoVacaciones and wire tap-to-edit / swipe-to-delete (with confirmation) onto every range card, mirroring the alarm list's own Dismissible + confirm-dialog pattern exactly. This covers the active-range hero too: a freshly created range is active immediately and only ever renders there, never in the scheduled/past lists, so it needed the same affordances or a user's very first range could never be fixed. --- lib/estado/estado_alarmas.dart | 13 + lib/l10n/app_ar.arb | 3 + lib/l10n/app_bn.arb | 3 + lib/l10n/app_de.arb | 3 + lib/l10n/app_en.arb | 3 + lib/l10n/app_es.arb | 3 + lib/l10n/app_fr.arb | 3 + lib/l10n/app_hi.arb | 3 + lib/l10n/app_id.arb | 3 + lib/l10n/app_it.arb | 3 + lib/l10n/app_ja.arb | 3 + lib/l10n/app_pt.arb | 3 + lib/l10n/app_ru.arb | 3 + lib/l10n/app_zh.arb | 3 + lib/l10n/gen/app_localizations.dart | 18 + lib/l10n/gen/app_localizations_ar.dart | 9 + lib/l10n/gen/app_localizations_bn.dart | 10 + lib/l10n/gen/app_localizations_de.dart | 10 + lib/l10n/gen/app_localizations_en.dart | 9 + lib/l10n/gen/app_localizations_es.dart | 10 + lib/l10n/gen/app_localizations_fr.dart | 9 + lib/l10n/gen/app_localizations_hi.dart | 9 + lib/l10n/gen/app_localizations_id.dart | 10 + lib/l10n/gen/app_localizations_it.dart | 10 + lib/l10n/gen/app_localizations_ja.dart | 9 + lib/l10n/gen/app_localizations_pt.dart | 9 + lib/l10n/gen/app_localizations_ru.dart | 9 + lib/l10n/gen/app_localizations_zh.dart | 9 + lib/pantallas/pantalla_vacaciones.dart | 389 +++++++++++++------ test/pantallas/pantalla_vacaciones_test.dart | 169 ++++++++ 30 files changed, 639 insertions(+), 111 deletions(-) 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/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/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/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); + }, + ); + }); }