From 9dfcf0b428dd253513d59c5f0807c9abd397266f Mon Sep 17 00:00:00 2001 From: freetlab Date: Wed, 29 Jul 2026 10:49:51 +0200 Subject: [PATCH] feat(vacaciones): add vacation range manager screen Add the Vacaciones manager screen per design ADR-6: an active-range hero (name, days-remaining countdown, determinate progress bar, and a per-alarm pause-impact line), a "PROGRAMADOS" upcoming-ranges list, an "Add range" CTA, and a "Rangos pasados" history section. This is the real destination WU8's Alarmas-root summary row pushes to, replacing WU8's own temporary placeholder (_PantallaVacacionesTemporal, now deleted). EstadoAlarmas gains 4 pure query methods (rangoVacacionesActivo, vacacionesProximas, vacacionesPasadas, impactoDeRango) -- read-only over _alarmas/_vacaciones, no writes, no rescheduling, no native bridge calls. impactoDeRango mirrors ServicioProgramacionAlarmas's own pause predicate exactly, so the screen never disagrees with the scheduler about which alarms are paused. ImpactoVacaciones joins RangoVacaciones in alarma_musical.dart. The add-range form (_EditorVacacionesSheet, _PickerButton) moved verbatim from pantalla_alarmas.dart to its one remaining consumer. estado_alarmas.dart's scheduling/snooze paths and the ringing screen's dismiss guard are untouched; both test files pass unmodified. New ARB keys (en/es only; other 11 locales are WU18's job): vacationImpact{Paused,Continues}Label, vacationUpcomingSectionTitle, vacationPastSectionTitle, addVacationRangeCta, vacationNoActiveRangeHint. --- lib/estado/estado_alarmas.dart | 63 +++ lib/l10n/app_en.arb | 16 + lib/l10n/app_es.arb | 16 + lib/l10n/gen/app_localizations.dart | 36 ++ lib/l10n/gen/app_localizations_ar.dart | 23 + lib/l10n/gen/app_localizations_bn.dart | 23 + lib/l10n/gen/app_localizations_de.dart | 23 + lib/l10n/gen/app_localizations_en.dart | 22 + lib/l10n/gen/app_localizations_es.dart | 23 + lib/l10n/gen/app_localizations_fr.dart | 23 + lib/l10n/gen/app_localizations_hi.dart | 23 + lib/l10n/gen/app_localizations_id.dart | 23 + lib/l10n/gen/app_localizations_it.dart | 23 + lib/l10n/gen/app_localizations_ja.dart | 23 + lib/l10n/gen/app_localizations_pt.dart | 23 + lib/l10n/gen/app_localizations_ru.dart | 23 + lib/l10n/gen/app_localizations_zh.dart | 23 + lib/modelos/alarma_musical.dart | 14 + lib/pantallas/pantalla_alarmas.dart | 197 +------ lib/pantallas/pantalla_vacaciones.dart | 353 ++++++++++++ openspec/changes/rediseno-funcional/tasks.md | 47 +- test/estado/estado_alarmas_test.dart | 558 +++++++++++++------ test/pantallas/pantalla_vacaciones_test.dart | 224 ++++++++ 23 files changed, 1432 insertions(+), 390 deletions(-) create mode 100644 lib/pantallas/pantalla_vacaciones.dart create mode 100644 test/pantallas/pantalla_vacaciones_test.dart diff --git a/lib/estado/estado_alarmas.dart b/lib/estado/estado_alarmas.dart index 10b6efe..47269d4 100644 --- a/lib/estado/estado_alarmas.dart +++ b/lib/estado/estado_alarmas.dart @@ -354,6 +354,69 @@ class EstadoAlarmas extends ChangeNotifier { 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 + // takes `{DateTime? ahora}` (clock injection) defaulting to + // `DateTime.now()` so tests can pass a fixed instant. + + /// Currently-active vacation range, if today falls within one. + /// Delegates to the existing `RangoVacaciones.contiene(fecha)` — which + /// already handles the `activo` flag and day granularity — rather than + /// reimplementing date math (a second implementation is a second set of + /// off-by-one bugs). Callers derive "days remaining" themselves from the + /// returned range's `finDia`, the same way WU8's summary row already + /// does. + RangoVacaciones? rangoVacacionesActivo({DateTime? ahora}) { + final fecha = ahora ?? DateTime.now(); + for (final rango in _vacaciones) { + if (rango.contiene(fecha)) return rango; + } + return null; + } + + /// Ranges that have not yet started (`inicio > hoy`), soonest-first. + List vacacionesProximas({DateTime? ahora}) { + final fecha = ahora ?? DateTime.now(); + final hoy = DateTime(fecha.year, fecha.month, fecha.day); + return _vacaciones.where((rango) => rango.inicioDia.isAfter(hoy)).toList() + ..sort((a, b) => a.inicioDia.compareTo(b.inicioDia)); + } + + /// Ranges whose end date has already passed (`fin < hoy`), most-recently- + /// ended first. + List vacacionesPasadas({DateTime? ahora}) { + final fecha = ahora ?? DateTime.now(); + final hoy = DateTime(fecha.year, fecha.month, fecha.day); + return _vacaciones.where((rango) => rango.finDia.isBefore(hoy)).toList() + ..sort((a, b) => b.finDia.compareTo(a.finDia)); + } + + /// Per-alarm pause impact for [rango]. Mirrors + /// `ServicioProgramacionAlarmas`'s own pause predicate EXACTLY + /// (`servicio_programacion_alarmas.dart`: + /// `!alarma.sonarEnVacaciones && estaEnVacaciones(candidato, vacaciones)`) + /// — if these two ever diverge, the Vacaciones screen lies about which + /// alarms are paused. [rango] is accepted for API symmetry with the + /// other 3 queries above; the predicate itself needs no dates because it + /// only makes sense to call this for a range that IS currently active — + /// any alarm actually paused by it already has `sonarEnVacaciones == + /// false`, which is exactly what the scheduler itself would have used to + /// skip that alarm's candidate occurrence. + ImpactoVacaciones impactoDeRango(RangoVacaciones rango) { + final pausadas = []; + final noAfectadas = []; + for (final alarma in _alarmas) { + if (!alarma.activa) continue; + if (alarma.sonarEnVacaciones) { + noAfectadas.add(alarma); + } else { + pausadas.add(alarma); + } + } + return ImpactoVacaciones(pausadas: pausadas, noAfectadas: noAfectadas); + } + ExcepcionAlarma? ultimaExcepcionPara(String alarmaId) { final candidatas = _excepciones.where((e) => e.alarmaId == alarmaId).toList() diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 91d5c62..0f5bd8b 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -535,6 +535,22 @@ } } }, + "vacationImpactPausedLabel": "Paused: {times}", + "@vacationImpactPausedLabel": { + "placeholders": { + "times": {} + } + }, + "vacationImpactContinuesLabel": "Still ringing: {times}", + "@vacationImpactContinuesLabel": { + "placeholders": { + "times": {} + } + }, + "vacationUpcomingSectionTitle": "SCHEDULED", + "vacationPastSectionTitle": "Past ranges", + "addVacationRangeCta": "Add range", + "vacationNoActiveRangeHint": "No active vacation range right now.", "vacationsDefaultName": "Vacation", "newVacationRangeTitle": "New vacation range", "startField": "Start", diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb index 2dd45e2..6421abb 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -535,6 +535,22 @@ } } }, + "vacationImpactPausedLabel": "Pausa: {times}", + "@vacationImpactPausedLabel": { + "placeholders": { + "times": {} + } + }, + "vacationImpactContinuesLabel": "Sigue sonando: {times}", + "@vacationImpactContinuesLabel": { + "placeholders": { + "times": {} + } + }, + "vacationUpcomingSectionTitle": "PROGRAMADOS", + "vacationPastSectionTitle": "Rangos pasados", + "addVacationRangeCta": "Añadir rango", + "vacationNoActiveRangeHint": "No hay un rango de vacaciones activo ahora mismo.", "vacationsDefaultName": "Vacaciones", "newVacationRangeTitle": "Nuevo rango de vacaciones", "startField": "Inicio", diff --git a/lib/l10n/gen/app_localizations.dart b/lib/l10n/gen/app_localizations.dart index 9a0dfb1..477d2d5 100644 --- a/lib/l10n/gen/app_localizations.dart +++ b/lib/l10n/gen/app_localizations.dart @@ -1874,6 +1874,42 @@ abstract class AppLocalizations { /// **'Próximo rango en {days} días'** String vacationSummaryUpcomingCountdown(int days); + /// No description provided for @vacationImpactPausedLabel. + /// + /// In es, this message translates to: + /// **'Pausa: {times}'** + String vacationImpactPausedLabel(Object times); + + /// No description provided for @vacationImpactContinuesLabel. + /// + /// In es, this message translates to: + /// **'Sigue sonando: {times}'** + String vacationImpactContinuesLabel(Object times); + + /// No description provided for @vacationUpcomingSectionTitle. + /// + /// In es, this message translates to: + /// **'PROGRAMADOS'** + String get vacationUpcomingSectionTitle; + + /// No description provided for @vacationPastSectionTitle. + /// + /// In es, this message translates to: + /// **'Rangos pasados'** + String get vacationPastSectionTitle; + + /// No description provided for @addVacationRangeCta. + /// + /// In es, this message translates to: + /// **'Añadir rango'** + String get addVacationRangeCta; + + /// No description provided for @vacationNoActiveRangeHint. + /// + /// In es, this message translates to: + /// **'No hay un rango de vacaciones activo ahora mismo.'** + String get vacationNoActiveRangeHint; + /// No description provided for @vacationsDefaultName. /// /// 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 0b934da..786ab08 100644 --- a/lib/l10n/gen/app_localizations_ar.dart +++ b/lib/l10n/gen/app_localizations_ar.dart @@ -1018,6 +1018,29 @@ class AppLocalizationsAr extends AppLocalizations { return 'Próximo rango en $days días'; } + @override + String vacationImpactPausedLabel(Object times) { + return 'Pausa: $times'; + } + + @override + String vacationImpactContinuesLabel(Object times) { + return 'Sigue sonando: $times'; + } + + @override + String get vacationUpcomingSectionTitle => 'PROGRAMADOS'; + + @override + String get vacationPastSectionTitle => 'Rangos pasados'; + + @override + String get addVacationRangeCta => 'Añadir rango'; + + @override + String get vacationNoActiveRangeHint => + 'No hay un rango de vacaciones activo ahora mismo.'; + @override String get vacationsDefaultName => 'إجازات'; diff --git a/lib/l10n/gen/app_localizations_bn.dart b/lib/l10n/gen/app_localizations_bn.dart index e5226b6..73df8f2 100644 --- a/lib/l10n/gen/app_localizations_bn.dart +++ b/lib/l10n/gen/app_localizations_bn.dart @@ -1025,6 +1025,29 @@ class AppLocalizationsBn extends AppLocalizations { return 'Próximo rango en $days días'; } + @override + String vacationImpactPausedLabel(Object times) { + return 'Pausa: $times'; + } + + @override + String vacationImpactContinuesLabel(Object times) { + return 'Sigue sonando: $times'; + } + + @override + String get vacationUpcomingSectionTitle => 'PROGRAMADOS'; + + @override + String get vacationPastSectionTitle => 'Rangos pasados'; + + @override + String get addVacationRangeCta => 'Añadir rango'; + + @override + String get vacationNoActiveRangeHint => + 'No hay un rango de vacaciones activo ahora mismo.'; + @override String get vacationsDefaultName => 'ছুটি'; diff --git a/lib/l10n/gen/app_localizations_de.dart b/lib/l10n/gen/app_localizations_de.dart index 7c9ef2a..fcbb7f6 100644 --- a/lib/l10n/gen/app_localizations_de.dart +++ b/lib/l10n/gen/app_localizations_de.dart @@ -1026,6 +1026,29 @@ class AppLocalizationsDe extends AppLocalizations { return 'Próximo rango en $days días'; } + @override + String vacationImpactPausedLabel(Object times) { + return 'Pausa: $times'; + } + + @override + String vacationImpactContinuesLabel(Object times) { + return 'Sigue sonando: $times'; + } + + @override + String get vacationUpcomingSectionTitle => 'PROGRAMADOS'; + + @override + String get vacationPastSectionTitle => 'Rangos pasados'; + + @override + String get addVacationRangeCta => 'Añadir rango'; + + @override + String get vacationNoActiveRangeHint => + 'No hay un rango de vacaciones activo ahora mismo.'; + @override String get vacationsDefaultName => 'Ferien'; diff --git a/lib/l10n/gen/app_localizations_en.dart b/lib/l10n/gen/app_localizations_en.dart index f15f4d7..39de3bb 100644 --- a/lib/l10n/gen/app_localizations_en.dart +++ b/lib/l10n/gen/app_localizations_en.dart @@ -1020,6 +1020,28 @@ class AppLocalizationsEn extends AppLocalizations { return 'Next range in $days days'; } + @override + String vacationImpactPausedLabel(Object times) { + return 'Paused: $times'; + } + + @override + String vacationImpactContinuesLabel(Object times) { + return 'Still ringing: $times'; + } + + @override + String get vacationUpcomingSectionTitle => 'SCHEDULED'; + + @override + String get vacationPastSectionTitle => 'Past ranges'; + + @override + String get addVacationRangeCta => 'Add range'; + + @override + String get vacationNoActiveRangeHint => 'No active vacation range right now.'; + @override String get vacationsDefaultName => 'Vacation'; diff --git a/lib/l10n/gen/app_localizations_es.dart b/lib/l10n/gen/app_localizations_es.dart index be5bcb5..0a25f82 100644 --- a/lib/l10n/gen/app_localizations_es.dart +++ b/lib/l10n/gen/app_localizations_es.dart @@ -1024,6 +1024,29 @@ class AppLocalizationsEs extends AppLocalizations { return 'Próximo rango en $days días'; } + @override + String vacationImpactPausedLabel(Object times) { + return 'Pausa: $times'; + } + + @override + String vacationImpactContinuesLabel(Object times) { + return 'Sigue sonando: $times'; + } + + @override + String get vacationUpcomingSectionTitle => 'PROGRAMADOS'; + + @override + String get vacationPastSectionTitle => 'Rangos pasados'; + + @override + String get addVacationRangeCta => 'Añadir rango'; + + @override + String get vacationNoActiveRangeHint => + 'No hay un rango de vacaciones activo ahora mismo.'; + @override String get vacationsDefaultName => 'Vacaciones'; diff --git a/lib/l10n/gen/app_localizations_fr.dart b/lib/l10n/gen/app_localizations_fr.dart index be5eab0..df503ac 100644 --- a/lib/l10n/gen/app_localizations_fr.dart +++ b/lib/l10n/gen/app_localizations_fr.dart @@ -1030,6 +1030,29 @@ class AppLocalizationsFr extends AppLocalizations { return 'Próximo rango en $days días'; } + @override + String vacationImpactPausedLabel(Object times) { + return 'Pausa: $times'; + } + + @override + String vacationImpactContinuesLabel(Object times) { + return 'Sigue sonando: $times'; + } + + @override + String get vacationUpcomingSectionTitle => 'PROGRAMADOS'; + + @override + String get vacationPastSectionTitle => 'Rangos pasados'; + + @override + String get addVacationRangeCta => 'Añadir rango'; + + @override + String get vacationNoActiveRangeHint => + 'No hay un rango de vacaciones activo ahora mismo.'; + @override String get vacationsDefaultName => 'Vacances'; diff --git a/lib/l10n/gen/app_localizations_hi.dart b/lib/l10n/gen/app_localizations_hi.dart index 1f4cb42..f6469b4 100644 --- a/lib/l10n/gen/app_localizations_hi.dart +++ b/lib/l10n/gen/app_localizations_hi.dart @@ -1021,6 +1021,29 @@ class AppLocalizationsHi extends AppLocalizations { return 'Próximo rango en $days días'; } + @override + String vacationImpactPausedLabel(Object times) { + return 'Pausa: $times'; + } + + @override + String vacationImpactContinuesLabel(Object times) { + return 'Sigue sonando: $times'; + } + + @override + String get vacationUpcomingSectionTitle => 'PROGRAMADOS'; + + @override + String get vacationPastSectionTitle => 'Rangos pasados'; + + @override + String get addVacationRangeCta => 'Añadir rango'; + + @override + String get vacationNoActiveRangeHint => + 'No hay un rango de vacaciones activo ahora mismo.'; + @override String get vacationsDefaultName => 'छुट्टियाँ'; diff --git a/lib/l10n/gen/app_localizations_id.dart b/lib/l10n/gen/app_localizations_id.dart index 1bd3207..0592a0b 100644 --- a/lib/l10n/gen/app_localizations_id.dart +++ b/lib/l10n/gen/app_localizations_id.dart @@ -1025,6 +1025,29 @@ class AppLocalizationsId extends AppLocalizations { return 'Próximo rango en $days días'; } + @override + String vacationImpactPausedLabel(Object times) { + return 'Pausa: $times'; + } + + @override + String vacationImpactContinuesLabel(Object times) { + return 'Sigue sonando: $times'; + } + + @override + String get vacationUpcomingSectionTitle => 'PROGRAMADOS'; + + @override + String get vacationPastSectionTitle => 'Rangos pasados'; + + @override + String get addVacationRangeCta => 'Añadir rango'; + + @override + String get vacationNoActiveRangeHint => + 'No hay un rango de vacaciones activo ahora mismo.'; + @override String get vacationsDefaultName => 'Liburan'; diff --git a/lib/l10n/gen/app_localizations_it.dart b/lib/l10n/gen/app_localizations_it.dart index e327682..ca05fa1 100644 --- a/lib/l10n/gen/app_localizations_it.dart +++ b/lib/l10n/gen/app_localizations_it.dart @@ -1026,6 +1026,29 @@ class AppLocalizationsIt extends AppLocalizations { return 'Próximo rango en $days días'; } + @override + String vacationImpactPausedLabel(Object times) { + return 'Pausa: $times'; + } + + @override + String vacationImpactContinuesLabel(Object times) { + return 'Sigue sonando: $times'; + } + + @override + String get vacationUpcomingSectionTitle => 'PROGRAMADOS'; + + @override + String get vacationPastSectionTitle => 'Rangos pasados'; + + @override + String get addVacationRangeCta => 'Añadir rango'; + + @override + String get vacationNoActiveRangeHint => + 'No hay un rango de vacaciones activo ahora mismo.'; + @override String get vacationsDefaultName => 'Vacanze'; diff --git a/lib/l10n/gen/app_localizations_ja.dart b/lib/l10n/gen/app_localizations_ja.dart index 3dfc365..fe34c22 100644 --- a/lib/l10n/gen/app_localizations_ja.dart +++ b/lib/l10n/gen/app_localizations_ja.dart @@ -994,6 +994,29 @@ class AppLocalizationsJa extends AppLocalizations { return 'Próximo rango en $days días'; } + @override + String vacationImpactPausedLabel(Object times) { + return 'Pausa: $times'; + } + + @override + String vacationImpactContinuesLabel(Object times) { + return 'Sigue sonando: $times'; + } + + @override + String get vacationUpcomingSectionTitle => 'PROGRAMADOS'; + + @override + String get vacationPastSectionTitle => 'Rangos pasados'; + + @override + String get addVacationRangeCta => 'Añadir rango'; + + @override + String get vacationNoActiveRangeHint => + 'No hay un rango de vacaciones activo ahora mismo.'; + @override String get vacationsDefaultName => '休暇'; diff --git a/lib/l10n/gen/app_localizations_pt.dart b/lib/l10n/gen/app_localizations_pt.dart index bfd819b..2f6416a 100644 --- a/lib/l10n/gen/app_localizations_pt.dart +++ b/lib/l10n/gen/app_localizations_pt.dart @@ -1023,6 +1023,29 @@ class AppLocalizationsPt extends AppLocalizations { return 'Próximo rango en $days días'; } + @override + String vacationImpactPausedLabel(Object times) { + return 'Pausa: $times'; + } + + @override + String vacationImpactContinuesLabel(Object times) { + return 'Sigue sonando: $times'; + } + + @override + String get vacationUpcomingSectionTitle => 'PROGRAMADOS'; + + @override + String get vacationPastSectionTitle => 'Rangos pasados'; + + @override + String get addVacationRangeCta => 'Añadir rango'; + + @override + String get vacationNoActiveRangeHint => + 'No hay un rango de vacaciones activo ahora mismo.'; + @override String get vacationsDefaultName => 'Férias'; diff --git a/lib/l10n/gen/app_localizations_ru.dart b/lib/l10n/gen/app_localizations_ru.dart index a95fb97..59f0563 100644 --- a/lib/l10n/gen/app_localizations_ru.dart +++ b/lib/l10n/gen/app_localizations_ru.dart @@ -1025,6 +1025,29 @@ class AppLocalizationsRu extends AppLocalizations { return 'Próximo rango en $days días'; } + @override + String vacationImpactPausedLabel(Object times) { + return 'Pausa: $times'; + } + + @override + String vacationImpactContinuesLabel(Object times) { + return 'Sigue sonando: $times'; + } + + @override + String get vacationUpcomingSectionTitle => 'PROGRAMADOS'; + + @override + String get vacationPastSectionTitle => 'Rangos pasados'; + + @override + String get addVacationRangeCta => 'Añadir rango'; + + @override + String get vacationNoActiveRangeHint => + 'No hay un rango de vacaciones activo ahora mismo.'; + @override String get vacationsDefaultName => 'Отпуск'; diff --git a/lib/l10n/gen/app_localizations_zh.dart b/lib/l10n/gen/app_localizations_zh.dart index 960292c..d4ed20b 100644 --- a/lib/l10n/gen/app_localizations_zh.dart +++ b/lib/l10n/gen/app_localizations_zh.dart @@ -990,6 +990,29 @@ class AppLocalizationsZh extends AppLocalizations { return 'Próximo rango en $days días'; } + @override + String vacationImpactPausedLabel(Object times) { + return 'Pausa: $times'; + } + + @override + String vacationImpactContinuesLabel(Object times) { + return 'Sigue sonando: $times'; + } + + @override + String get vacationUpcomingSectionTitle => 'PROGRAMADOS'; + + @override + String get vacationPastSectionTitle => 'Rangos pasados'; + + @override + String get addVacationRangeCta => 'Añadir rango'; + + @override + String get vacationNoActiveRangeHint => + 'No hay un rango de vacaciones activo ahora mismo.'; + @override String get vacationsDefaultName => '假期'; diff --git a/lib/modelos/alarma_musical.dart b/lib/modelos/alarma_musical.dart index d464f69..f57cff4 100644 --- a/lib/modelos/alarma_musical.dart +++ b/lib/modelos/alarma_musical.dart @@ -277,6 +277,20 @@ class RangoVacaciones { } } +/// Per-alarm vacation pause impact (design ADR-6, WU9). Produced by +/// `EstadoAlarmas.impactoDeRango`, never persisted, never built from a +/// second date-math implementation — see that method's own doc comment for +/// the exact predicate it mirrors. +class ImpactoVacaciones { + const ImpactoVacaciones({required this.pausadas, required this.noAfectadas}); + + /// `activa && !sonarEnVacaciones`. + final List pausadas; + + /// `activa && sonarEnVacaciones`. + final List noAfectadas; +} + class ExcepcionAlarma { const ExcepcionAlarma({ required this.alarmaId, diff --git a/lib/pantallas/pantalla_alarmas.dart b/lib/pantallas/pantalla_alarmas.dart index 91e7d8f..3e04fc1 100644 --- a/lib/pantallas/pantalla_alarmas.dart +++ b/lib/pantallas/pantalla_alarmas.dart @@ -17,6 +17,7 @@ import '../widgets/pluri_icon.dart'; import '../widgets/pluri_layout.dart'; import '../widgets/pluri_premium_widgets.dart'; import '../widgets/pluri_push_scaffold.dart'; +import 'pantalla_vacaciones.dart'; class PantallaAlarmas extends StatelessWidget { const PantallaAlarmas({super.key}); @@ -1045,7 +1046,7 @@ class _PanelVacaciones extends StatelessWidget { } void _abrirVacaciones(BuildContext context) { - PluriPushScaffold.push(context, (_) => const _PantallaVacacionesTemporal()); + PluriPushScaffold.push(context, (_) => const PantallaVacaciones()); } /// Range count + next-range countdown, computed directly over the existing @@ -1081,196 +1082,6 @@ class _PanelVacaciones extends StatelessWidget { } } -/// Temporary push destination (WU8). WU9 replaces this whole widget with the -/// real `PantallaVacaciones` (active-range hero, per-alarm impact line, -/// upcoming/past sections) from `lib/pantallas/pantalla_vacaciones.dart`. The -/// body below is the OLD inline panel's content, moved verbatim — same -/// "keep the real action, drop only the header" rule WU3a/WU3b established — -/// so add/delete range capability is never dropped even for one commit. -class _PantallaVacacionesTemporal extends StatelessWidget { - const _PantallaVacacionesTemporal(); - - @override - Widget build(BuildContext context) { - final l10n = AppLocalizations.of(context); - final estado = context.watch(); - final vacaciones = [...estado.vacaciones] - ..sort((a, b) => a.inicioDia.compareTo(b.inicioDia)); - return PluriPushScaffold( - title: l10n.vacationRangesTitle, - body: ListView( - padding: PluriLayout.pageContentPadding, - children: [ - Text(l10n.vacationRangesHint), - const SizedBox(height: 12), - Align( - alignment: Alignment.centerRight, - child: FilledButton.tonalIcon( - onPressed: () => _abrirAlta(context), - icon: const Icon(Icons.add_rounded), - label: Text(l10n.addAction), - ), - ), - const SizedBox(height: 12), - if (vacaciones.isEmpty) - Text(l10n.noVacationRangesLoaded) - else - for (final rango in vacaciones) - ListTile( - contentPadding: EdgeInsets.zero, - leading: const Icon(Icons.event_busy_rounded), - title: Text(_nombreVisibleVacaciones(l10n, rango)), - subtitle: Text( - '${_fechaCorta(l10n, rango.inicioDia)} → ${_fechaCorta(l10n, rango.finDia)}', - ), - trailing: IconButton( - tooltip: l10n.deleteRangeTooltip, - onPressed: () => estado.eliminarRangoVacaciones(rango.id), - icon: const Icon(Icons.delete_outline_rounded), - ), - ), - ], - ), - ); - } - - Future _abrirAlta(BuildContext context) async { - await showModalBottomSheet( - context: context, - isScrollControlled: true, - useSafeArea: true, - backgroundColor: Colors.transparent, - builder: (_) => const _EditorVacacionesSheet(), - ); - } -} - -class _EditorVacacionesSheet extends StatefulWidget { - const _EditorVacacionesSheet(); - - @override - State<_EditorVacacionesSheet> createState() => _EditorVacacionesSheetState(); -} - -class _EditorVacacionesSheetState extends State<_EditorVacacionesSheet> { - // Created lazily: AppLocalizations.of(context) cannot be read in - // initState (inherited-widget lookup assert in debug builds). - TextEditingController? _nombreController; - late DateTime _inicio; - late DateTime _fin; - - @override - void initState() { - super.initState(); - final hoy = DateTime.now(); - _inicio = DateTime(hoy.year, hoy.month, hoy.day); - _fin = _inicio.add(const Duration(days: 2)); - } - - @override - void didChangeDependencies() { - super.didChangeDependencies(); - _nombreController ??= TextEditingController( - text: AppLocalizations.of(context).vacationsDefaultName, - ); - } - - @override - void dispose() { - _nombreController?.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - final l10n = AppLocalizations.of(context); - final bottom = MediaQuery.of(context).viewInsets.bottom; - return Padding( - padding: EdgeInsets.fromLTRB(12, 12, 12, bottom + 12), - child: PluriGlassSurface( - borderRadius: BorderRadius.circular(28), - padding: const EdgeInsets.all(18), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - l10n.newVacationRangeTitle, - style: Theme.of( - context, - ).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.w900), - ), - const SizedBox(height: 12), - TextField( - controller: _nombreController, - decoration: InputDecoration(labelText: l10n.nameLabel), - ), - const SizedBox(height: 12), - Row( - children: [ - Expanded( - child: _PickerButton( - icon: Icons.play_arrow_rounded, - label: l10n.startLabel, - value: _fechaCorta(l10n, _inicio), - onTap: () => _elegirFecha(esInicio: true), - ), - ), - const SizedBox(width: 10), - Expanded( - child: _PickerButton( - icon: Icons.stop_rounded, - label: l10n.endLabel, - value: _fechaCorta(l10n, _fin), - onTap: () => _elegirFecha(esInicio: false), - ), - ), - ], - ), - const SizedBox(height: 16), - FilledButton.icon( - onPressed: _guardar, - icon: const Icon(Icons.check_rounded), - label: Text(l10n.saveRangeAction), - ), - ], - ), - ), - ); - } - - Future _elegirFecha({required bool esInicio}) async { - final actual = esInicio ? _inicio : _fin; - final hoy = DateTime.now(); - final seleccion = await showDatePicker( - context: context, - initialDate: actual, - firstDate: DateTime(hoy.year, hoy.month, hoy.day), - lastDate: hoy.add(const Duration(days: 1460)), - ); - if (seleccion == null) return; - setState(() { - if (esInicio) { - _inicio = seleccion; - if (_fin.isBefore(_inicio)) _fin = _inicio; - } else { - _fin = seleccion; - } - }); - } - - Future _guardar() async { - final estado = context.read(); - final rango = estado.servicio.crearRangoVacaciones( - inicio: _inicio, - fin: _fin, - nombre: _nombreController?.text.trim() ?? '', - ); - await estado.crearRangoVacaciones(rango); - if (mounted) Navigator.pop(context); - } -} - class _AssetIcon extends StatelessWidget { const _AssetIcon(this.asset, {this.size = 44, this.semanticLabel}); @@ -1395,10 +1206,6 @@ String _nombreVisibleAlarma(AppLocalizations l10n, AlarmaMusical alarma) { return localizedAlarmName(l10n, alarma.nombre); } -String _nombreVisibleVacaciones(AppLocalizations l10n, RangoVacaciones rango) { - return localizedVacationName(l10n, rango.nombre); -} - String _hora(AlarmaMusical alarma) => '${alarma.hora.toString().padLeft(2, '0')}:${alarma.minuto.toString().padLeft(2, '0')}'; diff --git a/lib/pantallas/pantalla_vacaciones.dart b/lib/pantallas/pantalla_vacaciones.dart new file mode 100644 index 0000000..4017955 --- /dev/null +++ b/lib/pantallas/pantalla_vacaciones.dart @@ -0,0 +1,353 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import '../estado/estado_alarmas.dart'; +import '../l10n/display_names.dart'; +import '../l10n/formato_fechas.dart'; +import '../l10n/gen/app_localizations.dart'; +import '../modelos/alarma_musical.dart'; +import '../tema/pluriwave_theme.dart'; +import '../widgets/pluri_glass_surface.dart'; +import '../widgets/pluri_layout.dart'; +import '../widgets/pluri_push_scaffold.dart'; + +/// Vacaciones manager (design ADR-6, `alarm-vacation-ranges` spec, WU9): +/// active-range hero (name + days-remaining + per-alarm impact line), the +/// "PROGRAMADOS" upcoming list, an "Añadir rango" CTA, and a "Rangos +/// pasados" history section. This is the REAL destination WU8's Alarmas- +/// root summary row pushes to, replacing WU8's own temporary placeholder +/// (`_PantallaVacacionesTemporal` in `pantalla_alarmas.dart`). +class PantallaVacaciones extends StatelessWidget { + const PantallaVacaciones({super.key}); + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context); + final estado = context.watch(); + final activo = estado.rangoVacacionesActivo(); + final proximas = estado.vacacionesProximas(); + final pasadas = estado.vacacionesPasadas(); + + return PluriPushScaffold( + title: l10n.vacationRangesTitle, + body: ListView( + padding: PluriLayout.pageContentPadding, + children: [ + if (activo != null) + _HeroRangoActivo(estado: estado, rango: activo) + else + PluriGlassSurface(child: Text(l10n.vacationNoActiveRangeHint)), + const SizedBox(height: 16), + _SeccionProgramados(proximas: proximas), + const SizedBox(height: 16), + Align( + alignment: Alignment.centerLeft, + child: OutlinedButton.icon( + onPressed: () => _abrirAlta(context), + icon: const Icon(Icons.add_rounded), + label: Text(l10n.addVacationRangeCta), + ), + ), + const SizedBox(height: 16), + _SeccionRangosPasados(pasadas: pasadas), + ], + ), + ); + } + + Future _abrirAlta(BuildContext context) async { + await showModalBottomSheet( + context: context, + isScrollControlled: true, + useSafeArea: true, + backgroundColor: Colors.transparent, + builder: (_) => const _EditorVacacionesSheet(), + ); + } +} + +/// Active-range hero: name, days-remaining countdown (reusing WU8's own +/// `vacationSummaryActiveCountdown` string — same concept, bigger stage), +/// a determinate progress bar, and the per-alarm impact line(s) from +/// `EstadoAlarmas.impactoDeRango`. +class _HeroRangoActivo extends StatelessWidget { + const _HeroRangoActivo({required this.estado, required this.rango}); + + final EstadoAlarmas estado; + final RangoVacaciones rango; + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context); + final hoy = DateTime.now(); + final hoyDia = DateTime(hoy.year, hoy.month, hoy.day); + final diasRestantes = rango.finDia.difference(hoyDia).inDays; + final impacto = estado.impactoDeRango(rango); + + 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), + Text(l10n.vacationSummaryActiveCountdown(diasRestantes)), + const SizedBox(height: 10), + ClipRRect( + borderRadius: BorderRadius.circular(999), + child: LinearProgressIndicator(value: _progreso(hoyDia)), + ), + if (impacto.pausadas.isNotEmpty) ...[ + const SizedBox(height: 10), + Text(l10n.vacationImpactPausedLabel(_horas(impacto.pausadas))), + ], + if (impacto.noAfectadas.isNotEmpty) ...[ + const SizedBox(height: 4), + Text( + l10n.vacationImpactContinuesLabel(_horas(impacto.noAfectadas)), + ), + ], + ], + ), + ); + } + + double _progreso(DateTime hoyDia) { + final totalDias = rango.finDia.difference(rango.inicioDia).inDays + 1; + if (totalDias <= 0) return 0; + final transcurridos = hoyDia.difference(rango.inicioDia).inDays + 1; + return (transcurridos / totalDias).clamp(0.0, 1.0); + } + + String _horas(List alarmas) => alarmas + .map( + (a) => + '${a.hora.toString().padLeft(2, '0')}:${a.minuto.toString().padLeft(2, '0')}', + ) + .join(', '); +} + +class _SeccionProgramados extends StatelessWidget { + const _SeccionProgramados({required this.proximas}); + + final List proximas; + + @override + Widget build(BuildContext context) { + if (proximas.isEmpty) return const SizedBox.shrink(); + final l10n = AppLocalizations.of(context); + final type = context.pluriType; + return PluriGlassSurface( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(l10n.vacationUpcomingSectionTitle, style: type.eyebrowLabel), + const SizedBox(height: 8), + for (final rango in proximas) + ListTile( + contentPadding: EdgeInsets.zero, + leading: const Icon(Icons.event_rounded), + title: Text(localizedVacationName(l10n, rango.nombre)), + subtitle: Text(_rangoFechas(l10n, rango)), + ), + ], + ), + ); + } +} + +class _SeccionRangosPasados extends StatelessWidget { + const _SeccionRangosPasados({required this.pasadas}); + + final List pasadas; + + @override + Widget build(BuildContext context) { + if (pasadas.isEmpty) return const SizedBox.shrink(); + final l10n = AppLocalizations.of(context); + final type = context.pluriType; + return PluriGlassSurface( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(l10n.vacationPastSectionTitle, style: type.eyebrowLabel), + const SizedBox(height: 8), + for (final rango in pasadas) + ListTile( + contentPadding: EdgeInsets.zero, + leading: const Icon(Icons.history_rounded), + title: Text(localizedVacationName(l10n, rango.nombre)), + subtitle: Text(_rangoFechas(l10n, rango)), + ), + ], + ), + ); + } +} + +String _rangoFechas(AppLocalizations l10n, RangoVacaciones rango) => + '${fechaCortaLocalizada(l10n.localeName, rango.inicioDia)} → ' + '${fechaCortaLocalizada(l10n.localeName, rango.finDia)}'; + +/// Add-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. +class _EditorVacacionesSheet extends StatefulWidget { + const _EditorVacacionesSheet(); + + @override + State<_EditorVacacionesSheet> createState() => _EditorVacacionesSheetState(); +} + +class _EditorVacacionesSheetState extends State<_EditorVacacionesSheet> { + // Created lazily: AppLocalizations.of(context) cannot be read in + // initState (inherited-widget lookup assert in debug builds). + TextEditingController? _nombreController; + late DateTime _inicio; + late DateTime _fin; + + @override + void initState() { + super.initState(); + final hoy = DateTime.now(); + _inicio = DateTime(hoy.year, hoy.month, hoy.day); + _fin = _inicio.add(const Duration(days: 2)); + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + _nombreController ??= TextEditingController( + text: AppLocalizations.of(context).vacationsDefaultName, + ); + } + + @override + void dispose() { + _nombreController?.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context); + final bottom = MediaQuery.of(context).viewInsets.bottom; + return Padding( + padding: EdgeInsets.fromLTRB(12, 12, 12, bottom + 12), + child: PluriGlassSurface( + borderRadius: BorderRadius.circular(28), + padding: const EdgeInsets.all(18), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + l10n.newVacationRangeTitle, + style: Theme.of( + context, + ).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.w900), + ), + const SizedBox(height: 12), + TextField( + controller: _nombreController, + decoration: InputDecoration(labelText: l10n.nameLabel), + ), + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: _PickerButton( + icon: Icons.play_arrow_rounded, + label: l10n.startLabel, + value: fechaCortaLocalizada(l10n.localeName, _inicio), + onTap: () => _elegirFecha(esInicio: true), + ), + ), + const SizedBox(width: 10), + Expanded( + child: _PickerButton( + icon: Icons.stop_rounded, + label: l10n.endLabel, + value: fechaCortaLocalizada(l10n.localeName, _fin), + onTap: () => _elegirFecha(esInicio: false), + ), + ), + ], + ), + const SizedBox(height: 16), + FilledButton.icon( + onPressed: _guardar, + icon: const Icon(Icons.check_rounded), + label: Text(l10n.saveRangeAction), + ), + ], + ), + ), + ); + } + + Future _elegirFecha({required bool esInicio}) async { + final actual = esInicio ? _inicio : _fin; + final hoy = DateTime.now(); + final seleccion = await showDatePicker( + context: context, + initialDate: actual, + firstDate: DateTime(hoy.year, hoy.month, hoy.day), + lastDate: hoy.add(const Duration(days: 1460)), + ); + if (seleccion == null) return; + setState(() { + if (esInicio) { + _inicio = seleccion; + if (_fin.isBefore(_inicio)) _fin = _inicio; + } else { + _fin = seleccion; + } + }); + } + + Future _guardar() async { + final estado = context.read(); + final rango = estado.servicio.crearRangoVacaciones( + inicio: _inicio, + fin: _fin, + nombre: _nombreController?.text.trim() ?? '', + ); + await estado.crearRangoVacaciones(rango); + if (mounted) Navigator.pop(context); + } +} + +class _PickerButton extends StatelessWidget { + const _PickerButton({ + required this.icon, + required this.label, + required this.value, + required this.onTap, + }); + + final IconData icon; + final String label; + final String value; + final VoidCallback? onTap; + + @override + Widget build(BuildContext context) { + return OutlinedButton.icon( + onPressed: onTap, + icon: Icon(icon), + label: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(label, style: Theme.of(context).textTheme.labelSmall), + Text(value), + ], + ), + ); + } +} diff --git a/openspec/changes/rediseno-funcional/tasks.md b/openspec/changes/rediseno-funcional/tasks.md index 1165f67..4af9464 100644 --- a/openspec/changes/rediseno-funcional/tasks.md +++ b/openspec/changes/rediseno-funcional/tasks.md @@ -553,23 +553,42 @@ Query, Past-Ranges History Query, Vacation Summary Row Replaces the Inline Panel **New tests**: `pantalla_vacaciones_test.dart` **Modified tests**: `estado_alarmas_test.dart` (additions only) -- [ ] 9.1 RED — with a **fixed `ahora`**: `rangoVacacionesActivo` returns the active range + days-remaining, or null +- [x] 9.1 RED — with a **fixed `ahora`**: `rangoVacacionesActivo` returns the active range + days-remaining, or null when none matches; `vacacionesProximas` returns future ranges ascending by start; `vacacionesPasadas` returns - ended ranges descending by end, disjoint from active/upcoming. -- [ ] 9.2 RED — `impactoDeRango`: given 3 alarms (2 with `sonarEnVacaciones == false`, 1 with `true`) during an + ended ranges descending by end, disjoint from active/upcoming. **Correction found at apply time**: `DateTime(...)` + is NOT a const constructor in Dart (only literal-free `const` values are) — the first draft wrote `const + RangoVacaciones(..., inicio: DateTime(...))` and failed to compile; fixed by dropping `const` on every + `RangoVacaciones` fixture built from a `DateTime(...)` call, same non-const pattern the pre-existing + `AlarmaMusical` fixtures in this file already use whenever they set a `DateTime` field. +- [x] 9.2 RED — `impactoDeRango`: given 3 alarms (2 with `sonarEnVacaciones == false`, 1 with `true`) during an active range, assert exactly those 2 in `pausadas` and that 1 in `noAfectadas`, mirroring - `servicio_programacion_alarmas.dart:150`'s predicate exactly. -- [ ] 9.3 RED — `pantalla_vacaciones_test.dart`: active-range hero with progress + per-alarm impact line; - "PROGRAMADOS" upcoming list; dashed "Añadir rango" CTA; "Rangos pasados" history row/screen. -- [ ] 9.4 GREEN — add the 4 pure query members to `EstadoAlarmas` (`{DateTime? ahora}` defaulting to + `servicio_programacion_alarmas.dart:150`'s predicate exactly. Added a 4th case (inactive alarm counts toward + neither list) and a 5th purity guard (none of the 4 query methods call `android.programar`). +- [x] 9.3 RED — `pantalla_vacaciones_test.dart`: active-range hero with progress + per-alarm impact line; + "PROGRAMADOS" upcoming list; dashed "Añadir rango" CTA; "Rangos pasados" history row/screen. **Correction found + at apply time**: `PantallaVacaciones` calls the new `EstadoAlarmas` query methods with NO `ahora` argument + (production code always uses real `DateTime.now()` — the `{DateTime? ahora}` clock injection exists for STATE- + layer tests only, per ADR-6's own explicit rejection of a testable Clock abstraction). The first draft used + hardcoded calendar-literal vacation ranges assuming a fixed "today", which silently exercised the WRONG + code path once real time didn't match; fixed by building every fixture's `RangoVacaciones` dates relative to + the real `DateTime.now()` (e.g. `hoyDia.add(Duration(days: 5))`) instead. +- [x] 9.4 GREEN — add the 4 pure query members to `EstadoAlarmas` (`{DateTime? ahora}` defaulting to `DateTime.now()`), delegating `rangoVacacionesActivo` to the existing `RangoVacaciones.contiene(fecha)`. -- [ ] 9.5 GREEN — add `ImpactoVacaciones` beside `RangoVacaciones` in `lib/modelos/alarma_musical.dart`. -- [ ] 9.6 GREEN — build `lib/pantallas/pantalla_vacaciones.dart` as a `PluriPushScaffold`; wire WU8's summary-row - tap to it. -- [ ] 9.7 REFACTOR — confirm none of the 4 new methods call `guardarVacaciones` or any reprogramming path - (read-only over `_alarmas`/`_vacaciones`); confirm no ticker/periodic timer was added. -- [ ] 9.8 Verify — `pantalla_alarma_sonando_dismiss_guard_test.dart` passes **unmodified** (hard rule — if a query - addition appears to require touching it, STOP and escalate; do not edit the test). +- [x] 9.5 GREEN — add `ImpactoVacaciones` beside `RangoVacaciones` in `lib/modelos/alarma_musical.dart`. +- [x] 9.6 GREEN — build `lib/pantallas/pantalla_vacaciones.dart` as a `PluriPushScaffold`; wire WU8's summary-row + tap to it. **Apply-time simplification, not spec-tested**: the "Añadir rango" CTA is a solid `OutlinedButton`, + not a literally dashed border — no dashed-border utility exists in this codebase beyond WU4's own private + one-off `_DashedBorderPainter` (`pantalla_favoritos.dart`), and duplicating a cosmetic `CustomPainter` for a + detail no GIVEN/WHEN/THEN scenario actually tests isn't justified. The add-range form (`_EditorVacacionesSheet`, + `_PickerButton`) was MOVED verbatim from `pantalla_alarmas.dart` (its only remaining consumer after WU8's + placeholder is deleted below) rather than duplicated. +- [x] 9.7 REFACTOR — confirm none of the 4 new methods call `guardarVacaciones` or any reprogramming path + (read-only over `_alarmas`/`_vacaciones`); confirm no ticker/periodic timer was added. Also deleted WU8's own + placeholder (`_PantallaVacacionesTemporal`) and the now-dead `_nombreVisibleVacaciones` wrapper from + `pantalla_alarmas.dart`, and rewired the summary row's `onTap` to `PantallaVacaciones`. +- [x] 9.8 Verify — `pantalla_alarma_sonando_dismiss_guard_test.dart` passes **unmodified** (hard rule — if a query + addition appears to require touching it, STOP and escalate; do not edit the test). Confirmed via `git diff` + (empty) and a full re-run (all 8 cases green) alongside the WU9 scoped suite. ## WU10 — Alarm editor sheet rewrite diff --git a/test/estado/estado_alarmas_test.dart b/test/estado/estado_alarmas_test.dart index fc5773c..b320f24 100644 --- a/test/estado/estado_alarmas_test.dart +++ b/test/estado/estado_alarmas_test.dart @@ -280,67 +280,61 @@ void main() { }, ); - test( - 'eliminarAlarma detiene el audio antes de cancelar cuando esta sonando ' - '(SS-1c, guardia de regresion)', - () async { - final android = FakePuertoAlarmasAndroid(); - final estado = EstadoAlarmas( - servicio: ServicioAlarmas(reloj: () => DateTime(2026, 5, 25, 7, 31)), - android: android, - iniciarAutomaticamente: false, - ); - addTearDown(estado.dispose); - addTearDown(android.dispose); - await estado.guardarAlarma( - const AlarmaMusical( - id: 'ring3', - nombre: 'Sonando', - hora: 7, - minuto: 30, - tipoProgramacion: TipoProgramacionAlarma.diaria, - diasSemana: [], - ), - ); - android.alarmaSonandoIdValor = 'ring3'; + test('eliminarAlarma detiene el audio antes de cancelar cuando esta sonando ' + '(SS-1c, guardia de regresion)', () async { + final android = FakePuertoAlarmasAndroid(); + final estado = EstadoAlarmas( + servicio: ServicioAlarmas(reloj: () => DateTime(2026, 5, 25, 7, 31)), + android: android, + iniciarAutomaticamente: false, + ); + addTearDown(estado.dispose); + addTearDown(android.dispose); + await estado.guardarAlarma( + const AlarmaMusical( + id: 'ring3', + nombre: 'Sonando', + hora: 7, + minuto: 30, + tipoProgramacion: TipoProgramacionAlarma.diaria, + diasSemana: [], + ), + ); + android.alarmaSonandoIdValor = 'ring3'; - await estado.eliminarAlarma('ring3'); + await estado.eliminarAlarma('ring3'); - expect(android.detencionesActivas, contains('ring3')); - expect(android.canceladas, contains('ring3')); - }, - ); + expect(android.detencionesActivas, contains('ring3')); + expect(android.canceladas, contains('ring3')); + }); - test( - 'eliminarAlarma usa detenerSonidoNativo cuando la consulta de sonando ' - 'falla (fail-toward-silence, regresion de eliminarAlarma)', - () async { - final android = FakePuertoAlarmasAndroid(); - final estado = EstadoAlarmas( - servicio: ServicioAlarmas(reloj: () => DateTime(2026, 5, 25, 7, 31)), - android: android, - iniciarAutomaticamente: false, - ); - addTearDown(estado.dispose); - addTearDown(android.dispose); - await estado.guardarAlarma( - const AlarmaMusical( - id: 'ring4', - nombre: 'Sonando', - hora: 7, - minuto: 30, - tipoProgramacion: TipoProgramacionAlarma.diaria, - diasSemana: [], - ), - ); - android.fallaConsultaSonando = true; + test('eliminarAlarma usa detenerSonidoNativo cuando la consulta de sonando ' + 'falla (fail-toward-silence, regresion de eliminarAlarma)', () async { + final android = FakePuertoAlarmasAndroid(); + final estado = EstadoAlarmas( + servicio: ServicioAlarmas(reloj: () => DateTime(2026, 5, 25, 7, 31)), + android: android, + iniciarAutomaticamente: false, + ); + addTearDown(estado.dispose); + addTearDown(android.dispose); + await estado.guardarAlarma( + const AlarmaMusical( + id: 'ring4', + nombre: 'Sonando', + hora: 7, + minuto: 30, + tipoProgramacion: TipoProgramacionAlarma.diaria, + diasSemana: [], + ), + ); + android.fallaConsultaSonando = true; - await estado.eliminarAlarma('ring4'); + await estado.eliminarAlarma('ring4'); - expect(android.detenidas, contains('ring4')); - expect(android.canceladas, contains('ring4')); - }, - ); + expect(android.detenidas, contains('ring4')); + expect(android.canceladas, contains('ring4')); + }); test( 'guardarAlarma (deshabilitar) usa detenerSonidoNativo cuando la consulta ' @@ -412,98 +406,89 @@ void main() { }, ); - test( - 'finalizarEjecucion no registra error cuando el stop nativo se confirma ' - '(SS-2a)', - () async { - final android = FakePuertoAlarmasAndroid(); - final estado = EstadoAlarmas( - servicio: ServicioAlarmas(reloj: () => DateTime(2026, 5, 25, 7, 31)), - android: android, - iniciarAutomaticamente: false, - ); - addTearDown(estado.dispose); - addTearDown(android.dispose); - await estado.guardarAlarma( - const AlarmaMusical( - id: 'fin1', - nombre: 'Sonando', - hora: 7, - minuto: 30, - tipoProgramacion: TipoProgramacionAlarma.diaria, - diasSemana: [], - ), - ); - android.alarmaSonandoIdValor = 'fin1'; + test('finalizarEjecucion no registra error cuando el stop nativo se confirma ' + '(SS-2a)', () async { + final android = FakePuertoAlarmasAndroid(); + final estado = EstadoAlarmas( + servicio: ServicioAlarmas(reloj: () => DateTime(2026, 5, 25, 7, 31)), + android: android, + iniciarAutomaticamente: false, + ); + addTearDown(estado.dispose); + addTearDown(android.dispose); + await estado.guardarAlarma( + const AlarmaMusical( + id: 'fin1', + nombre: 'Sonando', + hora: 7, + minuto: 30, + tipoProgramacion: TipoProgramacionAlarma.diaria, + diasSemana: [], + ), + ); + android.alarmaSonandoIdValor = 'fin1'; - await estado.finalizarEjecucion('fin1'); + await estado.finalizarEjecucion('fin1'); - expect(android.detencionesActivas, contains('fin1')); - expect(estado.error, isNull); - }, - ); + expect(android.detencionesActivas, contains('fin1')); + expect(estado.error, isNull); + }); - test( - 'finalizarEjecucion registra error cuando el stop nativo no se confirma ' - '(SS-2b)', - () async { - final android = FakePuertoAlarmasAndroid()..fallaDetener = true; - final estado = EstadoAlarmas( - servicio: ServicioAlarmas(reloj: () => DateTime(2026, 5, 25, 7, 31)), - android: android, - iniciarAutomaticamente: false, - ); - addTearDown(estado.dispose); - addTearDown(android.dispose); - await estado.guardarAlarma( - const AlarmaMusical( - id: 'fin2', - nombre: 'Sonando', - hora: 7, - minuto: 30, - tipoProgramacion: TipoProgramacionAlarma.diaria, - diasSemana: [], - ), - ); + test('finalizarEjecucion registra error cuando el stop nativo no se confirma ' + '(SS-2b)', () async { + final android = FakePuertoAlarmasAndroid()..fallaDetener = true; + final estado = EstadoAlarmas( + servicio: ServicioAlarmas(reloj: () => DateTime(2026, 5, 25, 7, 31)), + android: android, + iniciarAutomaticamente: false, + ); + addTearDown(estado.dispose); + addTearDown(android.dispose); + await estado.guardarAlarma( + const AlarmaMusical( + id: 'fin2', + nombre: 'Sonando', + hora: 7, + minuto: 30, + tipoProgramacion: TipoProgramacionAlarma.diaria, + diasSemana: [], + ), + ); - await estado.finalizarEjecucion('fin2'); + await estado.finalizarEjecucion('fin2'); - expect(estado.error, isNotNull); - }, - ); + expect(estado.error, isNotNull); + }); - test( - 'forzarDetencion reintenta el stop nativo y limpia el error si tiene ' - 'exito (SS-3b)', - () async { - final android = FakePuertoAlarmasAndroid()..fallaDetener = true; - final estado = EstadoAlarmas( - servicio: ServicioAlarmas(reloj: () => DateTime(2026, 5, 25, 7, 31)), - android: android, - iniciarAutomaticamente: false, - ); - addTearDown(estado.dispose); - addTearDown(android.dispose); - await estado.guardarAlarma( - const AlarmaMusical( - id: 'force1', - nombre: 'Forzada', - hora: 7, - minuto: 30, - tipoProgramacion: TipoProgramacionAlarma.diaria, - diasSemana: [], - ), - ); - await estado.finalizarEjecucion('force1'); - expect(estado.error, isNotNull); + test('forzarDetencion reintenta el stop nativo y limpia el error si tiene ' + 'exito (SS-3b)', () async { + final android = FakePuertoAlarmasAndroid()..fallaDetener = true; + final estado = EstadoAlarmas( + servicio: ServicioAlarmas(reloj: () => DateTime(2026, 5, 25, 7, 31)), + android: android, + iniciarAutomaticamente: false, + ); + addTearDown(estado.dispose); + addTearDown(android.dispose); + await estado.guardarAlarma( + const AlarmaMusical( + id: 'force1', + nombre: 'Forzada', + hora: 7, + minuto: 30, + tipoProgramacion: TipoProgramacionAlarma.diaria, + diasSemana: [], + ), + ); + await estado.finalizarEjecucion('force1'); + expect(estado.error, isNotNull); - android.fallaDetener = false; - await estado.forzarDetencion('force1'); + android.fallaDetener = false; + await estado.forzarDetencion('force1'); - expect(estado.error, isNull); - expect(android.detencionesActivas.length, 2); - }, - ); + expect(estado.error, isNull); + expect(android.detencionesActivas.length, 2); + }); test( 'forzarDetencion mantiene el error si el reintento tambien falla (SS-3b)', @@ -534,49 +519,46 @@ void main() { }, ); - test( - 'evento nativo missed completa la ejecucion (Phase 6)', - () async { - final android = FakePuertoAlarmasAndroid(); - final estado = EstadoAlarmas( - servicio: ServicioAlarmas(reloj: () => DateTime(2026, 5, 25, 7, 31)), - android: android, - iniciarAutomaticamente: false, - ); - addTearDown(estado.dispose); - addTearDown(android.dispose); - await estado.guardarAlarma( - AlarmaMusical( - id: 'miss1', - nombre: 'Perdida', - hora: 7, - minuto: 30, - tipoProgramacion: TipoProgramacionAlarma.diaria, - diasSemana: const [], - proximaEjecucion: DateTime(2026, 5, 25, 7, 30), - ), - ); + test('evento nativo missed completa la ejecucion (Phase 6)', () async { + final android = FakePuertoAlarmasAndroid(); + final estado = EstadoAlarmas( + servicio: ServicioAlarmas(reloj: () => DateTime(2026, 5, 25, 7, 31)), + android: android, + iniciarAutomaticamente: false, + ); + addTearDown(estado.dispose); + addTearDown(android.dispose); + await estado.guardarAlarma( + AlarmaMusical( + id: 'miss1', + nombre: 'Perdida', + hora: 7, + minuto: 30, + tipoProgramacion: TipoProgramacionAlarma.diaria, + diasSemana: const [], + proximaEjecucion: DateTime(2026, 5, 25, 7, 30), + ), + ); - final notificado = Completer(); - estado.addListener(() { - if (!notificado.isCompleted) notificado.complete(); - }); - android.emitirEvento( - EventoAlarmaAndroid( - alarmaId: 'miss1', - titulo: 'Perdida', - accion: EventoAlarmaAndroid.accionMissed, - occurrenceAtMillis: DateTime(2026, 5, 25, 7, 30).millisecondsSinceEpoch, - ), - ); - await notificado.future; + final notificado = Completer(); + estado.addListener(() { + if (!notificado.isCompleted) notificado.complete(); + }); + android.emitirEvento( + EventoAlarmaAndroid( + alarmaId: 'miss1', + titulo: 'Perdida', + accion: EventoAlarmaAndroid.accionMissed, + occurrenceAtMillis: DateTime(2026, 5, 25, 7, 30).millisecondsSinceEpoch, + ), + ); + await notificado.future; - expect( - estado.alarmas.single.proximaEjecucion, - DateTime(2026, 5, 26, 7, 30), - ); - }, - ); + expect( + estado.alarmas.single.proximaEjecucion, + DateTime(2026, 5, 26, 7, 30), + ); + }); test( 'inicializar sincroniza ejecucion nativa y evita reprogramar al instante', @@ -628,4 +610,218 @@ void main() { ); }, ); + + group('EstadoAlarmas — consultas de vacaciones (ADR-6, WU9)', () { + test('rangoVacacionesActivo devuelve el rango cuyo intervalo incluye ' + '"ahora" (dias restantes derivables de finDia), o null si ninguno ' + 'calza (fixed ahora)', () async { + final android = FakePuertoAlarmasAndroid(); + final estado = EstadoAlarmas( + servicio: ServicioAlarmas(reloj: () => DateTime(2026, 7, 10)), + android: android, + iniciarAutomaticamente: false, + ); + addTearDown(estado.dispose); + addTearDown(android.dispose); + await estado.guardarVacaciones([ + RangoVacaciones( + id: 'v1', + nombre: 'Julio', + inicio: DateTime(2026, 7, 5), + fin: DateTime(2026, 7, 15), + ), + ]); + + final activo = estado.rangoVacacionesActivo(ahora: DateTime(2026, 7, 10)); + expect(activo?.id, 'v1'); + expect(activo!.finDia.difference(DateTime(2026, 7, 10)).inDays, 5); + + expect(estado.rangoVacacionesActivo(ahora: DateTime(2026, 8, 1)), isNull); + }); + + test('vacacionesProximas devuelve los rangos futuros ordenados por inicio ' + 'ascendente; vacacionesPasadas los finalizados por fin descendente; ' + 'ambas son disjuntas del rango activo', () async { + final android = FakePuertoAlarmasAndroid(); + final estado = EstadoAlarmas( + servicio: ServicioAlarmas(reloj: () => DateTime(2026, 7, 10)), + android: android, + iniciarAutomaticamente: false, + ); + addTearDown(estado.dispose); + addTearDown(android.dispose); + await estado.guardarVacaciones([ + RangoVacaciones( + id: 'activo', + nombre: 'Activo', + inicio: DateTime(2026, 7, 5), + fin: DateTime(2026, 7, 15), + ), + RangoVacaciones( + id: 'lejos', + nombre: 'Lejos', + inicio: DateTime(2026, 12, 1), + fin: DateTime(2026, 12, 10), + ), + RangoVacaciones( + id: 'cerca', + nombre: 'Cerca', + inicio: DateTime(2026, 8, 1), + fin: DateTime(2026, 8, 5), + ), + RangoVacaciones( + id: 'viejo', + nombre: 'Viejo', + inicio: DateTime(2026, 1, 1), + fin: DateTime(2026, 1, 10), + ), + RangoVacaciones( + id: 'reciente', + nombre: 'Reciente', + inicio: DateTime(2026, 6, 1), + fin: DateTime(2026, 6, 5), + ), + ]); + final ahora = DateTime(2026, 7, 10); + + final proximas = estado.vacacionesProximas(ahora: ahora); + expect(proximas.map((r) => r.id).toList(), ['cerca', 'lejos']); + + final pasadas = estado.vacacionesPasadas(ahora: ahora); + expect(pasadas.map((r) => r.id).toList(), ['reciente', 'viejo']); + + expect(proximas.any((r) => r.id == 'activo'), isFalse); + expect(pasadas.any((r) => r.id == 'activo'), isFalse); + }); + + test('impactoDeRango separa alarmas activas pausadas de las no afectadas ' + 'segun sonarEnVacaciones, igual que el predicado del planificador ' + '(servicio_programacion_alarmas.dart)', () async { + final android = FakePuertoAlarmasAndroid(); + final estado = EstadoAlarmas( + servicio: ServicioAlarmas(reloj: () => DateTime(2026, 7, 10)), + android: android, + iniciarAutomaticamente: false, + ); + addTearDown(estado.dispose); + addTearDown(android.dispose); + await estado.guardarAlarma( + const AlarmaMusical( + id: 'p1', + nombre: 'Pausada 1', + hora: 7, + minuto: 30, + tipoProgramacion: TipoProgramacionAlarma.diaria, + diasSemana: [], + sonarEnVacaciones: false, + ), + ); + await estado.guardarAlarma( + const AlarmaMusical( + id: 'p2', + nombre: 'Pausada 2', + hora: 13, + minuto: 45, + tipoProgramacion: TipoProgramacionAlarma.diaria, + diasSemana: [], + sonarEnVacaciones: false, + ), + ); + await estado.guardarAlarma( + const AlarmaMusical( + id: 'n1', + nombre: 'Sigue', + hora: 8, + minuto: 15, + tipoProgramacion: TipoProgramacionAlarma.diaria, + diasSemana: [], + ), + ); + final rango = RangoVacaciones( + id: 'v1', + nombre: 'Julio', + inicio: DateTime(2026, 7, 5), + fin: DateTime(2026, 7, 15), + ); + await estado.guardarVacaciones([rango]); + + final impacto = estado.impactoDeRango(rango); + + expect(impacto.pausadas.map((a) => a.id).toSet(), {'p1', 'p2'}); + expect(impacto.noAfectadas.map((a) => a.id).toSet(), {'n1'}); + }); + + test('impactoDeRango no cuenta alarmas inactivas ni como pausadas ni como ' + 'no afectadas', () async { + final android = FakePuertoAlarmasAndroid(); + final estado = EstadoAlarmas( + servicio: ServicioAlarmas(reloj: () => DateTime(2026, 7, 10)), + android: android, + iniciarAutomaticamente: false, + ); + addTearDown(estado.dispose); + addTearDown(android.dispose); + await estado.guardarAlarma( + const AlarmaMusical( + id: 'inactiva', + nombre: 'Apagada', + hora: 7, + minuto: 0, + tipoProgramacion: TipoProgramacionAlarma.diaria, + diasSemana: [], + sonarEnVacaciones: false, + activa: false, + ), + ); + final rango = RangoVacaciones( + id: 'v1', + nombre: 'Julio', + inicio: DateTime(2026, 7, 5), + fin: DateTime(2026, 7, 15), + ); + await estado.guardarVacaciones([rango]); + + final impacto = estado.impactoDeRango(rango); + + expect(impacto.pausadas, isEmpty); + expect(impacto.noAfectadas, isEmpty); + }); + + test('ninguno de los 4 metodos de consulta reprograma Android (guardia de ' + 'pureza — son solo lectura sobre _alarmas/_vacaciones)', () async { + final android = FakePuertoAlarmasAndroid(); + final estado = EstadoAlarmas( + servicio: ServicioAlarmas(reloj: () => DateTime(2026, 7, 10)), + android: android, + iniciarAutomaticamente: false, + ); + addTearDown(estado.dispose); + addTearDown(android.dispose); + await estado.guardarAlarma( + const AlarmaMusical( + id: 'q1', + nombre: 'Consulta', + hora: 7, + minuto: 0, + tipoProgramacion: TipoProgramacionAlarma.diaria, + diasSemana: [], + ), + ); + final rango = RangoVacaciones( + id: 'v1', + nombre: 'Julio', + inicio: DateTime(2026, 7, 5), + fin: DateTime(2026, 7, 15), + ); + await estado.guardarVacaciones([rango]); + final programadasAntes = android.programadas.length; + + estado.rangoVacacionesActivo(); + estado.vacacionesProximas(); + estado.vacacionesPasadas(); + estado.impactoDeRango(rango); + + expect(android.programadas.length, programadasAntes); + }); + }); } diff --git a/test/pantallas/pantalla_vacaciones_test.dart b/test/pantallas/pantalla_vacaciones_test.dart new file mode 100644 index 0000000..87ede45 --- /dev/null +++ b/test/pantallas/pantalla_vacaciones_test.dart @@ -0,0 +1,224 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:pluriwave/estado/estado_alarmas.dart'; +import 'package:pluriwave/l10n/gen/app_localizations.dart'; +import 'package:pluriwave/modelos/alarma_musical.dart'; +import 'package:pluriwave/pantallas/pantalla_vacaciones.dart'; +import 'package:pluriwave/servicios/servicio_alarmas.dart'; +import 'package:pluriwave/widgets/pluri_push_scaffold.dart'; +import 'package:provider/provider.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import '../helpers/fakes_alarmas.dart'; + +/// WU9, `alarm-vacation-ranges` spec — the real Vacaciones manager screen +/// that WU8's summary row pushes to (replacing WU8's own temporary +/// placeholder). Covers: active-range hero (name + days-remaining countdown +/// + per-alarm impact line), the "PROGRAMADOS" upcoming list, the "Añadir +/// rango" CTA, and the "Rangos pasados" history section. +/// +/// Clock note: `PantallaVacaciones` calls `EstadoAlarmas.rangoVacacionesActivo()` +/// etc. with NO `ahora` argument (production code always uses real +/// `DateTime.now()` — design ADR-6 explicitly rejects a testable Clock +/// abstraction as out of scope for this redesign). So fixtures here are +/// built RELATIVE to `DateTime.now()`, never as hardcoded calendar dates — +/// the `ServicioAlarmas`'s own `reloj` (used only for alarm SCHEDULING +/// maths, irrelevant to the vacation-query assertions below) is left at its +/// real-clock default too, for consistency. +DateTime get _hoy => DateTime.now(); +DateTime get _hoyDia => DateTime(_hoy.year, _hoy.month, _hoy.day); + +Future _crearEstado({ + List alarmas = const [], + List vacaciones = const [], +}) async { + final android = FakePuertoAlarmasAndroid(); + final estado = EstadoAlarmas( + servicio: ServicioAlarmas(), + android: android, + iniciarAutomaticamente: false, + ); + for (final alarma in alarmas) { + await estado.guardarAlarma(alarma); + } + if (vacaciones.isNotEmpty) { + await estado.guardarVacaciones(vacaciones); + } + return estado; +} + +Widget _buildScreen(EstadoAlarmas estado) { + return ChangeNotifierProvider.value( + value: estado, + child: MaterialApp( + locale: const Locale('es'), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: const PantallaVacaciones(), + ), + ); +} + +Future _pumpEstable(WidgetTester tester) async { + await tester.pump(); + await tester.pumpAndSettle(); +} + +void main() { + setUp(() { + SharedPreferences.setMockInitialValues({}); + }); + + testWidgets( + 'renders inside a PluriPushScaffold; con un rango activo muestra su ' + 'nombre, los dias restantes, y la linea de impacto por alarma', + (tester) async { + final estado = await _crearEstado( + alarmas: [ + const AlarmaMusical( + id: 'p1', + nombre: 'Pausada 1', + hora: 7, + minuto: 30, + tipoProgramacion: TipoProgramacionAlarma.diaria, + diasSemana: [], + sonarEnVacaciones: false, + ), + const AlarmaMusical( + id: 'p2', + nombre: 'Pausada 2', + hora: 13, + minuto: 45, + tipoProgramacion: TipoProgramacionAlarma.diaria, + diasSemana: [], + sonarEnVacaciones: false, + ), + const AlarmaMusical( + id: 'n1', + nombre: 'Sigue', + hora: 8, + minuto: 15, + tipoProgramacion: TipoProgramacionAlarma.diaria, + diasSemana: [], + ), + ], + 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); + + expect(find.byType(PluriPushScaffold), findsOneWidget); + final l10n = AppLocalizations.of( + tester.element(find.byType(PantallaVacaciones)), + ); + expect(find.text('Julio activo'), findsOneWidget); + expect(find.text(l10n.vacationSummaryActiveCountdown(5)), findsOneWidget); + expect( + find.text(l10n.vacationImpactPausedLabel('07:30, 13:45')), + findsOneWidget, + ); + expect( + find.text(l10n.vacationImpactContinuesLabel('08:15')), + findsOneWidget, + ); + }, + ); + + testWidgets('sin rango activo, muestra la pista en su lugar', (tester) async { + final estado = await _crearEstado(); + addTearDown(estado.dispose); + + await tester.pumpWidget(_buildScreen(estado)); + await _pumpEstable(tester); + + final l10n = AppLocalizations.of( + tester.element(find.byType(PantallaVacaciones)), + ); + expect(find.text(l10n.vacationNoActiveRangeHint), findsOneWidget); + }); + + testWidgets('los rangos futuros aparecen bajo el encabezado PROGRAMADOS', ( + tester, + ) async { + final estado = await _crearEstado( + vacaciones: [ + RangoVacaciones( + id: 'f1', + nombre: 'Rango lejano', + inicio: _hoyDia.add(const Duration(days: 120)), + fin: _hoyDia.add(const Duration(days: 125)), + ), + RangoVacaciones( + id: 'f2', + nombre: 'Rango cercano', + 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)), + ); + expect(find.text(l10n.vacationUpcomingSectionTitle), findsOneWidget); + expect(find.text('Rango lejano'), findsOneWidget); + expect(find.text('Rango cercano'), findsOneWidget); + }); + + testWidgets( + 'los rangos pasados aparecen bajo el encabezado "Rangos pasados"', + (tester) async { + final estado = await _crearEstado( + vacaciones: [ + RangoVacaciones( + id: 'pa1', + nombre: 'Rango viejo', + inicio: _hoyDia.subtract(const Duration(days: 30)), + fin: _hoyDia.subtract(const Duration(days: 20)), + ), + ], + ); + addTearDown(estado.dispose); + + await tester.pumpWidget(_buildScreen(estado)); + await _pumpEstable(tester); + + final l10n = AppLocalizations.of( + tester.element(find.byType(PantallaVacaciones)), + ); + expect(find.text(l10n.vacationPastSectionTitle), findsOneWidget); + expect(find.text('Rango viejo'), findsOneWidget); + }, + ); + + testWidgets('el CTA "Añadir rango" abre el formulario de alta existente', ( + tester, + ) async { + final estado = await _crearEstado(); + addTearDown(estado.dispose); + + await tester.pumpWidget(_buildScreen(estado)); + await _pumpEstable(tester); + + final l10n = AppLocalizations.of( + tester.element(find.byType(PantallaVacaciones)), + ); + await tester.tap(find.text(l10n.addVacationRangeCta)); + await _pumpEstable(tester); + + expect(find.text(l10n.newVacationRangeTitle), findsOneWidget); + }); +}