Files
pluriwave/lib/pantallas/pantalla_vacaciones.dart
T
FreeTLab aa0b242374 feat(iap): add freemium unlock via one-time in-app purchase
Adds a permanent, non-consumable premium unlock (EstadoEntitlement +
PuertoCompras/ServicioComprasPlayBilling) that removes ads and unlocks
alarm vacations, alarms past a 5-alarm free cap, recording start, and
full Android Auto browsing. The phone equalizer stays free for everyone.

- Entitlement is prefs-backed (compra_premium_v1), fail-open, and
  resolvable headlessly via esPremiumPersistido() for the Android Auto
  audio handler, which registers before runApp.
- Android Auto reduced mode keeps the real root folder labels for free
  users; browsing into any of them (and playFromMediaId/playFromSearch/
  skipToNext/skipToPrevious) is blocked at the getChildren/servicio_audio
  choke points, with a locked "Función Premium" item as the backstop.
  Current-station play/pause/stop stays untouched. A free -> premium
  transition actively invalidates the head unit's cached browse tree.
- Ads (top banner + capped interstitial before adding a station or an
  alarm) are gated behind entitlement via ServicioAnuncios, using
  official Google test ad unit IDs pending AdMob provisioning.
- Alarm cap UX shows an explanatory message with a secondary unlock
  action rather than a bare paywall jump; existing data is grandfathered.
- 4 new localization keys translated across all 13 supported locales.

Co-located tests use strict TDD (RED test before implementation) for
every new pure-logic unit; full existing suite passes unchanged.
2026-08-10 20:37:07 +02:00

987 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/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<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,
);
// 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<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),
],
),
);
}
}