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:
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user