fix(vacaciones): edit and delete vacation ranges

Vacaciones ranges could be created but never edited or removed --
EstadoAlarmas already had crearRangoVacaciones/eliminarRangoVacaciones
with no UI affordance reaching them, and no update path at all.

Add EstadoAlarmas.editarRangoVacaciones and wire tap-to-edit /
swipe-to-delete (with confirmation) onto every range card, mirroring
the alarm list's own Dismissible + confirm-dialog pattern exactly. This
covers the active-range hero too: a freshly created range is active
immediately and only ever renders there, never in the
scheduled/past lists, so it needed the same affordances or a user's
very first range could never be fixed.
This commit is contained in:
2026-07-30 19:05:28 +02:00
parent d1a911e587
commit c7e1a212ca
30 changed files with 639 additions and 111 deletions
+278 -111
View File
@@ -82,13 +82,78 @@ class PantallaVacaciones extends StatelessWidget {
);
}
Future<void> _abrirAlta(BuildContext context) async {
await showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
useSafeArea: true,
backgroundColor: Colors.transparent,
builder: (_) => const _EditorVacacionesSheet(),
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,
),
);
}
}
@@ -242,46 +307,81 @@ class _HeroRangoActivo extends StatelessWidget {
final diasRestantes = rango.finDia.difference(hoyDia).inDays;
final impacto = estado.impactoDeRango(rango);
final type = context.pluriType;
final tokens = context.pluriTokens;
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)),
// 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),
),
),
],
],
),
),
],
],
),
),
),
);
}
@@ -452,61 +552,87 @@ class _TarjetaRangoVacaciones extends StatelessWidget {
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)),
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,
),
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),
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),
),
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
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,
),
),
],
),
],
),
),
],
),
),
),
);
@@ -609,11 +735,17 @@ class _BloqueFecha extends StatelessWidget {
}
}
/// Add-range form. Moved verbatim from `pantalla_alarmas.dart` (WU8's
/// 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). Behaviour unchanged.
/// 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();
const _EditorVacacionesSheet({this.rango});
final RangoVacaciones? rango;
@override
State<_EditorVacacionesSheet> createState() => _EditorVacacionesSheetState();
@@ -629,16 +761,29 @@ class _EditorVacacionesSheetState extends State<_EditorVacacionesSheet> {
@override
void initState() {
super.initState();
final hoy = DateTime.now();
_inicio = DateTime(hoy.year, hoy.month, hoy.day);
_fin = _inicio.add(const Duration(days: 2));
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: AppLocalizations.of(context).vacationsDefaultName,
text:
rango != null
? localizedVacationName(
AppLocalizations.of(context),
rango.nombre,
)
: AppLocalizations.of(context).vacationsDefaultName,
);
}
@@ -662,7 +807,9 @@ class _EditorVacacionesSheetState extends State<_EditorVacacionesSheet> {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
l10n.newVacationRangeTitle,
widget.rango != null
? l10n.editVacationRangeTitle
: l10n.newVacationRangeTitle,
style: Theme.of(
context,
).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.w900),
@@ -709,10 +856,16 @@ class _EditorVacacionesSheetState extends State<_EditorVacacionesSheet> {
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: DateTime(hoy.year, hoy.month, hoy.day),
firstDate: primerDiaPermitido,
lastDate: hoy.add(const Duration(days: 1460)),
);
if (seleccion == null) return;
@@ -728,12 +881,26 @@ class _EditorVacacionesSheetState extends State<_EditorVacacionesSheet> {
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);
final nombre = _nombreController?.text.trim() ?? '';
final existente = widget.rango;
if (existente != null) {
await estado.editarRangoVacaciones(
RangoVacaciones(
id: existente.id,
nombre: nombre,
inicio: _inicio,
fin: _fin,
activo: existente.activo,
),
);
} else {
final rango = estado.servicio.crearRangoVacaciones(
inicio: _inicio,
fin: _fin,
nombre: nombre,
);
await estado.crearRangoVacaciones(rango);
}
if (mounted) Navigator.pop(context);
}
}