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:
@@ -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')}';
|
||||
|
||||
|
||||
Reference in New Issue
Block a user