Files
pluriwave/lib/pantallas/pantalla_vacaciones.dart
T
FreeTLab 597701f497 fix(alarmas): add a delete action to the vacation range edit sheet
The vacation edit sheet could save changes to an existing range but had
no way to remove it, forcing users back to the swipe-to-delete gesture
on the list. When editing (not creating) a range, the sheet now shows
an outlined delete action next to Save; it reuses the existing
confirmation dialog and EstadoAlarmas.eliminarRangoVacaciones exactly
as the swipe gesture already does, then pops on success.
2026-08-01 12:06:00 +02:00

979 lines
34 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 '../tema/pluriwave_tokens.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,
// Audit 9b.1 (t4:446): a solid brand-teal "Add" header action --
// the prototype's OWN mid-page CTA (audit 9b.6, still present below,
// now dashed) is a SECOND, additional entry point in the prototype,
// not a replacement for this one.
actions: [
Padding(
padding: const EdgeInsets.only(right: 8),
child: FilledButton.icon(
key: const ValueKey('vacation-add-header'),
style: FilledButton.styleFrom(
backgroundColor: PluriWaveTokens.brand,
foregroundColor: const Color(0xFF062126),
padding: const EdgeInsets.symmetric(horizontal: 13, vertical: 8),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(11),
),
textStyle: const TextStyle(
fontSize: 12.5,
fontWeight: FontWeight.w800,
),
),
onPressed: () => _abrirAlta(context),
icon: const Icon(Icons.add_rounded, size: 17),
label: Text(l10n.addAction),
),
),
],
body: ListView(
padding: PluriLayout.pageContentPadding,
children: [
// Audit 9b.2 (t4:448): the explanatory banner is ALWAYS visible
// -- never rendered anywhere before.
_BannerExplicativo(texto: l10n.vacationExplainerBanner),
const SizedBox(height: 18),
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),
// Audit 9b.6 (t4:487): a dashed border + `date_range` icon --
// was a solid `OutlinedButton` with an `add` glyph.
_CtaAnadirRango(onTap: () => _abrirAlta(context)),
const SizedBox(height: 14),
_SeccionRangosPasados(pasadas: pasadas),
],
),
);
}
Future<void> _abrirAlta(BuildContext context) =>
_abrirEditorVacaciones(context);
}
/// Issue 1 (feedback-pruebas): the ONE sheet-opener both the header/CTA
/// "create" entry points and every range's own "tap to edit" affordance call
/// -- passing [rango] switches the sheet from create to edit mode (mirrors
/// `pantalla_alarmas.dart`'s `_abrirEditor`/`_EditorAlarmaSheet` split).
Future<void> _abrirEditorVacaciones(
BuildContext context, {
RangoVacaciones? rango,
}) async {
await showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
useSafeArea: true,
backgroundColor: Colors.transparent,
builder: (_) => _EditorVacacionesSheet(rango: rango),
);
}
/// Issue 1 (feedback-pruebas): mirrors `pantalla_alarmas.dart`'s
/// `_confirmarEliminarAlarma` exactly -- same AlertDialog shape, same
/// generic delete/cancel actions, only the copy is vacation-specific.
Future<bool> _confirmarEliminarRango(
BuildContext context,
AppLocalizations l10n,
) async {
final confirmado = await showDialog<bool>(
context: context,
builder:
(ctx) => AlertDialog(
title: Text(l10n.vacationDeleteConfirmTitle),
content: Text(l10n.vacationDeleteConfirmMessage),
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, mirroring
/// `pantalla_alarmas.dart`'s `_FondoSwipeEliminarAlarma` -- duplicated
/// rather than shared, matching this codebase's own precedent for tiny
/// per-screen chrome (see this file's `_DashedBorderPainter` doc comment).
class _FondoSwipeEliminarRango extends StatelessWidget {
const _FondoSwipeEliminarRango({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,
),
);
}
}
/// Audit 9b.2 (t4:448): teal-tinted explainer banner, always visible above
/// the active-range hero.
class _BannerExplicativo extends StatelessWidget {
const _BannerExplicativo({required this.texto});
final String texto;
@override
Widget build(BuildContext context) {
final tokens = context.pluriTokens;
return DecoratedBox(
decoration: BoxDecoration(
color: tokens.liveGreen.withValues(alpha: 0.09),
borderRadius: BorderRadius.circular(16),
border: Border.all(color: tokens.liveGreen.withValues(alpha: 0.26)),
),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 15, vertical: 13),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(Icons.info_outline_rounded, size: 20, color: tokens.liveGreen),
const SizedBox(width: 11),
Expanded(
child: Text(
texto,
style: TextStyle(
fontSize: 12,
height: 1.5,
color: Theme.of(
context,
).colorScheme.onSurface.withValues(alpha: 0.72),
),
),
),
],
),
),
);
}
}
/// Audit 9b.6 (t4:487): dashed-border CTA with a `date_range` glyph --
/// reuses the same dashed-painter shape already established in
/// `pantalla_favoritos.dart`'s custom-station CTA (audit 4.5), duplicated
/// rather than shared (small, self-contained, matching this codebase's own
/// precedent for tiny per-screen painters).
class _CtaAnadirRango extends StatelessWidget {
const _CtaAnadirRango({required this.onTap});
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final colorTexto = Theme.of(
context,
).colorScheme.onSurface.withValues(alpha: 0.6);
return CustomPaint(
painter: _DashedBorderPainter(
color: Colors.white.withValues(alpha: 0.16),
),
child: Material(
color: Colors.transparent,
child: InkWell(
borderRadius: BorderRadius.circular(16),
onTap: onTap,
child: Padding(
padding: const EdgeInsets.all(15),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.date_range_rounded, size: 20, color: colorTexto),
const SizedBox(width: 8),
Text(
l10n.addVacationRangeCta,
style: TextStyle(
fontSize: 13.5,
fontWeight: FontWeight.w800,
color: colorTexto,
),
),
],
),
),
),
),
);
}
}
class _DashedBorderPainter extends CustomPainter {
const _DashedBorderPainter({required this.color});
final Color color;
static const _radius = 16.0;
static const _dashWidth = 6.0;
static const _gapWidth = 4.0;
@override
void paint(Canvas canvas, Size size) {
final rrect = RRect.fromRectAndRadius(
Offset.zero & size,
const Radius.circular(_radius),
);
final path = Path()..addRRect(rrect);
final paint =
Paint()
..color = color
..style = PaintingStyle.stroke
..strokeWidth = 1.5;
for (final metric in path.computeMetrics()) {
var distance = 0.0;
while (distance < metric.length) {
final next = distance + _dashWidth;
canvas.drawPath(
metric.extractPath(distance, next.clamp(0.0, metric.length)),
paint,
);
distance = next + _gapWidth;
}
}
}
@override
bool shouldRepaint(covariant _DashedBorderPainter oldDelegate) =>
oldDelegate.color != color;
}
/// Active-range hero: name, days-remaining countdown (reusing WU8's own
/// `vacationSummaryActiveCountdown` string — same concept, bigger stage),
/// a start/end date pair (item 21 / audit 9b.4, replacing the former
/// 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);
final type = context.pluriType;
final tokens = context.pluriTokens;
// Issue 1 (feedback-pruebas): a range starts ACTIVE the instant it's
// created (today .. today+2), so this hero is the ONLY place a
// brand-new range ever renders until it either becomes "programado" in
// the future or "pasado" once it ends. Without tap/swipe here, the
// very first range a user creates could never be fixed or removed.
return Dismissible(
key: ValueKey('vacaciones-tarjeta-${rango.id}'),
direction: DismissDirection.horizontal,
background: const _FondoSwipeEliminarRango(
alignment: Alignment.centerLeft,
),
secondaryBackground: const _FondoSwipeEliminarRango(
alignment: Alignment.centerRight,
),
confirmDismiss: (_) => _confirmarEliminarRango(context, l10n),
onDismissed: (_) => estado.eliminarRangoVacaciones(rango.id),
child: PluriGlassSurface(
glowColor: tokens.electricMagenta.withValues(alpha: 0.24),
padding: EdgeInsets.zero,
child: Material(
type: MaterialType.transparency,
child: InkWell(
borderRadius: BorderRadius.circular(tokens.radiusMd),
onTap: () => _abrirEditorVacaciones(context, rango: rango),
child: Padding(
padding: const EdgeInsets.all(16),
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),
// Item 21 / audit 9b.4 (t4:454): the "active now" caption is a
// teal eyebrow, not default body text.
Text(
l10n.vacationSummaryActiveCountdown(diasRestantes),
style: type.eyebrowLabel.copyWith(
color: PluriWaveTokens.brand,
),
),
const SizedBox(height: 12),
// Item 21 / audit 9b.4 (t4:451-462): the screen's signature
// element is a start/end date pair joined by a gradient rule —
// the prototype never draws a determinate progress bar here.
_ParFechasVacaciones(
inicio: rango.inicioDia,
fin: rango.finDia,
destacado: true,
reglaKey: const ValueKey('vacaciones-regla-activo'),
),
if (impacto.pausadas.isNotEmpty) ...[
const SizedBox(height: 12),
Text(
l10n.vacationImpactPausedLabel(_horas(impacto.pausadas)),
),
],
if (impacto.noAfectadas.isNotEmpty) ...[
const SizedBox(height: 4),
Text(
l10n.vacationImpactContinuesLabel(
_horas(impacto.noAfectadas),
),
),
],
],
),
),
),
),
),
);
}
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),
// Item 21 / audit 9b.5 (t4:469-479, gap:10 at t4:468): a date-pair
// card per row, not a ListTile — the same connector widget the
// active hero uses, just not `destacado`.
PluriPanelColumn(
gap: 10,
children: [
for (final rango in proximas)
_TarjetaRangoVacaciones(
rango: rango,
// Reuses the existing "Next range in {days} days" string
// (already shipped, already translated) as the header
// eyebrow — the prototype's own header text ("EMPIEZA EN
// 1 DIA - 15 DIAS") pairs a start countdown with a
// duration count; only the countdown half has a
// corresponding ARB string today, so that's the honest
// subset delivered here.
encabezado: l10n.vacationSummaryUpcomingCountdown(
rango.inicioDia
.difference(DateTime.now().dateOnly())
.inDays,
),
),
],
),
],
),
);
}
}
/// Audit 9b.7 (t4:489): a collapsible row -- icon, title, count, chevron
/// -- COLLAPSED by default; tapping reveals the full list below it. Was
/// always fully expanded inline.
class _SeccionRangosPasados extends StatefulWidget {
const _SeccionRangosPasados({required this.pasadas});
final List<RangoVacaciones> pasadas;
@override
State<_SeccionRangosPasados> createState() => _SeccionRangosPasadosState();
}
class _SeccionRangosPasadosState extends State<_SeccionRangosPasados> {
/// Ephemeral UI state only (design's "State is for ephemeral UI only"
/// ruling) -- collapsed by default, matching the prototype's own count
/// only row.
bool _expandido = false;
@override
Widget build(BuildContext context) {
if (widget.pasadas.isEmpty) return const SizedBox.shrink();
final l10n = AppLocalizations.of(context);
return PluriGlassSurface(
padding: EdgeInsets.zero,
child: Column(
children: [
Material(
type: MaterialType.transparency,
child: InkWell(
onTap: () => setState(() => _expandido = !_expandido),
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 14,
vertical: 12,
),
child: Row(
children: [
Icon(
Icons.history_rounded,
size: 20,
color: Theme.of(
context,
).colorScheme.onSurface.withValues(alpha: 0.6),
),
const SizedBox(width: 12),
Expanded(
child: Text(
l10n.vacationPastSectionTitle,
style: context.pluriType.cardTitle,
),
),
Text(
'${widget.pasadas.length}',
style: TextStyle(
fontSize: 12.5,
color: Theme.of(
context,
).colorScheme.onSurface.withValues(alpha: 0.5),
),
),
const SizedBox(width: 4),
Icon(
_expandido
? Icons.expand_less_rounded
: Icons.chevron_right_rounded,
size: 19,
color: Theme.of(
context,
).colorScheme.onSurface.withValues(alpha: 0.4),
),
],
),
),
),
),
if (_expandido)
Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
child: PluriPanelColumn(
gap: 10,
children: [
for (final rango in widget.pasadas)
_TarjetaRangoVacaciones(rango: rango),
],
),
),
],
),
);
}
}
extension _SoloFecha on DateTime {
DateTime dateOnly() => DateTime(year, month, day);
}
/// Item 21 / audit 9b.5 (t4:469-479): the shared card for both "programados"
/// and "pasados" rows — radius 20, opaque `listSurface`, the same date-pair
/// connector the active hero uses (flat, not gradient), and a label row
/// (icon + range name) matching the prototype's "Verano"/"Puente" caption
/// (t4:478).
class _TarjetaRangoVacaciones extends StatelessWidget {
const _TarjetaRangoVacaciones({required this.rango, this.encabezado});
final RangoVacaciones rango;
final String? encabezado;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final t = context.pluriTokens;
final type = context.pluriType;
final estado = context.read<EstadoAlarmas>();
// Issue 1 (feedback-pruebas): tap = edit, swipe = delete (with
// confirmation) — same interaction `pantalla_alarmas.dart`'s
// `_TarjetaAlarma` already uses for the same concept, applied here to
// BOTH the "programados" and "pasados" sections (this card backs both).
return Dismissible(
key: ValueKey('vacaciones-tarjeta-${rango.id}'),
direction: DismissDirection.horizontal,
background: const _FondoSwipeEliminarRango(
alignment: Alignment.centerLeft,
),
secondaryBackground: const _FondoSwipeEliminarRango(
alignment: Alignment.centerRight,
),
confirmDismiss: (_) => _confirmarEliminarRango(context, l10n),
onDismissed: (_) => estado.eliminarRangoVacaciones(rango.id),
child: DecoratedBox(
decoration: BoxDecoration(
color: t.listSurface,
borderRadius: BorderRadius.circular(20),
border: Border.all(color: Colors.white.withValues(alpha: 0.08)),
),
child: Material(
type: MaterialType.transparency,
child: InkWell(
borderRadius: BorderRadius.circular(20),
onTap: () => _abrirEditorVacaciones(context, rango: rango),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 15),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (encabezado != null) ...[
Text(
encabezado!,
style: type.eyebrowLabel.copyWith(
color: Theme.of(
context,
).colorScheme.onSurface.withValues(alpha: 0.5),
),
),
const SizedBox(height: 11),
],
_ParFechasVacaciones(
inicio: rango.inicioDia,
fin: rango.finDia,
destacado: false,
reglaKey: ValueKey('vacaciones-regla-${rango.id}'),
),
const SizedBox(height: 12),
Row(
children: [
Icon(
Icons.label_outline_rounded,
size: 17,
color: Theme.of(
context,
).colorScheme.onSurface.withValues(alpha: 0.6),
),
const SizedBox(width: 8),
Expanded(
child: Text(
localizedVacationName(l10n, rango.nombre),
style: Theme.of(
context,
).textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w600,
color: Theme.of(
context,
).colorScheme.onSurface.withValues(alpha: 0.6),
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
],
),
],
),
),
),
),
),
);
}
}
/// Item 21 / audit 9b.4 + 9b.5 (t4:459-461, 473-475): a start/end date pair
/// joined by a connector rule — the vacation screen's one recurring motif,
/// shared by the active hero (`destacado: true`, brand-teal gradient rule)
/// and every scheduled/past row (`destacado: false`, flat translucent rule).
class _ParFechasVacaciones extends StatelessWidget {
const _ParFechasVacaciones({
required this.inicio,
required this.fin,
required this.destacado,
required this.reglaKey,
});
final DateTime inicio;
final DateTime fin;
final bool destacado;
final Key reglaKey;
@override
Widget build(BuildContext context) {
final locale = AppLocalizations.of(context).localeName;
return Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
_BloqueFecha(fecha: inicio, locale: locale, alinearDerecha: false),
const SizedBox(width: 12),
Expanded(
child: DecoratedBox(
key: reglaKey,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(1),
gradient:
destacado
? LinearGradient(
colors: [
PluriWaveTokens.brand,
PluriWaveTokens.brand.withValues(alpha: 0.3),
],
)
: null,
color: destacado ? null : Colors.white.withValues(alpha: 0.14),
),
child: const SizedBox(height: 2),
),
),
const SizedBox(width: 12),
_BloqueFecha(fecha: fin, locale: locale, alinearDerecha: true),
],
);
}
}
class _BloqueFecha extends StatelessWidget {
const _BloqueFecha({
required this.fecha,
required this.locale,
required this.alinearDerecha,
});
final DateTime fecha;
final String locale;
final bool alinearDerecha;
@override
Widget build(BuildContext context) {
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment:
alinearDerecha ? CrossAxisAlignment.end : CrossAxisAlignment.start,
children: [
// t4:459: 26px/w800/ls-1/lh1.
Text(
diaMesLocalizado(locale, fecha),
style: const TextStyle(
fontSize: 26,
fontWeight: FontWeight.w800,
letterSpacing: -1,
height: 1,
),
),
const SizedBox(height: 2),
// t4:459: 11px/w700/rgba(242,247,250,.5).
Text(
nombreDiaSemanaLocalizado(locale, fecha),
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w700,
color: Theme.of(
context,
).colorScheme.onSurface.withValues(alpha: 0.5),
),
),
],
);
}
}
/// Add/edit-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). Create behaviour unchanged; issue
/// 1 (feedback-pruebas) adds the edit half via the optional [rango] — the
/// SAME sheet, mirroring `pantalla_alarmas.dart`'s `_EditorAlarmaSheet`
/// (`alarma == null` -> create, non-null -> edit; one shared save button
/// either way).
class _EditorVacacionesSheet extends StatefulWidget {
const _EditorVacacionesSheet({this.rango});
final RangoVacaciones? rango;
@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 rango = widget.rango;
if (rango != null) {
_inicio = rango.inicioDia;
_fin = rango.finDia;
} else {
final hoy = DateTime.now();
_inicio = DateTime(hoy.year, hoy.month, hoy.day);
_fin = _inicio.add(const Duration(days: 2));
}
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
final rango = widget.rango;
_nombreController ??= TextEditingController(
text:
rango != null
? localizedVacationName(
AppLocalizations.of(context),
rango.nombre,
)
: 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(
widget.rango != null
? l10n.editVacationRangeTitle
: 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),
Row(
children: [
Expanded(
child: FilledButton.icon(
onPressed: _guardar,
icon: const Icon(Icons.check_rounded),
label: Text(l10n.saveRangeAction),
),
),
// Fix `vacaciones-delete`: only when EDITING an existing
// range (never when creating one -- there is nothing to
// delete yet). Reuses the exact same confirmation dialog
// (`_confirmarEliminarRango`) and deletion method
// (`eliminarRangoVacaciones`) the swipe-to-delete gesture
// already uses on both `_HeroRangoActivo` and
// `_TarjetaRangoVacaciones` -- no new deletion path.
if (widget.rango != null) ...[
const SizedBox(width: 10),
OutlinedButton.icon(
key: const ValueKey('vacation-delete-button'),
style: OutlinedButton.styleFrom(
foregroundColor: Theme.of(context).colorScheme.error,
side: BorderSide(
color: Theme.of(context).colorScheme.error,
),
),
onPressed: _eliminar,
icon: const Icon(Icons.delete_outline_rounded),
label: Text(l10n.deleteAction),
),
],
],
),
],
),
),
);
}
Future<void> _elegirFecha({required bool esInicio}) async {
final actual = esInicio ? _inicio : _fin;
final hoy = DateTime.now();
final hoyDia = DateTime(hoy.year, hoy.month, hoy.day);
// Issue 1 (feedback-pruebas): editing a PAST range (reachable from the
// "Rangos pasados" section) must not force its dates into the future —
// `firstDate` only floors at today for a range that starts there or
// later; an already-past range keeps its own start as the floor.
final primerDiaPermitido = _inicio.isBefore(hoyDia) ? _inicio : hoyDia;
final seleccion = await showDatePicker(
context: context,
initialDate: actual,
firstDate: primerDiaPermitido,
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 nombre = _nombreController?.text.trim() ?? '';
final existente = widget.rango;
if (existente != null) {
await estado.editarRangoVacaciones(
RangoVacaciones(
id: existente.id,
nombre: nombre,
inicio: _inicio,
fin: _fin,
activo: existente.activo,
),
);
} else {
final rango = estado.servicio.crearRangoVacaciones(
inicio: _inicio,
fin: _fin,
nombre: nombre,
);
await estado.crearRangoVacaciones(rango);
}
if (mounted) Navigator.pop(context);
}
/// Fix `vacaciones-delete`: mirrors `_guardar`'s pop-on-success shape,
/// but confirms first (via the same `_confirmarEliminarRange` dialog the
/// swipe gesture uses) and calls `eliminarRangoVacaciones` instead of
/// saving. Only reachable when [widget.rango] is non-null (the delete
/// button itself is hidden otherwise).
Future<void> _eliminar() async {
final rango = widget.rango;
if (rango == null) return;
final l10n = AppLocalizations.of(context);
final confirmado = await _confirmarEliminarRango(context, l10n);
if (!confirmado || !mounted) return;
await context.read<EstadoAlarmas>().eliminarRangoVacaciones(rango.id);
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),
],
),
);
}
}