Item 21 / audit 9b.4-9b.5 (t4:451-462, 469-479): the active vacation range showed a LinearProgressIndicator the prototype never draws. Replace it with the screen's real signature element -- a start/end date pair (day+month, weekday, connector rule) -- reused with a flat connector for the "programados"/"pasados" rows, replacing their plain ListTiles with proper cards.
552 lines
18 KiB
Dart
552 lines
18 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,
|
|
body: ListView(
|
|
padding: PluriLayout.pageContentPadding,
|
|
children: [
|
|
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),
|
|
Align(
|
|
alignment: Alignment.centerLeft,
|
|
child: OutlinedButton.icon(
|
|
onPressed: () => _abrirAlta(context),
|
|
icon: const Icon(Icons.add_rounded),
|
|
label: Text(l10n.addVacationRangeCta),
|
|
),
|
|
),
|
|
const SizedBox(height: 16),
|
|
_SeccionRangosPasados(pasadas: pasadas),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Future<void> _abrirAlta(BuildContext context) async {
|
|
await showModalBottomSheet<void>(
|
|
context: context,
|
|
isScrollControlled: true,
|
|
useSafeArea: true,
|
|
backgroundColor: Colors.transparent,
|
|
builder: (_) => const _EditorVacacionesSheet(),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// 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;
|
|
|
|
return PluriGlassSurface(
|
|
glowColor: context.pluriTokens.electricMagenta.withValues(alpha: 0.24),
|
|
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,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _SeccionRangosPasados extends StatelessWidget {
|
|
const _SeccionRangosPasados({required this.pasadas});
|
|
|
|
final List<RangoVacaciones> pasadas;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
if (pasadas.isEmpty) return const SizedBox.shrink();
|
|
final l10n = AppLocalizations.of(context);
|
|
final type = context.pluriType;
|
|
return PluriGlassSurface(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(l10n.vacationPastSectionTitle, style: type.eyebrowLabel),
|
|
const SizedBox(height: 8),
|
|
// No prototype-specified header text exists for an already-ended
|
|
// range, so `encabezado` is omitted rather than invented.
|
|
PluriPanelColumn(
|
|
gap: 10,
|
|
children: [
|
|
for (final rango in 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;
|
|
return DecoratedBox(
|
|
decoration: BoxDecoration(
|
|
color: t.listSurface,
|
|
borderRadius: BorderRadius.circular(20),
|
|
border: Border.all(color: Colors.white.withValues(alpha: 0.08)),
|
|
),
|
|
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-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). Behaviour unchanged.
|
|
class _EditorVacacionesSheet extends StatefulWidget {
|
|
const _EditorVacacionesSheet();
|
|
|
|
@override
|
|
State<_EditorVacacionesSheet> createState() => _EditorVacacionesSheetState();
|
|
}
|
|
|
|
class _EditorVacacionesSheetState extends State<_EditorVacacionesSheet> {
|
|
// Created lazily: AppLocalizations.of(context) cannot be read in
|
|
// initState (inherited-widget lookup assert in debug builds).
|
|
TextEditingController? _nombreController;
|
|
late DateTime _inicio;
|
|
late DateTime _fin;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
final hoy = DateTime.now();
|
|
_inicio = DateTime(hoy.year, hoy.month, hoy.day);
|
|
_fin = _inicio.add(const Duration(days: 2));
|
|
}
|
|
|
|
@override
|
|
void didChangeDependencies() {
|
|
super.didChangeDependencies();
|
|
_nombreController ??= TextEditingController(
|
|
text: AppLocalizations.of(context).vacationsDefaultName,
|
|
);
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_nombreController?.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final l10n = AppLocalizations.of(context);
|
|
final bottom = MediaQuery.of(context).viewInsets.bottom;
|
|
return Padding(
|
|
padding: EdgeInsets.fromLTRB(12, 12, 12, bottom + 12),
|
|
child: PluriGlassSurface(
|
|
borderRadius: BorderRadius.circular(28),
|
|
padding: const EdgeInsets.all(18),
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
l10n.newVacationRangeTitle,
|
|
style: Theme.of(
|
|
context,
|
|
).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.w900),
|
|
),
|
|
const SizedBox(height: 12),
|
|
TextField(
|
|
controller: _nombreController,
|
|
decoration: InputDecoration(labelText: l10n.nameLabel),
|
|
),
|
|
const SizedBox(height: 12),
|
|
Row(
|
|
children: [
|
|
Expanded(
|
|
child: _PickerButton(
|
|
icon: Icons.play_arrow_rounded,
|
|
label: l10n.startLabel,
|
|
value: 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),
|
|
FilledButton.icon(
|
|
onPressed: _guardar,
|
|
icon: const Icon(Icons.check_rounded),
|
|
label: Text(l10n.saveRangeAction),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Future<void> _elegirFecha({required bool esInicio}) async {
|
|
final actual = esInicio ? _inicio : _fin;
|
|
final hoy = DateTime.now();
|
|
final seleccion = await showDatePicker(
|
|
context: context,
|
|
initialDate: actual,
|
|
firstDate: DateTime(hoy.year, hoy.month, hoy.day),
|
|
lastDate: hoy.add(const Duration(days: 1460)),
|
|
);
|
|
if (seleccion == null) return;
|
|
setState(() {
|
|
if (esInicio) {
|
|
_inicio = seleccion;
|
|
if (_fin.isBefore(_inicio)) _fin = _inicio;
|
|
} else {
|
|
_fin = seleccion;
|
|
}
|
|
});
|
|
}
|
|
|
|
Future<void> _guardar() async {
|
|
final estado = context.read<EstadoAlarmas>();
|
|
final rango = estado.servicio.crearRangoVacaciones(
|
|
inicio: _inicio,
|
|
fin: _fin,
|
|
nombre: _nombreController?.text.trim() ?? '',
|
|
);
|
|
await estado.crearRangoVacaciones(rango);
|
|
if (mounted) Navigator.pop(context);
|
|
}
|
|
}
|
|
|
|
class _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),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|