From 2b28c4daeddf7073ea495eb2b81a5005f817e6bb Mon Sep 17 00:00:00 2001 From: freetlab Date: Thu, 30 Jul 2026 10:25:07 +0200 Subject: [PATCH] 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. --- lib/l10n/formato_fechas.dart | 11 + lib/pantallas/pantalla_vacaciones.dart | 260 ++++++++++++++++--- test/pantallas/pantalla_vacaciones_test.dart | 122 +++++++++ 3 files changed, 362 insertions(+), 31 deletions(-) diff --git a/lib/l10n/formato_fechas.dart b/lib/l10n/formato_fechas.dart index 12bde48..d027f95 100644 --- a/lib/l10n/formato_fechas.dart +++ b/lib/l10n/formato_fechas.dart @@ -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); diff --git a/lib/pantallas/pantalla_vacaciones.dart b/lib/pantallas/pantalla_vacaciones.dart index 4017955..ac1ba11 100644 --- a/lib/pantallas/pantalla_vacaciones.dart +++ b/lib/pantallas/pantalla_vacaciones.dart @@ -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 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 diff --git a/test/pantallas/pantalla_vacaciones_test.dart b/test/pantallas/pantalla_vacaciones_test.dart index 87ede45..63ce19c 100644 --- a/test/pantallas/pantalla_vacaciones_test.dart +++ b/test/pantallas/pantalla_vacaciones_test.dart @@ -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( + 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( + 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( + 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( + 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', + ); + }, + ); }