feat(vacaciones): add vacation range manager screen

Add the Vacaciones manager screen per design ADR-6: an active-range
hero (name, days-remaining countdown, determinate progress bar, and a
per-alarm pause-impact line), a "PROGRAMADOS" upcoming-ranges list, an
"Add range" CTA, and a "Rangos pasados" history section. This is the
real destination WU8's Alarmas-root summary row pushes to, replacing
WU8's own temporary placeholder (_PantallaVacacionesTemporal, now
deleted).

EstadoAlarmas gains 4 pure query methods (rangoVacacionesActivo,
vacacionesProximas, vacacionesPasadas, impactoDeRango) -- read-only
over _alarmas/_vacaciones, no writes, no rescheduling, no native
bridge calls. impactoDeRango mirrors ServicioProgramacionAlarmas's own
pause predicate exactly, so the screen never disagrees with the
scheduler about which alarms are paused. ImpactoVacaciones joins
RangoVacaciones in alarma_musical.dart.

The add-range form (_EditorVacacionesSheet, _PickerButton) moved
verbatim from pantalla_alarmas.dart to its one remaining consumer.
estado_alarmas.dart's scheduling/snooze paths and the ringing screen's
dismiss guard are untouched; both test files pass unmodified.

New ARB keys (en/es only; other 11 locales are WU18's job):
vacationImpact{Paused,Continues}Label, vacationUpcomingSectionTitle,
vacationPastSectionTitle, addVacationRangeCta,
vacationNoActiveRangeHint.
This commit is contained in:
2026-07-29 10:49:51 +02:00
parent a09d614f52
commit 9dfcf0b428
23 changed files with 1432 additions and 390 deletions
+63
View File
@@ -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()
+16
View File
@@ -535,6 +535,22 @@
}
}
},
"vacationImpactPausedLabel": "Paused: {times}",
"@vacationImpactPausedLabel": {
"placeholders": {
"times": {}
}
},
"vacationImpactContinuesLabel": "Still ringing: {times}",
"@vacationImpactContinuesLabel": {
"placeholders": {
"times": {}
}
},
"vacationUpcomingSectionTitle": "SCHEDULED",
"vacationPastSectionTitle": "Past ranges",
"addVacationRangeCta": "Add range",
"vacationNoActiveRangeHint": "No active vacation range right now.",
"vacationsDefaultName": "Vacation",
"newVacationRangeTitle": "New vacation range",
"startField": "Start",
+16
View File
@@ -535,6 +535,22 @@
}
}
},
"vacationImpactPausedLabel": "Pausa: {times}",
"@vacationImpactPausedLabel": {
"placeholders": {
"times": {}
}
},
"vacationImpactContinuesLabel": "Sigue sonando: {times}",
"@vacationImpactContinuesLabel": {
"placeholders": {
"times": {}
}
},
"vacationUpcomingSectionTitle": "PROGRAMADOS",
"vacationPastSectionTitle": "Rangos pasados",
"addVacationRangeCta": "Añadir rango",
"vacationNoActiveRangeHint": "No hay un rango de vacaciones activo ahora mismo.",
"vacationsDefaultName": "Vacaciones",
"newVacationRangeTitle": "Nuevo rango de vacaciones",
"startField": "Inicio",
+36
View File
@@ -1874,6 +1874,42 @@ abstract class AppLocalizations {
/// **'Próximo rango en {days} días'**
String vacationSummaryUpcomingCountdown(int days);
/// No description provided for @vacationImpactPausedLabel.
///
/// In es, this message translates to:
/// **'Pausa: {times}'**
String vacationImpactPausedLabel(Object times);
/// No description provided for @vacationImpactContinuesLabel.
///
/// In es, this message translates to:
/// **'Sigue sonando: {times}'**
String vacationImpactContinuesLabel(Object times);
/// No description provided for @vacationUpcomingSectionTitle.
///
/// In es, this message translates to:
/// **'PROGRAMADOS'**
String get vacationUpcomingSectionTitle;
/// No description provided for @vacationPastSectionTitle.
///
/// In es, this message translates to:
/// **'Rangos pasados'**
String get vacationPastSectionTitle;
/// No description provided for @addVacationRangeCta.
///
/// In es, this message translates to:
/// **'Añadir rango'**
String get addVacationRangeCta;
/// No description provided for @vacationNoActiveRangeHint.
///
/// In es, this message translates to:
/// **'No hay un rango de vacaciones activo ahora mismo.'**
String get vacationNoActiveRangeHint;
/// No description provided for @vacationsDefaultName.
///
/// In es, this message translates to:
+23
View File
@@ -1018,6 +1018,29 @@ class AppLocalizationsAr extends AppLocalizations {
return 'Próximo rango en $days días';
}
@override
String vacationImpactPausedLabel(Object times) {
return 'Pausa: $times';
}
@override
String vacationImpactContinuesLabel(Object times) {
return 'Sigue sonando: $times';
}
@override
String get vacationUpcomingSectionTitle => 'PROGRAMADOS';
@override
String get vacationPastSectionTitle => 'Rangos pasados';
@override
String get addVacationRangeCta => 'Añadir rango';
@override
String get vacationNoActiveRangeHint =>
'No hay un rango de vacaciones activo ahora mismo.';
@override
String get vacationsDefaultName => 'إجازات';
+23
View File
@@ -1025,6 +1025,29 @@ class AppLocalizationsBn extends AppLocalizations {
return 'Próximo rango en $days días';
}
@override
String vacationImpactPausedLabel(Object times) {
return 'Pausa: $times';
}
@override
String vacationImpactContinuesLabel(Object times) {
return 'Sigue sonando: $times';
}
@override
String get vacationUpcomingSectionTitle => 'PROGRAMADOS';
@override
String get vacationPastSectionTitle => 'Rangos pasados';
@override
String get addVacationRangeCta => 'Añadir rango';
@override
String get vacationNoActiveRangeHint =>
'No hay un rango de vacaciones activo ahora mismo.';
@override
String get vacationsDefaultName => 'ছুটি';
+23
View File
@@ -1026,6 +1026,29 @@ class AppLocalizationsDe extends AppLocalizations {
return 'Próximo rango en $days días';
}
@override
String vacationImpactPausedLabel(Object times) {
return 'Pausa: $times';
}
@override
String vacationImpactContinuesLabel(Object times) {
return 'Sigue sonando: $times';
}
@override
String get vacationUpcomingSectionTitle => 'PROGRAMADOS';
@override
String get vacationPastSectionTitle => 'Rangos pasados';
@override
String get addVacationRangeCta => 'Añadir rango';
@override
String get vacationNoActiveRangeHint =>
'No hay un rango de vacaciones activo ahora mismo.';
@override
String get vacationsDefaultName => 'Ferien';
+22
View File
@@ -1020,6 +1020,28 @@ class AppLocalizationsEn extends AppLocalizations {
return 'Next range in $days days';
}
@override
String vacationImpactPausedLabel(Object times) {
return 'Paused: $times';
}
@override
String vacationImpactContinuesLabel(Object times) {
return 'Still ringing: $times';
}
@override
String get vacationUpcomingSectionTitle => 'SCHEDULED';
@override
String get vacationPastSectionTitle => 'Past ranges';
@override
String get addVacationRangeCta => 'Add range';
@override
String get vacationNoActiveRangeHint => 'No active vacation range right now.';
@override
String get vacationsDefaultName => 'Vacation';
+23
View File
@@ -1024,6 +1024,29 @@ class AppLocalizationsEs extends AppLocalizations {
return 'Próximo rango en $days días';
}
@override
String vacationImpactPausedLabel(Object times) {
return 'Pausa: $times';
}
@override
String vacationImpactContinuesLabel(Object times) {
return 'Sigue sonando: $times';
}
@override
String get vacationUpcomingSectionTitle => 'PROGRAMADOS';
@override
String get vacationPastSectionTitle => 'Rangos pasados';
@override
String get addVacationRangeCta => 'Añadir rango';
@override
String get vacationNoActiveRangeHint =>
'No hay un rango de vacaciones activo ahora mismo.';
@override
String get vacationsDefaultName => 'Vacaciones';
+23
View File
@@ -1030,6 +1030,29 @@ class AppLocalizationsFr extends AppLocalizations {
return 'Próximo rango en $days días';
}
@override
String vacationImpactPausedLabel(Object times) {
return 'Pausa: $times';
}
@override
String vacationImpactContinuesLabel(Object times) {
return 'Sigue sonando: $times';
}
@override
String get vacationUpcomingSectionTitle => 'PROGRAMADOS';
@override
String get vacationPastSectionTitle => 'Rangos pasados';
@override
String get addVacationRangeCta => 'Añadir rango';
@override
String get vacationNoActiveRangeHint =>
'No hay un rango de vacaciones activo ahora mismo.';
@override
String get vacationsDefaultName => 'Vacances';
+23
View File
@@ -1021,6 +1021,29 @@ class AppLocalizationsHi extends AppLocalizations {
return 'Próximo rango en $days días';
}
@override
String vacationImpactPausedLabel(Object times) {
return 'Pausa: $times';
}
@override
String vacationImpactContinuesLabel(Object times) {
return 'Sigue sonando: $times';
}
@override
String get vacationUpcomingSectionTitle => 'PROGRAMADOS';
@override
String get vacationPastSectionTitle => 'Rangos pasados';
@override
String get addVacationRangeCta => 'Añadir rango';
@override
String get vacationNoActiveRangeHint =>
'No hay un rango de vacaciones activo ahora mismo.';
@override
String get vacationsDefaultName => 'छुट्टियाँ';
+23
View File
@@ -1025,6 +1025,29 @@ class AppLocalizationsId extends AppLocalizations {
return 'Próximo rango en $days días';
}
@override
String vacationImpactPausedLabel(Object times) {
return 'Pausa: $times';
}
@override
String vacationImpactContinuesLabel(Object times) {
return 'Sigue sonando: $times';
}
@override
String get vacationUpcomingSectionTitle => 'PROGRAMADOS';
@override
String get vacationPastSectionTitle => 'Rangos pasados';
@override
String get addVacationRangeCta => 'Añadir rango';
@override
String get vacationNoActiveRangeHint =>
'No hay un rango de vacaciones activo ahora mismo.';
@override
String get vacationsDefaultName => 'Liburan';
+23
View File
@@ -1026,6 +1026,29 @@ class AppLocalizationsIt extends AppLocalizations {
return 'Próximo rango en $days días';
}
@override
String vacationImpactPausedLabel(Object times) {
return 'Pausa: $times';
}
@override
String vacationImpactContinuesLabel(Object times) {
return 'Sigue sonando: $times';
}
@override
String get vacationUpcomingSectionTitle => 'PROGRAMADOS';
@override
String get vacationPastSectionTitle => 'Rangos pasados';
@override
String get addVacationRangeCta => 'Añadir rango';
@override
String get vacationNoActiveRangeHint =>
'No hay un rango de vacaciones activo ahora mismo.';
@override
String get vacationsDefaultName => 'Vacanze';
+23
View File
@@ -994,6 +994,29 @@ class AppLocalizationsJa extends AppLocalizations {
return 'Próximo rango en $days días';
}
@override
String vacationImpactPausedLabel(Object times) {
return 'Pausa: $times';
}
@override
String vacationImpactContinuesLabel(Object times) {
return 'Sigue sonando: $times';
}
@override
String get vacationUpcomingSectionTitle => 'PROGRAMADOS';
@override
String get vacationPastSectionTitle => 'Rangos pasados';
@override
String get addVacationRangeCta => 'Añadir rango';
@override
String get vacationNoActiveRangeHint =>
'No hay un rango de vacaciones activo ahora mismo.';
@override
String get vacationsDefaultName => '休暇';
+23
View File
@@ -1023,6 +1023,29 @@ class AppLocalizationsPt extends AppLocalizations {
return 'Próximo rango en $days días';
}
@override
String vacationImpactPausedLabel(Object times) {
return 'Pausa: $times';
}
@override
String vacationImpactContinuesLabel(Object times) {
return 'Sigue sonando: $times';
}
@override
String get vacationUpcomingSectionTitle => 'PROGRAMADOS';
@override
String get vacationPastSectionTitle => 'Rangos pasados';
@override
String get addVacationRangeCta => 'Añadir rango';
@override
String get vacationNoActiveRangeHint =>
'No hay un rango de vacaciones activo ahora mismo.';
@override
String get vacationsDefaultName => 'Férias';
+23
View File
@@ -1025,6 +1025,29 @@ class AppLocalizationsRu extends AppLocalizations {
return 'Próximo rango en $days días';
}
@override
String vacationImpactPausedLabel(Object times) {
return 'Pausa: $times';
}
@override
String vacationImpactContinuesLabel(Object times) {
return 'Sigue sonando: $times';
}
@override
String get vacationUpcomingSectionTitle => 'PROGRAMADOS';
@override
String get vacationPastSectionTitle => 'Rangos pasados';
@override
String get addVacationRangeCta => 'Añadir rango';
@override
String get vacationNoActiveRangeHint =>
'No hay un rango de vacaciones activo ahora mismo.';
@override
String get vacationsDefaultName => 'Отпуск';
+23
View File
@@ -990,6 +990,29 @@ class AppLocalizationsZh extends AppLocalizations {
return 'Próximo rango en $days días';
}
@override
String vacationImpactPausedLabel(Object times) {
return 'Pausa: $times';
}
@override
String vacationImpactContinuesLabel(Object times) {
return 'Sigue sonando: $times';
}
@override
String get vacationUpcomingSectionTitle => 'PROGRAMADOS';
@override
String get vacationPastSectionTitle => 'Rangos pasados';
@override
String get addVacationRangeCta => 'Añadir rango';
@override
String get vacationNoActiveRangeHint =>
'No hay un rango de vacaciones activo ahora mismo.';
@override
String get vacationsDefaultName => '假期';
+14
View File
@@ -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,
+2 -195
View File
@@ -17,6 +17,7 @@ import '../widgets/pluri_icon.dart';
import '../widgets/pluri_layout.dart';
import '../widgets/pluri_premium_widgets.dart';
import '../widgets/pluri_push_scaffold.dart';
import 'pantalla_vacaciones.dart';
class PantallaAlarmas extends StatelessWidget {
const PantallaAlarmas({super.key});
@@ -1045,7 +1046,7 @@ class _PanelVacaciones extends StatelessWidget {
}
void _abrirVacaciones(BuildContext context) {
PluriPushScaffold.push(context, (_) => const _PantallaVacacionesTemporal());
PluriPushScaffold.push(context, (_) => const PantallaVacaciones());
}
/// Range count + next-range countdown, computed directly over the existing
@@ -1081,196 +1082,6 @@ class _PanelVacaciones extends StatelessWidget {
}
}
/// Temporary push destination (WU8). WU9 replaces this whole widget with the
/// real `PantallaVacaciones` (active-range hero, per-alarm impact line,
/// upcoming/past sections) from `lib/pantallas/pantalla_vacaciones.dart`. The
/// body below is the OLD inline panel's content, moved verbatim — same
/// "keep the real action, drop only the header" rule WU3a/WU3b established —
/// so add/delete range capability is never dropped even for one commit.
class _PantallaVacacionesTemporal extends StatelessWidget {
const _PantallaVacacionesTemporal();
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final estado = context.watch<EstadoAlarmas>();
final vacaciones = [...estado.vacaciones]
..sort((a, b) => a.inicioDia.compareTo(b.inicioDia));
return PluriPushScaffold(
title: l10n.vacationRangesTitle,
body: ListView(
padding: PluriLayout.pageContentPadding,
children: [
Text(l10n.vacationRangesHint),
const SizedBox(height: 12),
Align(
alignment: Alignment.centerRight,
child: FilledButton.tonalIcon(
onPressed: () => _abrirAlta(context),
icon: const Icon(Icons.add_rounded),
label: Text(l10n.addAction),
),
),
const SizedBox(height: 12),
if (vacaciones.isEmpty)
Text(l10n.noVacationRangesLoaded)
else
for (final rango in vacaciones)
ListTile(
contentPadding: EdgeInsets.zero,
leading: const Icon(Icons.event_busy_rounded),
title: Text(_nombreVisibleVacaciones(l10n, rango)),
subtitle: Text(
'${_fechaCorta(l10n, rango.inicioDia)}${_fechaCorta(l10n, rango.finDia)}',
),
trailing: IconButton(
tooltip: l10n.deleteRangeTooltip,
onPressed: () => estado.eliminarRangoVacaciones(rango.id),
icon: const Icon(Icons.delete_outline_rounded),
),
),
],
),
);
}
Future<void> _abrirAlta(BuildContext context) async {
await showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
useSafeArea: true,
backgroundColor: Colors.transparent,
builder: (_) => const _EditorVacacionesSheet(),
);
}
}
class _EditorVacacionesSheet extends StatefulWidget {
const _EditorVacacionesSheet();
@override
State<_EditorVacacionesSheet> createState() => _EditorVacacionesSheetState();
}
class _EditorVacacionesSheetState extends State<_EditorVacacionesSheet> {
// Created lazily: AppLocalizations.of(context) cannot be read in
// initState (inherited-widget lookup assert in debug builds).
TextEditingController? _nombreController;
late DateTime _inicio;
late DateTime _fin;
@override
void initState() {
super.initState();
final hoy = DateTime.now();
_inicio = DateTime(hoy.year, hoy.month, hoy.day);
_fin = _inicio.add(const Duration(days: 2));
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
_nombreController ??= TextEditingController(
text: AppLocalizations.of(context).vacationsDefaultName,
);
}
@override
void dispose() {
_nombreController?.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final bottom = MediaQuery.of(context).viewInsets.bottom;
return Padding(
padding: EdgeInsets.fromLTRB(12, 12, 12, bottom + 12),
child: PluriGlassSurface(
borderRadius: BorderRadius.circular(28),
padding: const EdgeInsets.all(18),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
l10n.newVacationRangeTitle,
style: Theme.of(
context,
).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.w900),
),
const SizedBox(height: 12),
TextField(
controller: _nombreController,
decoration: InputDecoration(labelText: l10n.nameLabel),
),
const SizedBox(height: 12),
Row(
children: [
Expanded(
child: _PickerButton(
icon: Icons.play_arrow_rounded,
label: l10n.startLabel,
value: _fechaCorta(l10n, _inicio),
onTap: () => _elegirFecha(esInicio: true),
),
),
const SizedBox(width: 10),
Expanded(
child: _PickerButton(
icon: Icons.stop_rounded,
label: l10n.endLabel,
value: _fechaCorta(l10n, _fin),
onTap: () => _elegirFecha(esInicio: false),
),
),
],
),
const SizedBox(height: 16),
FilledButton.icon(
onPressed: _guardar,
icon: const Icon(Icons.check_rounded),
label: Text(l10n.saveRangeAction),
),
],
),
),
);
}
Future<void> _elegirFecha({required bool esInicio}) async {
final actual = esInicio ? _inicio : _fin;
final hoy = DateTime.now();
final seleccion = await showDatePicker(
context: context,
initialDate: actual,
firstDate: DateTime(hoy.year, hoy.month, hoy.day),
lastDate: hoy.add(const Duration(days: 1460)),
);
if (seleccion == null) return;
setState(() {
if (esInicio) {
_inicio = seleccion;
if (_fin.isBefore(_inicio)) _fin = _inicio;
} else {
_fin = seleccion;
}
});
}
Future<void> _guardar() async {
final estado = context.read<EstadoAlarmas>();
final rango = estado.servicio.crearRangoVacaciones(
inicio: _inicio,
fin: _fin,
nombre: _nombreController?.text.trim() ?? '',
);
await estado.crearRangoVacaciones(rango);
if (mounted) Navigator.pop(context);
}
}
class _AssetIcon extends StatelessWidget {
const _AssetIcon(this.asset, {this.size = 44, this.semanticLabel});
@@ -1395,10 +1206,6 @@ String _nombreVisibleAlarma(AppLocalizations l10n, AlarmaMusical alarma) {
return localizedAlarmName(l10n, alarma.nombre);
}
String _nombreVisibleVacaciones(AppLocalizations l10n, RangoVacaciones rango) {
return localizedVacationName(l10n, rango.nombre);
}
String _hora(AlarmaMusical alarma) =>
'${alarma.hora.toString().padLeft(2, '0')}:${alarma.minuto.toString().padLeft(2, '0')}';
+353
View File
@@ -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),
],
),
);
}
}