fix(vacaciones): replace the progress bar with the prototype's date pair

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.
This commit is contained in:
2026-07-30 10:25:07 +02:00
parent 2255374291
commit 2b28c4daed
3 changed files with 362 additions and 31 deletions
+11
View File
@@ -11,3 +11,14 @@ import 'package:intl/intl.dart';
/// this safely.
String fechaCortaLocalizada(String localeTag, DateTime fecha) =>
DateFormat.yMd(localeTag).format(fecha);
/// Day + abbreviated month, locale-aware (e.g. "28 jul" for `es`, "Jul 28"
/// for `en`) — item 21 / audit 9b.4 (t4:459), the big digit half of the
/// vacation screen's date-pair.
String diaMesLocalizado(String localeTag, DateTime fecha) =>
DateFormat.MMMd(localeTag).format(fecha);
/// Full weekday name, locale-aware (e.g. "lunes"/"Monday") — item 21 /
/// audit 9b.4 (t4:459), the small caption under the day-month.
String nombreDiaSemanaLocalizado(String localeTag, DateTime fecha) =>
DateFormat.EEEE(localeTag).format(fecha);
+229 -31
View File
@@ -7,6 +7,7 @@ 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';
@@ -68,7 +69,8 @@ class PantallaVacaciones extends StatelessWidget {
/// Active-range hero: name, days-remaining countdown (reusing WU8's own
/// `vacationSummaryActiveCountdown` string — same concept, bigger stage),
/// a determinate progress bar, and the per-alarm impact line(s) from
/// 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});
@@ -83,6 +85,7 @@ class _HeroRangoActivo extends StatelessWidget {
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),
@@ -96,14 +99,24 @@ class _HeroRangoActivo extends StatelessWidget {
).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.w900),
),
const SizedBox(height: 4),
Text(l10n.vacationSummaryActiveCountdown(diasRestantes)),
const SizedBox(height: 10),
ClipRRect(
borderRadius: BorderRadius.circular(999),
child: LinearProgressIndicator(value: _progreso(hoyDia)),
// 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: 10),
const SizedBox(height: 12),
Text(l10n.vacationImpactPausedLabel(_horas(impacto.pausadas))),
],
if (impacto.noAfectadas.isNotEmpty) ...[
@@ -117,13 +130,6 @@ class _HeroRangoActivo extends StatelessWidget {
);
}
double _progreso(DateTime hoyDia) {
final totalDias = rango.finDia.difference(rango.inicioDia).inDays + 1;
if (totalDias <= 0) return 0;
final transcurridos = hoyDia.difference(rango.inicioDia).inDays + 1;
return (transcurridos / totalDias).clamp(0.0, 1.0);
}
String _horas(List<AlarmaMusical> alarmas) => alarmas
.map(
(a) =>
@@ -148,13 +154,30 @@ class _SeccionProgramados extends StatelessWidget {
children: [
Text(l10n.vacationUpcomingSectionTitle, style: type.eyebrowLabel),
const SizedBox(height: 8),
for (final rango in proximas)
ListTile(
contentPadding: EdgeInsets.zero,
leading: const Icon(Icons.event_rounded),
title: Text(localizedVacationName(l10n, rango.nombre)),
subtitle: Text(_rangoFechas(l10n, rango)),
),
// 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,
),
),
],
),
],
),
);
@@ -177,22 +200,197 @@ class _SeccionRangosPasados extends StatelessWidget {
children: [
Text(l10n.vacationPastSectionTitle, style: type.eyebrowLabel),
const SizedBox(height: 8),
for (final rango in pasadas)
ListTile(
contentPadding: EdgeInsets.zero,
leading: const Icon(Icons.history_rounded),
title: Text(localizedVacationName(l10n, rango.nombre)),
subtitle: Text(_rangoFechas(l10n, rango)),
),
// 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),
],
),
],
),
);
}
}
String _rangoFechas(AppLocalizations l10n, RangoVacaciones rango) =>
'${fechaCortaLocalizada(l10n.localeName, rango.inicioDia)}'
'${fechaCortaLocalizada(l10n.localeName, rango.finDia)}';
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
@@ -1,6 +1,7 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:pluriwave/estado/estado_alarmas.dart';
import 'package:pluriwave/l10n/formato_fechas.dart';
import 'package:pluriwave/l10n/gen/app_localizations.dart';
import 'package:pluriwave/modelos/alarma_musical.dart';
import 'package:pluriwave/pantallas/pantalla_vacaciones.dart';
@@ -221,4 +222,125 @@ void main() {
expect(find.text(l10n.newVacationRangeTitle), findsOneWidget);
});
// Item 21 / audit 9b.4-9b.5 (t4:451-462, 469-479): the screen's signature
// element is a start/end date pair, never a determinate progress bar.
testWidgets(
'el rango activo muestra el par de fechas, no una barra de progreso',
(tester) async {
final inicio = _hoyDia.subtract(const Duration(days: 3));
final fin = _hoyDia.add(const Duration(days: 5));
final estado = await _crearEstado(
vacaciones: [
RangoVacaciones(
id: 'v1',
nombre: 'Julio activo',
inicio: inicio,
fin: fin,
),
],
);
addTearDown(estado.dispose);
await tester.pumpWidget(_buildScreen(estado));
await _pumpEstable(tester);
expect(
find.byType(LinearProgressIndicator),
findsNothing,
reason:
't4:451-462 replaces the progress bar with a start/end date '
'pair — the screen\'s signature element',
);
expect(find.text(diaMesLocalizado('es', inicio)), findsOneWidget);
expect(find.text(diaMesLocalizado('es', fin)), findsOneWidget);
expect(
find.text(nombreDiaSemanaLocalizado('es', inicio)),
findsOneWidget,
);
expect(find.text(nombreDiaSemanaLocalizado('es', fin)), findsOneWidget);
final fechaTexto = tester.widget<Text>(
find.text(diaMesLocalizado('es', inicio)),
);
expect(
fechaTexto.style?.fontSize,
26,
reason: 't4:459 the date digits are 26px/w800/ls-1',
);
expect(fechaTexto.style?.fontWeight, FontWeight.w800);
final diaSemanaTexto = tester.widget<Text>(
find.text(nombreDiaSemanaLocalizado('es', inicio)),
);
expect(
diaSemanaTexto.style?.fontSize,
11,
reason: 't4:459 the weekday caption is 11px/w700/50%',
);
expect(diaSemanaTexto.style?.fontWeight, FontWeight.w700);
final regla = tester.widget<DecoratedBox>(
find.byKey(const ValueKey('vacaciones-regla-activo')),
);
final decoration = regla.decoration as BoxDecoration;
expect(
decoration.gradient,
isNotNull,
reason:
't4:460 the active range connector is a brand-teal gradient, '
'not the flat rgba(255,255,255,.14) used by scheduled rows',
);
},
);
testWidgets(
'un rango programado usa el mismo par de fechas, con una regla plana y '
'una fila de etiqueta con el nombre del rango',
(tester) async {
final inicio = _hoyDia.add(const Duration(days: 20));
final fin = _hoyDia.add(const Duration(days: 25));
final estado = await _crearEstado(
vacaciones: [
RangoVacaciones(id: 'f2', nombre: 'Verano', inicio: inicio, fin: fin),
],
);
addTearDown(estado.dispose);
await tester.pumpWidget(_buildScreen(estado));
await _pumpEstable(tester);
expect(
find.descendant(
of: find.byType(PantallaVacaciones),
matching: find.byType(ListTile),
),
findsNothing,
reason: '9b.5 (t4:469-479) replaces the ListTile row with a card',
);
expect(find.text(diaMesLocalizado('es', inicio)), findsOneWidget);
expect(find.text(diaMesLocalizado('es', fin)), findsOneWidget);
expect(
find.text('Verano'),
findsOneWidget,
reason:
't4:478 the label row shows the range name next to a '
'"label" icon',
);
final regla = tester.widget<DecoratedBox>(
find.byKey(const ValueKey('vacaciones-regla-f2')),
);
final decoration = regla.decoration as BoxDecoration;
expect(decoration.gradient, isNull);
expect(
decoration.color,
Colors.white.withValues(alpha: 0.14),
reason:
't4:474 scheduled/past rows use a flat rgba(255,255,255,.14) '
'connector, not the active gradient',
);
},
);
}