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/hoja_premium.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(); 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 _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 _abrirEditorVacaciones( BuildContext context, { RangoVacaciones? rango, }) async { await showModalBottomSheet( 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 _confirmarEliminarRango( BuildContext context, AppLocalizations l10n, ) async { final confirmado = await showDialog( 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 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 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 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(); // 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 _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 _guardar() async { final estado = context.read(); 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, ); // freemium-gating spec "Gated Feature Set": vacation creation is // fully gated (unlike the alarm cap, there is no free allowance) — // `crearRangoVacaciones` is the authoritative choke point. final creada = await estado.crearRangoVacaciones(rango); if (!creada) { if (mounted) await mostrarHojaPremium(context); return; } } if (mounted) Navigator.pop(context); } /// Fix `vacaciones-delete`: mirrors `_guardar`'s pop-on-success shape, /// but confirms first (via the same `_confirmarEliminarRango` 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 _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().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), ], ), ); } }