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.
This commit is contained in:
@@ -354,6 +354,69 @@ class EstadoAlarmas extends ChangeNotifier {
|
|||||||
await guardarVacaciones(nuevos);
|
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<RangoVacaciones> 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<RangoVacaciones> 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 = <AlarmaMusical>[];
|
||||||
|
final noAfectadas = <AlarmaMusical>[];
|
||||||
|
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) {
|
ExcepcionAlarma? ultimaExcepcionPara(String alarmaId) {
|
||||||
final candidatas =
|
final candidatas =
|
||||||
_excepciones.where((e) => e.alarmaId == alarmaId).toList()
|
_excepciones.where((e) => e.alarmaId == alarmaId).toList()
|
||||||
|
|||||||
@@ -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",
|
"vacationsDefaultName": "Vacation",
|
||||||
"newVacationRangeTitle": "New vacation range",
|
"newVacationRangeTitle": "New vacation range",
|
||||||
"startField": "Start",
|
"startField": "Start",
|
||||||
|
|||||||
@@ -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",
|
"vacationsDefaultName": "Vacaciones",
|
||||||
"newVacationRangeTitle": "Nuevo rango de vacaciones",
|
"newVacationRangeTitle": "Nuevo rango de vacaciones",
|
||||||
"startField": "Inicio",
|
"startField": "Inicio",
|
||||||
|
|||||||
@@ -1874,6 +1874,42 @@ abstract class AppLocalizations {
|
|||||||
/// **'Próximo rango en {days} días'**
|
/// **'Próximo rango en {days} días'**
|
||||||
String vacationSummaryUpcomingCountdown(int days);
|
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.
|
/// No description provided for @vacationsDefaultName.
|
||||||
///
|
///
|
||||||
/// In es, this message translates to:
|
/// In es, this message translates to:
|
||||||
|
|||||||
@@ -1018,6 +1018,29 @@ class AppLocalizationsAr extends AppLocalizations {
|
|||||||
return 'Próximo rango en $days días';
|
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
|
@override
|
||||||
String get vacationsDefaultName => 'إجازات';
|
String get vacationsDefaultName => 'إجازات';
|
||||||
|
|
||||||
|
|||||||
@@ -1025,6 +1025,29 @@ class AppLocalizationsBn extends AppLocalizations {
|
|||||||
return 'Próximo rango en $days días';
|
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
|
@override
|
||||||
String get vacationsDefaultName => 'ছুটি';
|
String get vacationsDefaultName => 'ছুটি';
|
||||||
|
|
||||||
|
|||||||
@@ -1026,6 +1026,29 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||||||
return 'Próximo rango en $days días';
|
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
|
@override
|
||||||
String get vacationsDefaultName => 'Ferien';
|
String get vacationsDefaultName => 'Ferien';
|
||||||
|
|
||||||
|
|||||||
@@ -1020,6 +1020,28 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||||||
return 'Next range in $days days';
|
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
|
@override
|
||||||
String get vacationsDefaultName => 'Vacation';
|
String get vacationsDefaultName => 'Vacation';
|
||||||
|
|
||||||
|
|||||||
@@ -1024,6 +1024,29 @@ class AppLocalizationsEs extends AppLocalizations {
|
|||||||
return 'Próximo rango en $days días';
|
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
|
@override
|
||||||
String get vacationsDefaultName => 'Vacaciones';
|
String get vacationsDefaultName => 'Vacaciones';
|
||||||
|
|
||||||
|
|||||||
@@ -1030,6 +1030,29 @@ class AppLocalizationsFr extends AppLocalizations {
|
|||||||
return 'Próximo rango en $days días';
|
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
|
@override
|
||||||
String get vacationsDefaultName => 'Vacances';
|
String get vacationsDefaultName => 'Vacances';
|
||||||
|
|
||||||
|
|||||||
@@ -1021,6 +1021,29 @@ class AppLocalizationsHi extends AppLocalizations {
|
|||||||
return 'Próximo rango en $days días';
|
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
|
@override
|
||||||
String get vacationsDefaultName => 'छुट्टियाँ';
|
String get vacationsDefaultName => 'छुट्टियाँ';
|
||||||
|
|
||||||
|
|||||||
@@ -1025,6 +1025,29 @@ class AppLocalizationsId extends AppLocalizations {
|
|||||||
return 'Próximo rango en $days días';
|
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
|
@override
|
||||||
String get vacationsDefaultName => 'Liburan';
|
String get vacationsDefaultName => 'Liburan';
|
||||||
|
|
||||||
|
|||||||
@@ -1026,6 +1026,29 @@ class AppLocalizationsIt extends AppLocalizations {
|
|||||||
return 'Próximo rango en $days días';
|
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
|
@override
|
||||||
String get vacationsDefaultName => 'Vacanze';
|
String get vacationsDefaultName => 'Vacanze';
|
||||||
|
|
||||||
|
|||||||
@@ -994,6 +994,29 @@ class AppLocalizationsJa extends AppLocalizations {
|
|||||||
return 'Próximo rango en $days días';
|
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
|
@override
|
||||||
String get vacationsDefaultName => '休暇';
|
String get vacationsDefaultName => '休暇';
|
||||||
|
|
||||||
|
|||||||
@@ -1023,6 +1023,29 @@ class AppLocalizationsPt extends AppLocalizations {
|
|||||||
return 'Próximo rango en $days días';
|
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
|
@override
|
||||||
String get vacationsDefaultName => 'Férias';
|
String get vacationsDefaultName => 'Férias';
|
||||||
|
|
||||||
|
|||||||
@@ -1025,6 +1025,29 @@ class AppLocalizationsRu extends AppLocalizations {
|
|||||||
return 'Próximo rango en $days días';
|
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
|
@override
|
||||||
String get vacationsDefaultName => 'Отпуск';
|
String get vacationsDefaultName => 'Отпуск';
|
||||||
|
|
||||||
|
|||||||
@@ -990,6 +990,29 @@ class AppLocalizationsZh extends AppLocalizations {
|
|||||||
return 'Próximo rango en $days días';
|
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
|
@override
|
||||||
String get vacationsDefaultName => '假期';
|
String get vacationsDefaultName => '假期';
|
||||||
|
|
||||||
|
|||||||
@@ -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<AlarmaMusical> pausadas;
|
||||||
|
|
||||||
|
/// `activa && sonarEnVacaciones`.
|
||||||
|
final List<AlarmaMusical> noAfectadas;
|
||||||
|
}
|
||||||
|
|
||||||
class ExcepcionAlarma {
|
class ExcepcionAlarma {
|
||||||
const ExcepcionAlarma({
|
const ExcepcionAlarma({
|
||||||
required this.alarmaId,
|
required this.alarmaId,
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import '../widgets/pluri_icon.dart';
|
|||||||
import '../widgets/pluri_layout.dart';
|
import '../widgets/pluri_layout.dart';
|
||||||
import '../widgets/pluri_premium_widgets.dart';
|
import '../widgets/pluri_premium_widgets.dart';
|
||||||
import '../widgets/pluri_push_scaffold.dart';
|
import '../widgets/pluri_push_scaffold.dart';
|
||||||
|
import 'pantalla_vacaciones.dart';
|
||||||
|
|
||||||
class PantallaAlarmas extends StatelessWidget {
|
class PantallaAlarmas extends StatelessWidget {
|
||||||
const PantallaAlarmas({super.key});
|
const PantallaAlarmas({super.key});
|
||||||
@@ -1045,7 +1046,7 @@ class _PanelVacaciones extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _abrirVacaciones(BuildContext context) {
|
void _abrirVacaciones(BuildContext context) {
|
||||||
PluriPushScaffold.push(context, (_) => const _PantallaVacacionesTemporal());
|
PluriPushScaffold.push(context, (_) => const PantallaVacaciones());
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Range count + next-range countdown, computed directly over the existing
|
/// 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<EstadoAlarmas>();
|
|
||||||
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<void> _abrirAlta(BuildContext context) async {
|
|
||||||
await showModalBottomSheet<void>(
|
|
||||||
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<void> _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<void> _guardar() async {
|
|
||||||
final estado = context.read<EstadoAlarmas>();
|
|
||||||
final rango = estado.servicio.crearRangoVacaciones(
|
|
||||||
inicio: _inicio,
|
|
||||||
fin: _fin,
|
|
||||||
nombre: _nombreController?.text.trim() ?? '',
|
|
||||||
);
|
|
||||||
await estado.crearRangoVacaciones(rango);
|
|
||||||
if (mounted) Navigator.pop(context);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class _AssetIcon extends StatelessWidget {
|
class _AssetIcon extends StatelessWidget {
|
||||||
const _AssetIcon(this.asset, {this.size = 44, this.semanticLabel});
|
const _AssetIcon(this.asset, {this.size = 44, this.semanticLabel});
|
||||||
|
|
||||||
@@ -1395,10 +1206,6 @@ String _nombreVisibleAlarma(AppLocalizations l10n, AlarmaMusical alarma) {
|
|||||||
return localizedAlarmName(l10n, alarma.nombre);
|
return localizedAlarmName(l10n, alarma.nombre);
|
||||||
}
|
}
|
||||||
|
|
||||||
String _nombreVisibleVacaciones(AppLocalizations l10n, RangoVacaciones rango) {
|
|
||||||
return localizedVacationName(l10n, rango.nombre);
|
|
||||||
}
|
|
||||||
|
|
||||||
String _hora(AlarmaMusical alarma) =>
|
String _hora(AlarmaMusical alarma) =>
|
||||||
'${alarma.hora.toString().padLeft(2, '0')}:${alarma.minuto.toString().padLeft(2, '0')}';
|
'${alarma.hora.toString().padLeft(2, '0')}:${alarma.minuto.toString().padLeft(2, '0')}';
|
||||||
|
|
||||||
|
|||||||
@@ -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<EstadoAlarmas>();
|
||||||
|
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<void> _abrirAlta(BuildContext context) async {
|
||||||
|
await showModalBottomSheet<void>(
|
||||||
|
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<AlarmaMusical> 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<RangoVacaciones> 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<RangoVacaciones> 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<void> _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<void> _guardar() async {
|
||||||
|
final estado = context.read<EstadoAlarmas>();
|
||||||
|
final rango = estado.servicio.crearRangoVacaciones(
|
||||||
|
inicio: _inicio,
|
||||||
|
fin: _fin,
|
||||||
|
nombre: _nombreController?.text.trim() ?? '',
|
||||||
|
);
|
||||||
|
await estado.crearRangoVacaciones(rango);
|
||||||
|
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),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -553,23 +553,42 @@ Query, Past-Ranges History Query, Vacation Summary Row Replaces the Inline Panel
|
|||||||
**New tests**: `pantalla_vacaciones_test.dart`
|
**New tests**: `pantalla_vacaciones_test.dart`
|
||||||
**Modified tests**: `estado_alarmas_test.dart` (additions only)
|
**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
|
when none matches; `vacacionesProximas` returns future ranges ascending by start; `vacacionesPasadas` returns
|
||||||
ended ranges descending by end, disjoint from active/upcoming.
|
ended ranges descending by end, disjoint from active/upcoming. **Correction found at apply time**: `DateTime(...)`
|
||||||
- [ ] 9.2 RED — `impactoDeRango`: given 3 alarms (2 with `sonarEnVacaciones == false`, 1 with `true`) during an
|
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
|
active range, assert exactly those 2 in `pausadas` and that 1 in `noAfectadas`, mirroring
|
||||||
`servicio_programacion_alarmas.dart:150`'s predicate exactly.
|
`servicio_programacion_alarmas.dart:150`'s predicate exactly. Added a 4th case (inactive alarm counts toward
|
||||||
- [ ] 9.3 RED — `pantalla_vacaciones_test.dart`: active-range hero with progress + per-alarm impact line;
|
neither list) and a 5th purity guard (none of the 4 query methods call `android.programar`).
|
||||||
"PROGRAMADOS" upcoming list; dashed "Añadir rango" CTA; "Rangos pasados" history row/screen.
|
- [x] 9.3 RED — `pantalla_vacaciones_test.dart`: active-range hero with progress + per-alarm impact line;
|
||||||
- [ ] 9.4 GREEN — add the 4 pure query members to `EstadoAlarmas` (`{DateTime? ahora}` defaulting to
|
"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)`.
|
`DateTime.now()`), delegating `rangoVacacionesActivo` to the existing `RangoVacaciones.contiene(fecha)`.
|
||||||
- [ ] 9.5 GREEN — add `ImpactoVacaciones` beside `RangoVacaciones` in `lib/modelos/alarma_musical.dart`.
|
- [x] 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
|
- [x] 9.6 GREEN — build `lib/pantallas/pantalla_vacaciones.dart` as a `PluriPushScaffold`; wire WU8's summary-row
|
||||||
tap to it.
|
tap to it. **Apply-time simplification, not spec-tested**: the "Añadir rango" CTA is a solid `OutlinedButton`,
|
||||||
- [ ] 9.7 REFACTOR — confirm none of the 4 new methods call `guardarVacaciones` or any reprogramming path
|
not a literally dashed border — no dashed-border utility exists in this codebase beyond WU4's own private
|
||||||
(read-only over `_alarmas`/`_vacaciones`); confirm no ticker/periodic timer was added.
|
one-off `_DashedBorderPainter` (`pantalla_favoritos.dart`), and duplicating a cosmetic `CustomPainter` for a
|
||||||
- [ ] 9.8 Verify — `pantalla_alarma_sonando_dismiss_guard_test.dart` passes **unmodified** (hard rule — if a query
|
detail no GIVEN/WHEN/THEN scenario actually tests isn't justified. The add-range form (`_EditorVacacionesSheet`,
|
||||||
addition appears to require touching it, STOP and escalate; do not edit the test).
|
`_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
|
## WU10 — Alarm editor sheet rewrite
|
||||||
|
|
||||||
|
|||||||
@@ -280,67 +280,61 @@ void main() {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
test(
|
test('eliminarAlarma detiene el audio antes de cancelar cuando esta sonando '
|
||||||
'eliminarAlarma detiene el audio antes de cancelar cuando esta sonando '
|
'(SS-1c, guardia de regresion)', () async {
|
||||||
'(SS-1c, guardia de regresion)',
|
final android = FakePuertoAlarmasAndroid();
|
||||||
() async {
|
final estado = EstadoAlarmas(
|
||||||
final android = FakePuertoAlarmasAndroid();
|
servicio: ServicioAlarmas(reloj: () => DateTime(2026, 5, 25, 7, 31)),
|
||||||
final estado = EstadoAlarmas(
|
android: android,
|
||||||
servicio: ServicioAlarmas(reloj: () => DateTime(2026, 5, 25, 7, 31)),
|
iniciarAutomaticamente: false,
|
||||||
android: android,
|
);
|
||||||
iniciarAutomaticamente: false,
|
addTearDown(estado.dispose);
|
||||||
);
|
addTearDown(android.dispose);
|
||||||
addTearDown(estado.dispose);
|
await estado.guardarAlarma(
|
||||||
addTearDown(android.dispose);
|
const AlarmaMusical(
|
||||||
await estado.guardarAlarma(
|
id: 'ring3',
|
||||||
const AlarmaMusical(
|
nombre: 'Sonando',
|
||||||
id: 'ring3',
|
hora: 7,
|
||||||
nombre: 'Sonando',
|
minuto: 30,
|
||||||
hora: 7,
|
tipoProgramacion: TipoProgramacionAlarma.diaria,
|
||||||
minuto: 30,
|
diasSemana: [],
|
||||||
tipoProgramacion: TipoProgramacionAlarma.diaria,
|
),
|
||||||
diasSemana: [],
|
);
|
||||||
),
|
android.alarmaSonandoIdValor = 'ring3';
|
||||||
);
|
|
||||||
android.alarmaSonandoIdValor = 'ring3';
|
|
||||||
|
|
||||||
await estado.eliminarAlarma('ring3');
|
await estado.eliminarAlarma('ring3');
|
||||||
|
|
||||||
expect(android.detencionesActivas, contains('ring3'));
|
expect(android.detencionesActivas, contains('ring3'));
|
||||||
expect(android.canceladas, contains('ring3'));
|
expect(android.canceladas, contains('ring3'));
|
||||||
},
|
});
|
||||||
);
|
|
||||||
|
|
||||||
test(
|
test('eliminarAlarma usa detenerSonidoNativo cuando la consulta de sonando '
|
||||||
'eliminarAlarma usa detenerSonidoNativo cuando la consulta de sonando '
|
'falla (fail-toward-silence, regresion de eliminarAlarma)', () async {
|
||||||
'falla (fail-toward-silence, regresion de eliminarAlarma)',
|
final android = FakePuertoAlarmasAndroid();
|
||||||
() async {
|
final estado = EstadoAlarmas(
|
||||||
final android = FakePuertoAlarmasAndroid();
|
servicio: ServicioAlarmas(reloj: () => DateTime(2026, 5, 25, 7, 31)),
|
||||||
final estado = EstadoAlarmas(
|
android: android,
|
||||||
servicio: ServicioAlarmas(reloj: () => DateTime(2026, 5, 25, 7, 31)),
|
iniciarAutomaticamente: false,
|
||||||
android: android,
|
);
|
||||||
iniciarAutomaticamente: false,
|
addTearDown(estado.dispose);
|
||||||
);
|
addTearDown(android.dispose);
|
||||||
addTearDown(estado.dispose);
|
await estado.guardarAlarma(
|
||||||
addTearDown(android.dispose);
|
const AlarmaMusical(
|
||||||
await estado.guardarAlarma(
|
id: 'ring4',
|
||||||
const AlarmaMusical(
|
nombre: 'Sonando',
|
||||||
id: 'ring4',
|
hora: 7,
|
||||||
nombre: 'Sonando',
|
minuto: 30,
|
||||||
hora: 7,
|
tipoProgramacion: TipoProgramacionAlarma.diaria,
|
||||||
minuto: 30,
|
diasSemana: [],
|
||||||
tipoProgramacion: TipoProgramacionAlarma.diaria,
|
),
|
||||||
diasSemana: [],
|
);
|
||||||
),
|
android.fallaConsultaSonando = true;
|
||||||
);
|
|
||||||
android.fallaConsultaSonando = true;
|
|
||||||
|
|
||||||
await estado.eliminarAlarma('ring4');
|
await estado.eliminarAlarma('ring4');
|
||||||
|
|
||||||
expect(android.detenidas, contains('ring4'));
|
expect(android.detenidas, contains('ring4'));
|
||||||
expect(android.canceladas, contains('ring4'));
|
expect(android.canceladas, contains('ring4'));
|
||||||
},
|
});
|
||||||
);
|
|
||||||
|
|
||||||
test(
|
test(
|
||||||
'guardarAlarma (deshabilitar) usa detenerSonidoNativo cuando la consulta '
|
'guardarAlarma (deshabilitar) usa detenerSonidoNativo cuando la consulta '
|
||||||
@@ -412,98 +406,89 @@ void main() {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
test(
|
test('finalizarEjecucion no registra error cuando el stop nativo se confirma '
|
||||||
'finalizarEjecucion no registra error cuando el stop nativo se confirma '
|
'(SS-2a)', () async {
|
||||||
'(SS-2a)',
|
final android = FakePuertoAlarmasAndroid();
|
||||||
() async {
|
final estado = EstadoAlarmas(
|
||||||
final android = FakePuertoAlarmasAndroid();
|
servicio: ServicioAlarmas(reloj: () => DateTime(2026, 5, 25, 7, 31)),
|
||||||
final estado = EstadoAlarmas(
|
android: android,
|
||||||
servicio: ServicioAlarmas(reloj: () => DateTime(2026, 5, 25, 7, 31)),
|
iniciarAutomaticamente: false,
|
||||||
android: android,
|
);
|
||||||
iniciarAutomaticamente: false,
|
addTearDown(estado.dispose);
|
||||||
);
|
addTearDown(android.dispose);
|
||||||
addTearDown(estado.dispose);
|
await estado.guardarAlarma(
|
||||||
addTearDown(android.dispose);
|
const AlarmaMusical(
|
||||||
await estado.guardarAlarma(
|
id: 'fin1',
|
||||||
const AlarmaMusical(
|
nombre: 'Sonando',
|
||||||
id: 'fin1',
|
hora: 7,
|
||||||
nombre: 'Sonando',
|
minuto: 30,
|
||||||
hora: 7,
|
tipoProgramacion: TipoProgramacionAlarma.diaria,
|
||||||
minuto: 30,
|
diasSemana: [],
|
||||||
tipoProgramacion: TipoProgramacionAlarma.diaria,
|
),
|
||||||
diasSemana: [],
|
);
|
||||||
),
|
android.alarmaSonandoIdValor = 'fin1';
|
||||||
);
|
|
||||||
android.alarmaSonandoIdValor = 'fin1';
|
|
||||||
|
|
||||||
await estado.finalizarEjecucion('fin1');
|
await estado.finalizarEjecucion('fin1');
|
||||||
|
|
||||||
expect(android.detencionesActivas, contains('fin1'));
|
expect(android.detencionesActivas, contains('fin1'));
|
||||||
expect(estado.error, isNull);
|
expect(estado.error, isNull);
|
||||||
},
|
});
|
||||||
);
|
|
||||||
|
|
||||||
test(
|
test('finalizarEjecucion registra error cuando el stop nativo no se confirma '
|
||||||
'finalizarEjecucion registra error cuando el stop nativo no se confirma '
|
'(SS-2b)', () async {
|
||||||
'(SS-2b)',
|
final android = FakePuertoAlarmasAndroid()..fallaDetener = true;
|
||||||
() async {
|
final estado = EstadoAlarmas(
|
||||||
final android = FakePuertoAlarmasAndroid()..fallaDetener = true;
|
servicio: ServicioAlarmas(reloj: () => DateTime(2026, 5, 25, 7, 31)),
|
||||||
final estado = EstadoAlarmas(
|
android: android,
|
||||||
servicio: ServicioAlarmas(reloj: () => DateTime(2026, 5, 25, 7, 31)),
|
iniciarAutomaticamente: false,
|
||||||
android: android,
|
);
|
||||||
iniciarAutomaticamente: false,
|
addTearDown(estado.dispose);
|
||||||
);
|
addTearDown(android.dispose);
|
||||||
addTearDown(estado.dispose);
|
await estado.guardarAlarma(
|
||||||
addTearDown(android.dispose);
|
const AlarmaMusical(
|
||||||
await estado.guardarAlarma(
|
id: 'fin2',
|
||||||
const AlarmaMusical(
|
nombre: 'Sonando',
|
||||||
id: 'fin2',
|
hora: 7,
|
||||||
nombre: 'Sonando',
|
minuto: 30,
|
||||||
hora: 7,
|
tipoProgramacion: TipoProgramacionAlarma.diaria,
|
||||||
minuto: 30,
|
diasSemana: [],
|
||||||
tipoProgramacion: TipoProgramacionAlarma.diaria,
|
),
|
||||||
diasSemana: [],
|
);
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
await estado.finalizarEjecucion('fin2');
|
await estado.finalizarEjecucion('fin2');
|
||||||
|
|
||||||
expect(estado.error, isNotNull);
|
expect(estado.error, isNotNull);
|
||||||
},
|
});
|
||||||
);
|
|
||||||
|
|
||||||
test(
|
test('forzarDetencion reintenta el stop nativo y limpia el error si tiene '
|
||||||
'forzarDetencion reintenta el stop nativo y limpia el error si tiene '
|
'exito (SS-3b)', () async {
|
||||||
'exito (SS-3b)',
|
final android = FakePuertoAlarmasAndroid()..fallaDetener = true;
|
||||||
() async {
|
final estado = EstadoAlarmas(
|
||||||
final android = FakePuertoAlarmasAndroid()..fallaDetener = true;
|
servicio: ServicioAlarmas(reloj: () => DateTime(2026, 5, 25, 7, 31)),
|
||||||
final estado = EstadoAlarmas(
|
android: android,
|
||||||
servicio: ServicioAlarmas(reloj: () => DateTime(2026, 5, 25, 7, 31)),
|
iniciarAutomaticamente: false,
|
||||||
android: android,
|
);
|
||||||
iniciarAutomaticamente: false,
|
addTearDown(estado.dispose);
|
||||||
);
|
addTearDown(android.dispose);
|
||||||
addTearDown(estado.dispose);
|
await estado.guardarAlarma(
|
||||||
addTearDown(android.dispose);
|
const AlarmaMusical(
|
||||||
await estado.guardarAlarma(
|
id: 'force1',
|
||||||
const AlarmaMusical(
|
nombre: 'Forzada',
|
||||||
id: 'force1',
|
hora: 7,
|
||||||
nombre: 'Forzada',
|
minuto: 30,
|
||||||
hora: 7,
|
tipoProgramacion: TipoProgramacionAlarma.diaria,
|
||||||
minuto: 30,
|
diasSemana: [],
|
||||||
tipoProgramacion: TipoProgramacionAlarma.diaria,
|
),
|
||||||
diasSemana: [],
|
);
|
||||||
),
|
await estado.finalizarEjecucion('force1');
|
||||||
);
|
expect(estado.error, isNotNull);
|
||||||
await estado.finalizarEjecucion('force1');
|
|
||||||
expect(estado.error, isNotNull);
|
|
||||||
|
|
||||||
android.fallaDetener = false;
|
android.fallaDetener = false;
|
||||||
await estado.forzarDetencion('force1');
|
await estado.forzarDetencion('force1');
|
||||||
|
|
||||||
expect(estado.error, isNull);
|
expect(estado.error, isNull);
|
||||||
expect(android.detencionesActivas.length, 2);
|
expect(android.detencionesActivas.length, 2);
|
||||||
},
|
});
|
||||||
);
|
|
||||||
|
|
||||||
test(
|
test(
|
||||||
'forzarDetencion mantiene el error si el reintento tambien falla (SS-3b)',
|
'forzarDetencion mantiene el error si el reintento tambien falla (SS-3b)',
|
||||||
@@ -534,49 +519,46 @@ void main() {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
test(
|
test('evento nativo missed completa la ejecucion (Phase 6)', () async {
|
||||||
'evento nativo missed completa la ejecucion (Phase 6)',
|
final android = FakePuertoAlarmasAndroid();
|
||||||
() async {
|
final estado = EstadoAlarmas(
|
||||||
final android = FakePuertoAlarmasAndroid();
|
servicio: ServicioAlarmas(reloj: () => DateTime(2026, 5, 25, 7, 31)),
|
||||||
final estado = EstadoAlarmas(
|
android: android,
|
||||||
servicio: ServicioAlarmas(reloj: () => DateTime(2026, 5, 25, 7, 31)),
|
iniciarAutomaticamente: false,
|
||||||
android: android,
|
);
|
||||||
iniciarAutomaticamente: false,
|
addTearDown(estado.dispose);
|
||||||
);
|
addTearDown(android.dispose);
|
||||||
addTearDown(estado.dispose);
|
await estado.guardarAlarma(
|
||||||
addTearDown(android.dispose);
|
AlarmaMusical(
|
||||||
await estado.guardarAlarma(
|
id: 'miss1',
|
||||||
AlarmaMusical(
|
nombre: 'Perdida',
|
||||||
id: 'miss1',
|
hora: 7,
|
||||||
nombre: 'Perdida',
|
minuto: 30,
|
||||||
hora: 7,
|
tipoProgramacion: TipoProgramacionAlarma.diaria,
|
||||||
minuto: 30,
|
diasSemana: const [],
|
||||||
tipoProgramacion: TipoProgramacionAlarma.diaria,
|
proximaEjecucion: DateTime(2026, 5, 25, 7, 30),
|
||||||
diasSemana: const [],
|
),
|
||||||
proximaEjecucion: DateTime(2026, 5, 25, 7, 30),
|
);
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
final notificado = Completer<void>();
|
final notificado = Completer<void>();
|
||||||
estado.addListener(() {
|
estado.addListener(() {
|
||||||
if (!notificado.isCompleted) notificado.complete();
|
if (!notificado.isCompleted) notificado.complete();
|
||||||
});
|
});
|
||||||
android.emitirEvento(
|
android.emitirEvento(
|
||||||
EventoAlarmaAndroid(
|
EventoAlarmaAndroid(
|
||||||
alarmaId: 'miss1',
|
alarmaId: 'miss1',
|
||||||
titulo: 'Perdida',
|
titulo: 'Perdida',
|
||||||
accion: EventoAlarmaAndroid.accionMissed,
|
accion: EventoAlarmaAndroid.accionMissed,
|
||||||
occurrenceAtMillis: DateTime(2026, 5, 25, 7, 30).millisecondsSinceEpoch,
|
occurrenceAtMillis: DateTime(2026, 5, 25, 7, 30).millisecondsSinceEpoch,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
await notificado.future;
|
await notificado.future;
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
estado.alarmas.single.proximaEjecucion,
|
estado.alarmas.single.proximaEjecucion,
|
||||||
DateTime(2026, 5, 26, 7, 30),
|
DateTime(2026, 5, 26, 7, 30),
|
||||||
);
|
);
|
||||||
},
|
});
|
||||||
);
|
|
||||||
|
|
||||||
test(
|
test(
|
||||||
'inicializar sincroniza ejecucion nativa y evita reprogramar al instante',
|
'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);
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<EstadoAlarmas> _crearEstado({
|
||||||
|
List<AlarmaMusical> alarmas = const [],
|
||||||
|
List<RangoVacaciones> 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<EstadoAlarmas>.value(
|
||||||
|
value: estado,
|
||||||
|
child: MaterialApp(
|
||||||
|
locale: const Locale('es'),
|
||||||
|
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||||
|
supportedLocales: AppLocalizations.supportedLocales,
|
||||||
|
home: const PantallaVacaciones(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _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);
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user