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.
354 lines
11 KiB
Dart
354 lines
11 KiB
Dart
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),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|