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