Compare commits
4
Commits
6653e044c7
...
61d873f035
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
61d873f035 | ||
|
|
9dfcf0b428 | ||
|
|
a09d614f52 | ||
|
|
9a2eb57a0e |
@@ -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<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) {
|
||||
final candidatas =
|
||||
_excepciones.where((e) => e.alarmaId == alarmaId).toList()
|
||||
|
||||
@@ -422,6 +422,9 @@
|
||||
"editAction": "Edit",
|
||||
"skipNextAction": "Skip next",
|
||||
"deleteTooltip": "Delete",
|
||||
"alarmHeroSkipAction": "Skip",
|
||||
"alarmDeleteConfirmTitle": "Delete alarm?",
|
||||
"alarmDeleteConfirmMessage": "This can't be undone.",
|
||||
"alarmSkippedNoNextSnackbar": "Alarm skipped. There is no next occurrence left.",
|
||||
"alarmSkippedReturnsSnackbar": "Alarm skipped. It will return on {date}.",
|
||||
"@alarmSkippedReturnsSnackbar": {
|
||||
@@ -508,6 +511,46 @@
|
||||
"vacationRangesHint": "If an alarm is set to \"Paused during vacations\", it automatically skips these ranges.",
|
||||
"noVacationRangesLoaded": "No ranges loaded.",
|
||||
"deleteRangeTooltip": "Delete range",
|
||||
"vacationRangesCount": "{count} ranges",
|
||||
"@vacationRangesCount": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"vacationSummaryActiveCountdown": "Active now · {days} days left",
|
||||
"@vacationSummaryActiveCountdown": {
|
||||
"placeholders": {
|
||||
"days": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"vacationSummaryUpcomingCountdown": "Next range in {days} days",
|
||||
"@vacationSummaryUpcomingCountdown": {
|
||||
"placeholders": {
|
||||
"days": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"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",
|
||||
|
||||
@@ -422,6 +422,9 @@
|
||||
"editAction": "Editar",
|
||||
"skipNextAction": "Omitir siguiente",
|
||||
"deleteTooltip": "Eliminar",
|
||||
"alarmHeroSkipAction": "Saltar",
|
||||
"alarmDeleteConfirmTitle": "¿Eliminar alarma?",
|
||||
"alarmDeleteConfirmMessage": "Esta acción no se puede deshacer.",
|
||||
"alarmSkippedNoNextSnackbar": "Alarma omitida. No queda próxima ejecución.",
|
||||
"alarmSkippedReturnsSnackbar": "Alarma omitida. Volverá el {date}.",
|
||||
"@alarmSkippedReturnsSnackbar": {
|
||||
@@ -508,6 +511,46 @@
|
||||
"vacationRangesHint": "Si una alarma tiene \"Pausa en vacaciones\", se salta automáticamente estos rangos.",
|
||||
"noVacationRangesLoaded": "Sin rangos cargados.",
|
||||
"deleteRangeTooltip": "Eliminar rango",
|
||||
"vacationRangesCount": "{count} rangos",
|
||||
"@vacationRangesCount": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"vacationSummaryActiveCountdown": "En curso · quedan {days} días",
|
||||
"@vacationSummaryActiveCountdown": {
|
||||
"placeholders": {
|
||||
"days": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"vacationSummaryUpcomingCountdown": "Próximo rango en {days} días",
|
||||
"@vacationSummaryUpcomingCountdown": {
|
||||
"placeholders": {
|
||||
"days": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"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",
|
||||
|
||||
@@ -1570,6 +1570,24 @@ abstract class AppLocalizations {
|
||||
/// **'Eliminar'**
|
||||
String get deleteTooltip;
|
||||
|
||||
/// No description provided for @alarmHeroSkipAction.
|
||||
///
|
||||
/// In es, this message translates to:
|
||||
/// **'Saltar'**
|
||||
String get alarmHeroSkipAction;
|
||||
|
||||
/// No description provided for @alarmDeleteConfirmTitle.
|
||||
///
|
||||
/// In es, this message translates to:
|
||||
/// **'¿Eliminar alarma?'**
|
||||
String get alarmDeleteConfirmTitle;
|
||||
|
||||
/// No description provided for @alarmDeleteConfirmMessage.
|
||||
///
|
||||
/// In es, this message translates to:
|
||||
/// **'Esta acción no se puede deshacer.'**
|
||||
String get alarmDeleteConfirmMessage;
|
||||
|
||||
/// No description provided for @alarmSkippedNoNextSnackbar.
|
||||
///
|
||||
/// In es, this message translates to:
|
||||
@@ -1838,6 +1856,60 @@ abstract class AppLocalizations {
|
||||
/// **'Eliminar rango'**
|
||||
String get deleteRangeTooltip;
|
||||
|
||||
/// No description provided for @vacationRangesCount.
|
||||
///
|
||||
/// In es, this message translates to:
|
||||
/// **'{count} rangos'**
|
||||
String vacationRangesCount(int count);
|
||||
|
||||
/// No description provided for @vacationSummaryActiveCountdown.
|
||||
///
|
||||
/// In es, this message translates to:
|
||||
/// **'En curso · quedan {days} días'**
|
||||
String vacationSummaryActiveCountdown(int days);
|
||||
|
||||
/// No description provided for @vacationSummaryUpcomingCountdown.
|
||||
///
|
||||
/// In es, this message translates to:
|
||||
/// **'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:
|
||||
|
||||
@@ -840,6 +840,15 @@ class AppLocalizationsAr extends AppLocalizations {
|
||||
@override
|
||||
String get deleteTooltip => 'حذف';
|
||||
|
||||
@override
|
||||
String get alarmHeroSkipAction => 'Saltar';
|
||||
|
||||
@override
|
||||
String get alarmDeleteConfirmTitle => '¿Eliminar alarma?';
|
||||
|
||||
@override
|
||||
String get alarmDeleteConfirmMessage => 'Esta acción no se puede deshacer.';
|
||||
|
||||
@override
|
||||
String get alarmSkippedNoNextSnackbar =>
|
||||
'تم تخطي المنبه. لا يوجد تشغيل قادم.';
|
||||
@@ -994,6 +1003,44 @@ class AppLocalizationsAr extends AppLocalizations {
|
||||
@override
|
||||
String get deleteRangeTooltip => 'حذف النطاق';
|
||||
|
||||
@override
|
||||
String vacationRangesCount(int count) {
|
||||
return '$count rangos';
|
||||
}
|
||||
|
||||
@override
|
||||
String vacationSummaryActiveCountdown(int days) {
|
||||
return 'En curso · quedan $days días';
|
||||
}
|
||||
|
||||
@override
|
||||
String vacationSummaryUpcomingCountdown(int days) {
|
||||
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 => 'إجازات';
|
||||
|
||||
|
||||
@@ -845,6 +845,15 @@ class AppLocalizationsBn extends AppLocalizations {
|
||||
@override
|
||||
String get deleteTooltip => 'মুছুন';
|
||||
|
||||
@override
|
||||
String get alarmHeroSkipAction => 'Saltar';
|
||||
|
||||
@override
|
||||
String get alarmDeleteConfirmTitle => '¿Eliminar alarma?';
|
||||
|
||||
@override
|
||||
String get alarmDeleteConfirmMessage => 'Esta acción no se puede deshacer.';
|
||||
|
||||
@override
|
||||
String get alarmSkippedNoNextSnackbar =>
|
||||
'অ্যালার্ম এড়ানো হয়েছে। আর কোনো পরবর্তী চালনা নেই।';
|
||||
@@ -1001,6 +1010,44 @@ class AppLocalizationsBn extends AppLocalizations {
|
||||
@override
|
||||
String get deleteRangeTooltip => 'পরিসর মুছুন';
|
||||
|
||||
@override
|
||||
String vacationRangesCount(int count) {
|
||||
return '$count rangos';
|
||||
}
|
||||
|
||||
@override
|
||||
String vacationSummaryActiveCountdown(int days) {
|
||||
return 'En curso · quedan $days días';
|
||||
}
|
||||
|
||||
@override
|
||||
String vacationSummaryUpcomingCountdown(int days) {
|
||||
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 => 'ছুটি';
|
||||
|
||||
|
||||
@@ -848,6 +848,15 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
@override
|
||||
String get deleteTooltip => 'Löschen';
|
||||
|
||||
@override
|
||||
String get alarmHeroSkipAction => 'Saltar';
|
||||
|
||||
@override
|
||||
String get alarmDeleteConfirmTitle => '¿Eliminar alarma?';
|
||||
|
||||
@override
|
||||
String get alarmDeleteConfirmMessage => 'Esta acción no se puede deshacer.';
|
||||
|
||||
@override
|
||||
String get alarmSkippedNoNextSnackbar =>
|
||||
'Alarm ausgelassen. Es bleibt keine nächste Ausführung.';
|
||||
@@ -1002,6 +1011,44 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
@override
|
||||
String get deleteRangeTooltip => 'Zeitraum löschen';
|
||||
|
||||
@override
|
||||
String vacationRangesCount(int count) {
|
||||
return '$count rangos';
|
||||
}
|
||||
|
||||
@override
|
||||
String vacationSummaryActiveCountdown(int days) {
|
||||
return 'En curso · quedan $days días';
|
||||
}
|
||||
|
||||
@override
|
||||
String vacationSummaryUpcomingCountdown(int days) {
|
||||
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';
|
||||
|
||||
|
||||
@@ -842,6 +842,15 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
@override
|
||||
String get deleteTooltip => 'Delete';
|
||||
|
||||
@override
|
||||
String get alarmHeroSkipAction => 'Skip';
|
||||
|
||||
@override
|
||||
String get alarmDeleteConfirmTitle => 'Delete alarm?';
|
||||
|
||||
@override
|
||||
String get alarmDeleteConfirmMessage => 'This can\'t be undone.';
|
||||
|
||||
@override
|
||||
String get alarmSkippedNoNextSnackbar =>
|
||||
'Alarm skipped. There is no next occurrence left.';
|
||||
@@ -996,6 +1005,43 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
@override
|
||||
String get deleteRangeTooltip => 'Delete range';
|
||||
|
||||
@override
|
||||
String vacationRangesCount(int count) {
|
||||
return '$count ranges';
|
||||
}
|
||||
|
||||
@override
|
||||
String vacationSummaryActiveCountdown(int days) {
|
||||
return 'Active now · $days days left';
|
||||
}
|
||||
|
||||
@override
|
||||
String vacationSummaryUpcomingCountdown(int 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
|
||||
String get vacationsDefaultName => 'Vacation';
|
||||
|
||||
|
||||
@@ -846,6 +846,15 @@ class AppLocalizationsEs extends AppLocalizations {
|
||||
@override
|
||||
String get deleteTooltip => 'Eliminar';
|
||||
|
||||
@override
|
||||
String get alarmHeroSkipAction => 'Saltar';
|
||||
|
||||
@override
|
||||
String get alarmDeleteConfirmTitle => '¿Eliminar alarma?';
|
||||
|
||||
@override
|
||||
String get alarmDeleteConfirmMessage => 'Esta acción no se puede deshacer.';
|
||||
|
||||
@override
|
||||
String get alarmSkippedNoNextSnackbar =>
|
||||
'Alarma omitida. No queda próxima ejecución.';
|
||||
@@ -1000,6 +1009,44 @@ class AppLocalizationsEs extends AppLocalizations {
|
||||
@override
|
||||
String get deleteRangeTooltip => 'Eliminar rango';
|
||||
|
||||
@override
|
||||
String vacationRangesCount(int count) {
|
||||
return '$count rangos';
|
||||
}
|
||||
|
||||
@override
|
||||
String vacationSummaryActiveCountdown(int days) {
|
||||
return 'En curso · quedan $days días';
|
||||
}
|
||||
|
||||
@override
|
||||
String vacationSummaryUpcomingCountdown(int days) {
|
||||
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';
|
||||
|
||||
|
||||
@@ -850,6 +850,15 @@ class AppLocalizationsFr extends AppLocalizations {
|
||||
@override
|
||||
String get deleteTooltip => 'Supprimer';
|
||||
|
||||
@override
|
||||
String get alarmHeroSkipAction => 'Saltar';
|
||||
|
||||
@override
|
||||
String get alarmDeleteConfirmTitle => '¿Eliminar alarma?';
|
||||
|
||||
@override
|
||||
String get alarmDeleteConfirmMessage => 'Esta acción no se puede deshacer.';
|
||||
|
||||
@override
|
||||
String get alarmSkippedNoNextSnackbar =>
|
||||
'Alarme ignorée. Il ne reste aucune prochaine exécution.';
|
||||
@@ -1006,6 +1015,44 @@ class AppLocalizationsFr extends AppLocalizations {
|
||||
@override
|
||||
String get deleteRangeTooltip => 'Supprimer la période';
|
||||
|
||||
@override
|
||||
String vacationRangesCount(int count) {
|
||||
return '$count rangos';
|
||||
}
|
||||
|
||||
@override
|
||||
String vacationSummaryActiveCountdown(int days) {
|
||||
return 'En curso · quedan $days días';
|
||||
}
|
||||
|
||||
@override
|
||||
String vacationSummaryUpcomingCountdown(int days) {
|
||||
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';
|
||||
|
||||
|
||||
@@ -842,6 +842,15 @@ class AppLocalizationsHi extends AppLocalizations {
|
||||
@override
|
||||
String get deleteTooltip => 'हटाएँ';
|
||||
|
||||
@override
|
||||
String get alarmHeroSkipAction => 'Saltar';
|
||||
|
||||
@override
|
||||
String get alarmDeleteConfirmTitle => '¿Eliminar alarma?';
|
||||
|
||||
@override
|
||||
String get alarmDeleteConfirmMessage => 'Esta acción no se puede deshacer.';
|
||||
|
||||
@override
|
||||
String get alarmSkippedNoNextSnackbar =>
|
||||
'अलार्म छोड़ा गया। कोई अगली चाल बाकी नहीं।';
|
||||
@@ -997,6 +1006,44 @@ class AppLocalizationsHi extends AppLocalizations {
|
||||
@override
|
||||
String get deleteRangeTooltip => 'अवधि हटाएँ';
|
||||
|
||||
@override
|
||||
String vacationRangesCount(int count) {
|
||||
return '$count rangos';
|
||||
}
|
||||
|
||||
@override
|
||||
String vacationSummaryActiveCountdown(int days) {
|
||||
return 'En curso · quedan $days días';
|
||||
}
|
||||
|
||||
@override
|
||||
String vacationSummaryUpcomingCountdown(int days) {
|
||||
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 => 'छुट्टियाँ';
|
||||
|
||||
|
||||
@@ -845,6 +845,15 @@ class AppLocalizationsId extends AppLocalizations {
|
||||
@override
|
||||
String get deleteTooltip => 'Hapus';
|
||||
|
||||
@override
|
||||
String get alarmHeroSkipAction => 'Saltar';
|
||||
|
||||
@override
|
||||
String get alarmDeleteConfirmTitle => '¿Eliminar alarma?';
|
||||
|
||||
@override
|
||||
String get alarmDeleteConfirmMessage => 'Esta acción no se puede deshacer.';
|
||||
|
||||
@override
|
||||
String get alarmSkippedNoNextSnackbar =>
|
||||
'Alarm dilewati. Tidak ada eksekusi berikutnya.';
|
||||
@@ -1001,6 +1010,44 @@ class AppLocalizationsId extends AppLocalizations {
|
||||
@override
|
||||
String get deleteRangeTooltip => 'Hapus rentang';
|
||||
|
||||
@override
|
||||
String vacationRangesCount(int count) {
|
||||
return '$count rangos';
|
||||
}
|
||||
|
||||
@override
|
||||
String vacationSummaryActiveCountdown(int days) {
|
||||
return 'En curso · quedan $days días';
|
||||
}
|
||||
|
||||
@override
|
||||
String vacationSummaryUpcomingCountdown(int days) {
|
||||
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';
|
||||
|
||||
|
||||
@@ -846,6 +846,15 @@ class AppLocalizationsIt extends AppLocalizations {
|
||||
@override
|
||||
String get deleteTooltip => 'Elimina';
|
||||
|
||||
@override
|
||||
String get alarmHeroSkipAction => 'Saltar';
|
||||
|
||||
@override
|
||||
String get alarmDeleteConfirmTitle => '¿Eliminar alarma?';
|
||||
|
||||
@override
|
||||
String get alarmDeleteConfirmMessage => 'Esta acción no se puede deshacer.';
|
||||
|
||||
@override
|
||||
String get alarmSkippedNoNextSnackbar =>
|
||||
'Sveglia saltata. Non resta alcuna prossima esecuzione.';
|
||||
@@ -1002,6 +1011,44 @@ class AppLocalizationsIt extends AppLocalizations {
|
||||
@override
|
||||
String get deleteRangeTooltip => 'Elimina periodo';
|
||||
|
||||
@override
|
||||
String vacationRangesCount(int count) {
|
||||
return '$count rangos';
|
||||
}
|
||||
|
||||
@override
|
||||
String vacationSummaryActiveCountdown(int days) {
|
||||
return 'En curso · quedan $days días';
|
||||
}
|
||||
|
||||
@override
|
||||
String vacationSummaryUpcomingCountdown(int days) {
|
||||
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';
|
||||
|
||||
|
||||
@@ -820,6 +820,15 @@ class AppLocalizationsJa extends AppLocalizations {
|
||||
@override
|
||||
String get deleteTooltip => '削除';
|
||||
|
||||
@override
|
||||
String get alarmHeroSkipAction => 'Saltar';
|
||||
|
||||
@override
|
||||
String get alarmDeleteConfirmTitle => '¿Eliminar alarma?';
|
||||
|
||||
@override
|
||||
String get alarmDeleteConfirmMessage => 'Esta acción no se puede deshacer.';
|
||||
|
||||
@override
|
||||
String get alarmSkippedNoNextSnackbar => 'アラームをスキップしました。次回実行は残っていません。';
|
||||
|
||||
@@ -970,6 +979,44 @@ class AppLocalizationsJa extends AppLocalizations {
|
||||
@override
|
||||
String get deleteRangeTooltip => '期間を削除';
|
||||
|
||||
@override
|
||||
String vacationRangesCount(int count) {
|
||||
return '$count rangos';
|
||||
}
|
||||
|
||||
@override
|
||||
String vacationSummaryActiveCountdown(int days) {
|
||||
return 'En curso · quedan $days días';
|
||||
}
|
||||
|
||||
@override
|
||||
String vacationSummaryUpcomingCountdown(int days) {
|
||||
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 => '休暇';
|
||||
|
||||
|
||||
@@ -845,6 +845,15 @@ class AppLocalizationsPt extends AppLocalizations {
|
||||
@override
|
||||
String get deleteTooltip => 'Excluir';
|
||||
|
||||
@override
|
||||
String get alarmHeroSkipAction => 'Saltar';
|
||||
|
||||
@override
|
||||
String get alarmDeleteConfirmTitle => '¿Eliminar alarma?';
|
||||
|
||||
@override
|
||||
String get alarmDeleteConfirmMessage => 'Esta acción no se puede deshacer.';
|
||||
|
||||
@override
|
||||
String get alarmSkippedNoNextSnackbar =>
|
||||
'Alarme ignorado. Não resta próxima execução.';
|
||||
@@ -999,6 +1008,44 @@ class AppLocalizationsPt extends AppLocalizations {
|
||||
@override
|
||||
String get deleteRangeTooltip => 'Excluir período';
|
||||
|
||||
@override
|
||||
String vacationRangesCount(int count) {
|
||||
return '$count rangos';
|
||||
}
|
||||
|
||||
@override
|
||||
String vacationSummaryActiveCountdown(int days) {
|
||||
return 'En curso · quedan $days días';
|
||||
}
|
||||
|
||||
@override
|
||||
String vacationSummaryUpcomingCountdown(int days) {
|
||||
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';
|
||||
|
||||
|
||||
@@ -846,6 +846,15 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
@override
|
||||
String get deleteTooltip => 'Удалить';
|
||||
|
||||
@override
|
||||
String get alarmHeroSkipAction => 'Saltar';
|
||||
|
||||
@override
|
||||
String get alarmDeleteConfirmTitle => '¿Eliminar alarma?';
|
||||
|
||||
@override
|
||||
String get alarmDeleteConfirmMessage => 'Esta acción no se puede deshacer.';
|
||||
|
||||
@override
|
||||
String get alarmSkippedNoNextSnackbar =>
|
||||
'Будильник пропущен. Следующего запуска нет.';
|
||||
@@ -1001,6 +1010,44 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
@override
|
||||
String get deleteRangeTooltip => 'Удалить период';
|
||||
|
||||
@override
|
||||
String vacationRangesCount(int count) {
|
||||
return '$count rangos';
|
||||
}
|
||||
|
||||
@override
|
||||
String vacationSummaryActiveCountdown(int days) {
|
||||
return 'En curso · quedan $days días';
|
||||
}
|
||||
|
||||
@override
|
||||
String vacationSummaryUpcomingCountdown(int days) {
|
||||
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 => 'Отпуск';
|
||||
|
||||
|
||||
@@ -816,6 +816,15 @@ class AppLocalizationsZh extends AppLocalizations {
|
||||
@override
|
||||
String get deleteTooltip => '删除';
|
||||
|
||||
@override
|
||||
String get alarmHeroSkipAction => 'Saltar';
|
||||
|
||||
@override
|
||||
String get alarmDeleteConfirmTitle => '¿Eliminar alarma?';
|
||||
|
||||
@override
|
||||
String get alarmDeleteConfirmMessage => 'Esta acción no se puede deshacer.';
|
||||
|
||||
@override
|
||||
String get alarmSkippedNoNextSnackbar => '已跳过闹钟。没有剩余的下次执行。';
|
||||
|
||||
@@ -966,6 +975,44 @@ class AppLocalizationsZh extends AppLocalizations {
|
||||
@override
|
||||
String get deleteRangeTooltip => '删除范围';
|
||||
|
||||
@override
|
||||
String vacationRangesCount(int count) {
|
||||
return '$count rangos';
|
||||
}
|
||||
|
||||
@override
|
||||
String vacationSummaryActiveCountdown(int days) {
|
||||
return 'En curso · quedan $days días';
|
||||
}
|
||||
|
||||
@override
|
||||
String vacationSummaryUpcomingCountdown(int days) {
|
||||
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 => '假期';
|
||||
|
||||
|
||||
@@ -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 {
|
||||
const ExcepcionAlarma({
|
||||
required this.alarmaId,
|
||||
|
||||
+218
-372
@@ -16,6 +16,8 @@ import '../widgets/pluri_glass_surface.dart';
|
||||
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});
|
||||
@@ -128,6 +130,18 @@ class _PanelProximaAlarma extends StatelessWidget {
|
||||
: l10n.createAlarmHint
|
||||
: '${_nombreVisibleAlarma(l10n, proxima)} · ${_fechaHora(l10n, proximaProgramable!)}',
|
||||
),
|
||||
if (proxima != null) ...[
|
||||
const SizedBox(height: 10),
|
||||
Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: OutlinedButton.icon(
|
||||
key: const ValueKey('hero-skip-next'),
|
||||
onPressed: () => _saltarDesdeHero(context, proxima),
|
||||
icon: const Icon(Icons.skip_next_rounded, size: 18),
|
||||
label: Text(l10n.alarmHeroSkipAction),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -137,6 +151,40 @@ class _PanelProximaAlarma extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
/// Hero banner inline skip (native-alarms delta, WU8): skips the featured
|
||||
/// (soonest-firing) alarm via the SAME `saltarProxima` path the old
|
||||
/// always-visible per-card skip button used to call — only the trigger
|
||||
/// location moved.
|
||||
Future<void> _saltarDesdeHero(
|
||||
BuildContext context,
|
||||
AlarmaMusical proxima,
|
||||
) async {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final estado = context.read<EstadoAlarmas>();
|
||||
await estado.saltarProxima(proxima.id);
|
||||
if (!context.mounted) return;
|
||||
final actualizada = context.read<EstadoAlarmas>().alarmas.firstWhere(
|
||||
(item) => item.id == proxima.id,
|
||||
orElse: () => proxima,
|
||||
);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
actualizada.proximaProgramable == null
|
||||
? l10n.alarmSkippedNoNextSnackbar
|
||||
: l10n.alarmSkippedReturnsSnackbar(
|
||||
_fechaHora(l10n, actualizada.proximaProgramable!),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Simplified alarm card (native-alarms delta, WU8): giant time + station +
|
||||
/// switch, no always-visible action row. Edit/skip/delete are NEVER lost —
|
||||
/// they move behind gestures: tap opens the editor, swipe deletes (with
|
||||
/// confirmation), skip lives on the hero banner (`_PanelProximaAlarma`)
|
||||
/// instead of a per-card button.
|
||||
class _TarjetaAlarma extends StatelessWidget {
|
||||
const _TarjetaAlarma({required this.alarma});
|
||||
|
||||
@@ -146,187 +194,67 @@ class _TarjetaAlarma extends StatelessWidget {
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final estado = context.watch<EstadoAlarmas>();
|
||||
final excepcion = estado.ultimaExcepcionPara(alarma.id);
|
||||
final mensajeVacaciones = _mensajeVacaciones(l10n, estado.vacaciones);
|
||||
return PluriGlassSurface(
|
||||
glowColor: context.pluriTokens.electricMagenta.withValues(alpha: 0.22),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
_AssetIcon(
|
||||
'assets/icons/alarmas/alarm_music.png',
|
||||
size: 64,
|
||||
semanticLabel: l10n.alarmIconLabel,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
_hora(alarma),
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.headlineMedium?.copyWith(
|
||||
fontWeight: FontWeight.w900,
|
||||
letterSpacing: -1,
|
||||
),
|
||||
final tokens = context.pluriTokens;
|
||||
final estacion =
|
||||
alarma.emisora == null
|
||||
? l10n.noStationUseInternalSound
|
||||
: localizedStationName(l10n, alarma.emisora!.nombre);
|
||||
|
||||
return Dismissible(
|
||||
key: ValueKey('tarjeta-alarma-${alarma.id}'),
|
||||
direction: DismissDirection.horizontal,
|
||||
background: const _FondoSwipeEliminarAlarma(
|
||||
alignment: Alignment.centerLeft,
|
||||
),
|
||||
secondaryBackground: const _FondoSwipeEliminarAlarma(
|
||||
alignment: Alignment.centerRight,
|
||||
),
|
||||
confirmDismiss: (_) => _confirmarEliminarAlarma(context, l10n),
|
||||
onDismissed: (_) => estado.eliminarAlarma(alarma.id),
|
||||
child: PluriGlassSurface(
|
||||
glowColor: tokens.electricMagenta.withValues(alpha: 0.22),
|
||||
padding: EdgeInsets.zero,
|
||||
child: Material(
|
||||
type: MaterialType.transparency,
|
||||
child: InkWell(
|
||||
borderRadius: BorderRadius.circular(tokens.radiusMd),
|
||||
onTap: () => _abrirEditor(context, alarma: alarma),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
_hora(alarma),
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.displaySmall?.copyWith(
|
||||
fontWeight: FontWeight.w900,
|
||||
letterSpacing: -1.5,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(estacion, overflow: TextOverflow.ellipsis),
|
||||
],
|
||||
),
|
||||
Text(_nombreVisibleAlarma(l10n, alarma)),
|
||||
],
|
||||
),
|
||||
),
|
||||
Switch.adaptive(
|
||||
value: alarma.activa,
|
||||
onChanged: (value) => estado.cambiarActiva(alarma, value),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
_InfoChip(
|
||||
icon: Icons.repeat_rounded,
|
||||
label: _programacion(l10n, alarma),
|
||||
),
|
||||
_InfoChip(
|
||||
icon: Icons.beach_access_rounded,
|
||||
label:
|
||||
alarma.sonarEnVacaciones
|
||||
? l10n.alarmVacationPlay
|
||||
: l10n.alarmVacationPause,
|
||||
),
|
||||
_InfoChip(
|
||||
icon: Icons.volume_up_rounded,
|
||||
label: '${(alarma.volumen * 100).round()}%',
|
||||
),
|
||||
_InfoChip(
|
||||
icon: Icons.trending_up_rounded,
|
||||
label: l10n.alarmFadeInLabel(alarma.fadeInSegundos),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
if (alarma.proximaProgramable != null)
|
||||
_NoticeLine(
|
||||
icon: Icons.event_available_rounded,
|
||||
text: l10n.alarmNextExecution(
|
||||
_fechaHora(l10n, alarma.proximaProgramable!),
|
||||
),
|
||||
)
|
||||
else
|
||||
_NoticeLine(
|
||||
icon: Icons.pause_circle_outline_rounded,
|
||||
text: l10n.alarmNoNextExecution,
|
||||
),
|
||||
if (excepcion != null) ...[
|
||||
const SizedBox(height: 6),
|
||||
_NoticeLine(
|
||||
icon: Icons.skip_next_rounded,
|
||||
text: l10n.alarmSkippedExecution(
|
||||
_fechaHora(l10n, excepcion.ejecucion),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Switch.adaptive(
|
||||
value: alarma.activa,
|
||||
onChanged: (value) => estado.cambiarActiva(alarma, value),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
if (mensajeVacaciones != null) ...[
|
||||
const SizedBox(height: 6),
|
||||
_NoticeLine(
|
||||
icon: Icons.beach_access_rounded,
|
||||
text: mensajeVacaciones,
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
children: [
|
||||
TextButton.icon(
|
||||
icon: const Icon(Icons.edit_rounded),
|
||||
label: Text(l10n.editAction),
|
||||
onPressed: () => _abrirEditor(context, alarma: alarma),
|
||||
),
|
||||
TextButton.icon(
|
||||
icon: const Icon(Icons.skip_next_rounded),
|
||||
label: Text(l10n.skipNextAction),
|
||||
onPressed:
|
||||
alarma.proximaProgramable == null
|
||||
? null
|
||||
: () async {
|
||||
await estado.saltarProxima(alarma.id);
|
||||
if (context.mounted) {
|
||||
final alarmas =
|
||||
context.read<EstadoAlarmas>().alarmas;
|
||||
AlarmaMusical? actualizada;
|
||||
for (final item in alarmas) {
|
||||
if (item.id == alarma.id) {
|
||||
actualizada = item;
|
||||
break;
|
||||
}
|
||||
}
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
actualizada?.proximaProgramable == null
|
||||
? l10n.alarmSkippedNoNextSnackbar
|
||||
: l10n.alarmSkippedReturnsSnackbar(
|
||||
_fechaHora(
|
||||
l10n,
|
||||
actualizada!.proximaProgramable!,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
const Spacer(),
|
||||
IconButton(
|
||||
tooltip: l10n.deleteAction,
|
||||
icon: const Icon(Icons.delete_outline_rounded),
|
||||
onPressed: () => estado.eliminarAlarma(alarma.id),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String? _mensajeVacaciones(
|
||||
AppLocalizations l10n,
|
||||
List<RangoVacaciones> vacaciones,
|
||||
) {
|
||||
if (alarma.sonarEnVacaciones) return null;
|
||||
final ahora = DateTime.now();
|
||||
RangoVacaciones? actual;
|
||||
for (final rango in vacaciones) {
|
||||
if (rango.contiene(ahora)) {
|
||||
actual = rango;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (actual != null) {
|
||||
if (alarma.proximaProgramable == null) {
|
||||
return l10n.alarmVacationPausedNoNext(
|
||||
_nombreVisibleVacaciones(l10n, actual),
|
||||
);
|
||||
}
|
||||
return l10n.alarmVacationPausedReturns(
|
||||
_nombreVisibleVacaciones(l10n, actual),
|
||||
_fechaHora(l10n, alarma.proximaProgramable!),
|
||||
);
|
||||
}
|
||||
if (alarma.proximaProgramable != null) {
|
||||
return l10n.alarmVacationReturns(
|
||||
_fechaHora(l10n, alarma.proximaProgramable!),
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
void _abrirEditor(BuildContext context, {required AlarmaMusical alarma}) {
|
||||
showModalBottomSheet<void>(
|
||||
context: context,
|
||||
@@ -336,6 +264,56 @@ class _TarjetaAlarma extends StatelessWidget {
|
||||
builder: (_) => _EditorAlarmaSheet(alarma: alarma),
|
||||
);
|
||||
}
|
||||
|
||||
Future<bool> _confirmarEliminarAlarma(
|
||||
BuildContext context,
|
||||
AppLocalizations l10n,
|
||||
) async {
|
||||
final confirmado = await showDialog<bool>(
|
||||
context: context,
|
||||
builder:
|
||||
(ctx) => AlertDialog(
|
||||
title: Text(l10n.alarmDeleteConfirmTitle),
|
||||
content: Text(l10n.alarmDeleteConfirmMessage),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx, false),
|
||||
child: Text(l10n.cancelAction),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.pop(ctx, true),
|
||||
child: Text(l10n.deleteAction),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
return confirmado ?? false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Swipe-to-delete reveal, shown on both sides so either swipe direction
|
||||
/// works regardless of locale text direction.
|
||||
class _FondoSwipeEliminarAlarma extends StatelessWidget {
|
||||
const _FondoSwipeEliminarAlarma({required this.alignment});
|
||||
|
||||
final Alignment alignment;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tokens = context.pluriTokens;
|
||||
return Container(
|
||||
alignment: alignment,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.error,
|
||||
borderRadius: BorderRadius.circular(tokens.radiusMd),
|
||||
),
|
||||
child: Icon(
|
||||
Icons.delete_outline_rounded,
|
||||
color: Theme.of(context).colorScheme.onError,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _EditorAlarmaSheet extends StatefulWidget {
|
||||
@@ -1009,6 +987,12 @@ class _AccesoDiagnostico extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
/// Vacation summary row (alarm-vacation-ranges delta, WU8): replaces the old
|
||||
/// always-inline range list with a tap target showing range count + next-
|
||||
/// range countdown, pushing the Vacaciones manager screen. WU9 owns the
|
||||
/// destination screen's real content (`EstadoAlarmas` query additions per
|
||||
/// design ADR-6); this row's tap target is a temporary placeholder until then
|
||||
/// (see `_PantallaVacacionesTemporal` below).
|
||||
class _PanelVacaciones extends StatelessWidget {
|
||||
const _PanelVacaciones({required this.estado});
|
||||
|
||||
@@ -1017,194 +1001,84 @@ class _PanelVacaciones extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final vacaciones = [...estado.vacaciones]
|
||||
..sort((a, b) => a.inicioDia.compareTo(b.inicioDia));
|
||||
final tokens = context.pluriTokens;
|
||||
final resumen = _resumenVacaciones(l10n, estado.vacaciones);
|
||||
return PluriGlassSurface(
|
||||
glowColor: PluriWaveTokens.skyBlue.withValues(alpha: 0.22),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
_AssetIcon(
|
||||
'assets/icons/alarmas/vacation_wave.png',
|
||||
size: 48,
|
||||
semanticLabel: l10n.vacationIconLabel,
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Text(
|
||||
l10n.vacationRangesTitle,
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w900,
|
||||
),
|
||||
),
|
||||
),
|
||||
FilledButton.tonalIcon(
|
||||
onPressed: () => _abrirAlta(context),
|
||||
icon: const Icon(Icons.add_rounded),
|
||||
label: Text(l10n.addAction),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(l10n.vacationRangesHint),
|
||||
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(
|
||||
padding: EdgeInsets.zero,
|
||||
child: Material(
|
||||
type: MaterialType.transparency,
|
||||
child: InkWell(
|
||||
key: const ValueKey('vacaciones-resumen'),
|
||||
borderRadius: BorderRadius.circular(tokens.radiusMd),
|
||||
onTap: () => _abrirVacaciones(context),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _PickerButton(
|
||||
icon: Icons.play_arrow_rounded,
|
||||
label: l10n.startLabel,
|
||||
value: _fechaCorta(l10n, _inicio),
|
||||
onTap: () => _elegirFecha(esInicio: true),
|
||||
),
|
||||
_AssetIcon(
|
||||
'assets/icons/alarmas/vacation_wave.png',
|
||||
size: 48,
|
||||
semanticLabel: l10n.vacationIconLabel,
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: _PickerButton(
|
||||
icon: Icons.stop_rounded,
|
||||
label: l10n.endLabel,
|
||||
value: _fechaCorta(l10n, _fin),
|
||||
onTap: () => _elegirFecha(esInicio: false),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
l10n.vacationRangesTitle,
|
||||
style: Theme.of(context).textTheme.titleMedium
|
||||
?.copyWith(fontWeight: FontWeight.w900),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(resumen),
|
||||
],
|
||||
),
|
||||
),
|
||||
const Icon(Icons.chevron_right_rounded),
|
||||
],
|
||||
),
|
||||
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;
|
||||
}
|
||||
});
|
||||
void _abrirVacaciones(BuildContext context) {
|
||||
PluriPushScaffold.push(context, (_) => const PantallaVacaciones());
|
||||
}
|
||||
|
||||
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);
|
||||
/// Range count + next-range countdown, computed directly over the existing
|
||||
/// `estado.vacaciones` (no new `EstadoAlarmas` query method — those are
|
||||
/// design ADR-6's pure additions, owned by WU9).
|
||||
String _resumenVacaciones(
|
||||
AppLocalizations l10n,
|
||||
List<RangoVacaciones> vacaciones,
|
||||
) {
|
||||
if (vacaciones.isEmpty) return l10n.noVacationRangesLoaded;
|
||||
final ahora = DateTime.now();
|
||||
final hoy = DateTime(ahora.year, ahora.month, ahora.day);
|
||||
final conteo = l10n.vacationRangesCount(vacaciones.length);
|
||||
RangoVacaciones? activo;
|
||||
for (final rango in vacaciones) {
|
||||
if (rango.contiene(ahora)) {
|
||||
activo = rango;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (activo != null) {
|
||||
final dias = activo.finDia.difference(hoy).inDays;
|
||||
return '$conteo · ${l10n.vacationSummaryActiveCountdown(dias)}';
|
||||
}
|
||||
final futuros =
|
||||
vacaciones.where((rango) => rango.inicioDia.isAfter(hoy)).toList()
|
||||
..sort((a, b) => a.inicioDia.compareTo(b.inicioDia));
|
||||
if (futuros.isNotEmpty) {
|
||||
final dias = futuros.first.inicioDia.difference(hoy).inDays;
|
||||
return '$conteo · ${l10n.vacationSummaryUpcomingCountdown(dias)}';
|
||||
}
|
||||
return conteo;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1285,18 +1159,6 @@ class _SectionLabel extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
class _InfoChip extends StatelessWidget {
|
||||
const _InfoChip({required this.icon, required this.label});
|
||||
|
||||
final IconData icon;
|
||||
final String label;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Chip(avatar: Icon(icon, size: 16), label: Text(label));
|
||||
}
|
||||
}
|
||||
|
||||
class _NoticeLine extends StatelessWidget {
|
||||
const _NoticeLine({super.key, required this.icon, required this.text});
|
||||
|
||||
@@ -1344,25 +1206,9 @@ 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')}';
|
||||
|
||||
String _programacion(AppLocalizations l10n, AlarmaMusical alarma) {
|
||||
return switch (alarma.tipoProgramacion) {
|
||||
TipoProgramacionAlarma.unica => l10n.alarmScheduleOnce(
|
||||
_fechaCorta(l10n, alarma.fechaUnica ?? DateTime.now()),
|
||||
),
|
||||
TipoProgramacionAlarma.diaria => l10n.dailyOption,
|
||||
TipoProgramacionAlarma.diasSemana => l10n.alarmScheduleWeekdays(
|
||||
alarma.diasSemana.map((day) => _weekdayShort(l10n, day)).join(', '),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
String _fechaHora(AppLocalizations l10n, DateTime fecha) =>
|
||||
l10n.dateTimeSentence(fecha);
|
||||
|
||||
|
||||
@@ -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),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -502,21 +502,46 @@ Calls Unchanged by Transport Extraction
|
||||
**Modified tests**: `pantalla_alarmas_editor_test.dart` (+ new card-gesture scenarios); `estado_alarmas_test.dart`,
|
||||
`estado_alarmas_snooze_test.dart` must pass unmodified
|
||||
|
||||
- [ ] 8.1 RED — tapping a simplified alarm card (giant time + station + switch) opens the editor pre-filled;
|
||||
**`size:exception` — realized diff exceeded the ~500-line threshold.** Commit `9a2eb57`: **1,113 changed lines**
|
||||
(876+/237-) across 19 files. Breakdown: `pantalla_alarmas.dart` 485, ARB sources 54 (27 en + 27 es), 13 regenerated
|
||||
`lib/l10n/gen/*.dart` files ~300, new/modified test file 189, this docs file 37 — the same "a strict-TDD commit
|
||||
carries its test files, and any ARB touch drags 13 generated files with it" pattern as WU3a/WU7/WU15 (Engram
|
||||
`reference/estimating-strict-tdd-diffs`, id 2514). Justification per the forecast table's own footnote: "largest
|
||||
single alarm-card + hero + vacation-summary restyle, not divisible without breaking the one-commit-per-work-unit
|
||||
rule" — the card simplification, hero skip pill, and vacation summary row are one cohesive visual/behavioral change
|
||||
to one screen file; splitting them into separate commits would leave an inconsistent intermediate UI (e.g. a hero
|
||||
with a skip pill but cards still showing the old button row).
|
||||
|
||||
- [x] 8.1 RED — tapping a simplified alarm card (giant time + station + switch) opens the editor pre-filled;
|
||||
swiping triggers delete with confirmation; no capability from the old always-visible button row is lost.
|
||||
- [ ] 8.2 RED — the hero banner's inline "Saltar" action skips the featured (soonest-firing) alarm, consistent with
|
||||
- [x] 8.2 RED — the hero banner's inline "Saltar" action skips the featured (soonest-firing) alarm, consistent with
|
||||
existing skip-next behaviour.
|
||||
- [ ] 8.3 RED — the vacation panel is now a summary row (range count + next-range countdown) that pushes a screen
|
||||
- [x] 8.3 RED — the vacation panel is now a summary row (range count + next-range countdown) that pushes a screen
|
||||
on tap (destination screen not yet built — assert `PluriPushScaffold.push` is invoked; WU9 builds it).
|
||||
- [ ] 8.4 GREEN — simplify `_TarjetaAlarma` to giant time/name/switch; move Edit/Skip/Delete behind tap/swipe.
|
||||
- [ ] 8.5 GREEN — add the hero banner's inline "Saltar" pill calling the existing skip-next path.
|
||||
- [ ] 8.6 GREEN — replace `_PanelVacaciones`'s inline list with a summary row wired to push the Vacaciones manager
|
||||
screen.
|
||||
- [ ] 8.7 REFACTOR — confirm no scheduling/dismiss-guard code path was touched; `estado_alarmas_test.dart` and
|
||||
`estado_alarmas_snooze_test.dart` pass unmodified.
|
||||
- [ ] 8.8 Verify — tap/swipe/hero-skip scenarios all green; confirm
|
||||
- [x] 8.4 GREEN — simplify `_TarjetaAlarma` to giant time/name/switch; move Edit/Skip/Delete behind tap/swipe.
|
||||
**Corrected at apply time**: proposal.md's own WU8 row says "giant time + station + switch" (not "name") —
|
||||
the card shows the STATION (or `noStationUseInternalSound`), not the alarm's custom name; the name stays
|
||||
editable inside the editor sheet, unchanged. Tap-to-edit and swipe-to-delete-with-confirmation implemented via
|
||||
`Dismissible` (`confirmDismiss` shows an `AlertDialog`, `onDismissed` calls the existing `eliminarAlarma`) plus
|
||||
an inner `Material(type: MaterialType.transparency) > InkWell` (the known `PluriGlassSurface`-ink-splash hazard
|
||||
from WU7, pre-empted here rather than rediscovered). All 4 old per-card chips (repeat/vacation/volume/fade) and
|
||||
notice lines (next-execution/skipped/vacation-paused) are dropped from the collapsed card per "minimal cards"
|
||||
— still reachable via the editor once opened; only the hard-guarded trio (edit/skip/delete) is a capability
|
||||
that must never be lost, and it isn't.
|
||||
- [x] 8.5 GREEN — add the hero banner's inline "Saltar" pill calling the existing skip-next path.
|
||||
- [x] 8.6 GREEN — replace `_PanelVacaciones`'s inline list with a summary row wired to push the Vacaciones manager
|
||||
screen. **Design decision, not specified by any ADR (WU8 has none)**: the summary row's "range count +
|
||||
next-range countdown" is computed directly over the existing `estado.vacaciones` (no new `EstadoAlarmas` query
|
||||
method — ADR-6's pure query additions are WU9's job). The push target is `_PantallaVacacionesTemporal`, a
|
||||
private placeholder holding the OLD inline panel's body verbatim (hint text, "Add" action, per-range delete) —
|
||||
same "keep the real action, drop only the header" rule WU3a/WU3b established — so add/delete-range capability
|
||||
is never dropped, not even for one commit, and WU9 replaces this whole widget with the real `PantallaVacaciones`.
|
||||
- [x] 8.7 REFACTOR — confirm no scheduling/dismiss-guard code path was touched; `estado_alarmas_test.dart` and
|
||||
`estado_alarmas_snooze_test.dart` pass unmodified. Also removed now-dead code the simplification orphaned:
|
||||
the `_InfoChip` widget and the `_programacion` helper (both were only reachable from the old chips row).
|
||||
- [x] 8.8 Verify — tap/swipe/hero-skip scenarios all green; confirmed
|
||||
`pantalla_alarma_sonando_dismiss_guard_test.dart` was not touched by this WU (it only edits the root list
|
||||
screen).
|
||||
screen) — re-ran it alongside the scoped suite, byte-identical, all 8 cases green.
|
||||
|
||||
## WU9 — Vacaciones manager (new screen)
|
||||
|
||||
@@ -525,26 +550,58 @@ Calls Unchanged by Transport Extraction
|
||||
**Spec refs**: `alarm-vacation-ranges` — Active Range Detection, Per-Alarm Pause-Impact Computation, Upcoming Ranges
|
||||
Query, Past-Ranges History Query, Vacation Summary Row Replaces the Inline Panel (destination screen)
|
||||
**Verify**: `flutter test test/estado/estado_alarmas_test.dart test/pantallas/pantalla_vacaciones_test.dart && flutter analyze && dart format --set-exit-if-changed $(git diff --name-only --diff-filter=ACM HEAD -- '*.dart')`
|
||||
|
||||
**`size:exception` — realized diff exceeded the ~350-450 forecast.** Commit `9dfcf0b`: **1,822 changed lines**
|
||||
(1,432+/390-) across 23 files. Breakdown: `estado_alarmas_test.dart` 558 (**mostly `dart format` reformatting
|
||||
pre-existing, unrelated test blocks it happened to touch** — the same Dart-SDK-3.12.0 skew as Engram
|
||||
`reference/dart-format-scope-hazard` id 2511, here as an unavoidable side effect of the mandatory scoped-format
|
||||
gate on a file this WU legitimately edits, not new test logic; the actual new WU9 tests are ~215 of those lines),
|
||||
new `pantalla_vacaciones.dart` 353, 14 regenerated `lib/l10n/gen/*.dart` files 334, new `pantalla_vacaciones_test.dart`
|
||||
224, `pantalla_alarmas.dart` 197 (net code MOVED OUT to the new screen file, not new logic), `estado_alarmas.dart`
|
||||
63, `alarma_musical.dart` 14, ARB sources 32, this docs file 47. Same "test files + generated l10n + reformatting
|
||||
of touched files aren't counted by a lib/-only estimate" pattern as every prior WU (Engram
|
||||
`reference/estimating-strict-tdd-diffs`, id 2514). Justification: the 4 query methods, the model addition, and the
|
||||
new screen are one cohesive vertical slice (state + model + UI) that WU8 explicitly deferred to this commit;
|
||||
splitting further would leave either dead query methods with no UI consumer or a UI screen with no queries to call.
|
||||
**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
|
||||
|
||||
|
||||
@@ -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<void>();
|
||||
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<void>();
|
||||
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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import 'package:pluriwave/l10n/gen/app_localizations.dart';
|
||||
import 'package:pluriwave/modelos/alarma_musical.dart';
|
||||
import 'package:pluriwave/pantallas/pantalla_alarmas.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';
|
||||
|
||||
@@ -18,6 +19,80 @@ class _Entorno {
|
||||
final EstadoAlarmas estadoAlarmas;
|
||||
}
|
||||
|
||||
class _EntornoDosAlarmas {
|
||||
_EntornoDosAlarmas({required this.estadoAlarmas, required this.android});
|
||||
|
||||
final EstadoAlarmas estadoAlarmas;
|
||||
final FakePuertoAlarmasAndroid android;
|
||||
}
|
||||
|
||||
/// WU8 fixture: two DAILY alarms ('r1' 07:00, 'r2' 08:00) under a FIXED clock
|
||||
/// (06:00, before both) so 'r1' is deterministically the featured/soonest
|
||||
/// alarm regardless of the wall-clock time the suite happens to run at.
|
||||
Future<_EntornoDosAlarmas> _montarDosAlarmas(WidgetTester tester) async {
|
||||
tester.view.physicalSize = const Size(1440, 3200);
|
||||
tester.view.devicePixelRatio = 1.0;
|
||||
addTearDown(tester.view.resetPhysicalSize);
|
||||
addTearDown(tester.view.resetDevicePixelRatio);
|
||||
|
||||
final radio = EstadoRadio(
|
||||
audio: FakeServicioAudio(),
|
||||
favoritos: FakeServicioFavoritos(),
|
||||
radio: FakeServicioRadio(),
|
||||
servicioEcualizador: FakeServicioEcualizador(),
|
||||
servicioGrabacion: FakeServicioGrabacionRadioInactiva(),
|
||||
iniciarAutomaticamente: false,
|
||||
);
|
||||
addTearDown(radio.dispose);
|
||||
|
||||
final android = FakePuertoAlarmasAndroid();
|
||||
final estadoAlarmas = EstadoAlarmas(
|
||||
servicio: ServicioAlarmas(reloj: () => DateTime(2026, 6, 1, 6, 0)),
|
||||
android: android,
|
||||
iniciarAutomaticamente: false,
|
||||
);
|
||||
addTearDown(estadoAlarmas.dispose);
|
||||
addTearDown(android.dispose);
|
||||
await estadoAlarmas.guardarAlarma(
|
||||
const AlarmaMusical(
|
||||
id: 'r1',
|
||||
nombre: 'Primera',
|
||||
hora: 7,
|
||||
minuto: 0,
|
||||
tipoProgramacion: TipoProgramacionAlarma.diaria,
|
||||
diasSemana: [],
|
||||
),
|
||||
);
|
||||
await estadoAlarmas.guardarAlarma(
|
||||
const AlarmaMusical(
|
||||
id: 'r2',
|
||||
nombre: 'Segunda',
|
||||
hora: 8,
|
||||
minuto: 0,
|
||||
tipoProgramacion: TipoProgramacionAlarma.diaria,
|
||||
diasSemana: [],
|
||||
),
|
||||
);
|
||||
|
||||
await tester.pumpWidget(
|
||||
MultiProvider(
|
||||
providers: [
|
||||
ChangeNotifierProvider<EstadoRadio>.value(value: radio),
|
||||
ChangeNotifierProvider<EstadoAlarmas>.value(value: estadoAlarmas),
|
||||
],
|
||||
child: MaterialApp(
|
||||
locale: const Locale('es'),
|
||||
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||
supportedLocales: AppLocalizations.supportedLocales,
|
||||
home: const Scaffold(body: PantallaAlarmas()),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
return _EntornoDosAlarmas(estadoAlarmas: estadoAlarmas, android: android);
|
||||
}
|
||||
|
||||
Future<_Entorno> _abrirEditor(WidgetTester tester) async {
|
||||
tester.view.physicalSize = const Size(1440, 3200);
|
||||
tester.view.devicePixelRatio = 1.0;
|
||||
@@ -73,10 +148,12 @@ Future<_Entorno> _abrirEditor(WidgetTester tester) async {
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final l10n = lookupAppLocalizations(const Locale('es'));
|
||||
await tester.ensureVisible(find.text(l10n.editAction).first);
|
||||
// WU8: the always-visible "Edit" button is gone — tapping the simplified
|
||||
// card itself now opens the editor.
|
||||
final tarjeta = find.byKey(const ValueKey('tarjeta-alarma-ed1'));
|
||||
await tester.ensureVisible(tarjeta);
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text(l10n.editAction).first);
|
||||
await tester.tap(tarjeta);
|
||||
await tester.pumpAndSettle();
|
||||
return _Entorno(estadoAlarmas: estadoAlarmas);
|
||||
}
|
||||
@@ -207,4 +284,110 @@ void main() {
|
||||
final volumen = sliders.firstWhere((slider) => slider.max == 1.0);
|
||||
expect(volumen.min, 0.0);
|
||||
});
|
||||
|
||||
group('WU8 — tarjeta de alarma simplificada', () {
|
||||
testWidgets(
|
||||
'tocar la tarjeta abre el editor precargado con los datos de esa '
|
||||
'alarma',
|
||||
(tester) async {
|
||||
await _montarDosAlarmas(tester);
|
||||
|
||||
await tester.tap(find.byKey(const ValueKey('tarjeta-alarma-r2')));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text(l10n.editAlarmTitle), findsOneWidget);
|
||||
expect(find.text('Segunda'), findsOneWidget);
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets('deslizar la tarjeta pide confirmacion; cancelar la conserva y '
|
||||
'confirmar la elimina', (tester) async {
|
||||
final entorno = await _montarDosAlarmas(tester);
|
||||
|
||||
// Cancelar: la tarjeta se conserva.
|
||||
await tester.drag(
|
||||
find.byKey(const ValueKey('tarjeta-alarma-r2')),
|
||||
const Offset(-600, 0),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text(l10n.alarmDeleteConfirmTitle), findsOneWidget);
|
||||
await tester.tap(find.text(l10n.cancelAction));
|
||||
await tester.pumpAndSettle();
|
||||
expect(entorno.estadoAlarmas.alarmas.length, 2);
|
||||
expect(find.byKey(const ValueKey('tarjeta-alarma-r2')), findsOneWidget);
|
||||
|
||||
// Confirmar: la tarjeta se elimina.
|
||||
await tester.drag(
|
||||
find.byKey(const ValueKey('tarjeta-alarma-r2')),
|
||||
const Offset(-600, 0),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text(l10n.alarmDeleteConfirmTitle), findsOneWidget);
|
||||
await tester.tap(find.text(l10n.deleteAction));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(entorno.estadoAlarmas.alarmas.length, 1);
|
||||
expect(find.byKey(const ValueKey('tarjeta-alarma-r2')), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets(
|
||||
'el banner destacado permite saltar la alarma mas proxima (accion '
|
||||
'"Saltar")',
|
||||
(tester) async {
|
||||
final entorno = await _montarDosAlarmas(tester);
|
||||
final proximaAntes = entorno.estadoAlarmas.proximaAlarma;
|
||||
expect(proximaAntes?.id, 'r1');
|
||||
|
||||
await tester.tap(find.byKey(const ValueKey('hero-skip-next')));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byType(SnackBar), findsOneWidget);
|
||||
final r1 = entorno.estadoAlarmas.alarmas.firstWhere(
|
||||
(a) => a.id == 'r1',
|
||||
);
|
||||
expect(r1.proximaEjecucion, isNot(proximaAntes!.proximaEjecucion));
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets('la fila resumen de vacaciones empuja una pantalla al tocarla '
|
||||
'(destino provisorio; WU9 lo reemplaza)', (tester) async {
|
||||
await _montarDosAlarmas(tester);
|
||||
|
||||
await tester.tap(find.byKey(const ValueKey('vacaciones-resumen')));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byType(PluriPushScaffold), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets(
|
||||
'guardia de capacidad: editar (tap), saltar (hero) y eliminar (swipe) '
|
||||
'siguen siendo alcanzables tras la simplificacion de la tarjeta',
|
||||
(tester) async {
|
||||
final entorno = await _montarDosAlarmas(tester);
|
||||
|
||||
// 1) Saltar, desde el banner destacado.
|
||||
await tester.tap(find.byKey(const ValueKey('hero-skip-next')));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.byType(SnackBar), findsOneWidget);
|
||||
|
||||
// 2) Editar, tocando la tarjeta.
|
||||
await tester.tap(find.byKey(const ValueKey('tarjeta-alarma-r2')));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text(l10n.editAlarmTitle), findsOneWidget);
|
||||
await tester.tap(find.byIcon(Icons.close_rounded));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// 3) Eliminar, deslizando y confirmando.
|
||||
await tester.drag(
|
||||
find.byKey(const ValueKey('tarjeta-alarma-r2')),
|
||||
const Offset(-600, 0),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text(l10n.deleteAction));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(entorno.estadoAlarmas.alarmas.length, 1);
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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