fix(vacaciones): header Add action, info banner, dashed CTA, collapse

Audit 9b.1 (t4:446): a solid brand-teal "Add" header action -- the
prototype's own mid-page CTA (audit 9b.6, still present, now dashed)
is a SECOND, additional entry point, not a replacement.

Audit 9b.2 (t4:448): the teal explanatory banner ("alarms marked
pause-during-vacations won't ring...") is now ALWAYS visible -- never
rendered before. New ARB key vacationExplainerBanner, all 13 locales.

Audit 9b.6 (t4:487): the bottom CTA is now dashed-border with a
date_range glyph -- was a solid OutlinedButton with an add glyph.
Reuses the dashed-painter shape already established in
pantalla_favoritos.dart's custom-station CTA (audit 4.5), duplicated
rather than shared.

Audit 9b.7 (t4:489): "Rangos pasados" is now a collapsible row --
icon, title, count, chevron -- COLLAPSED by default, expanding on tap.
Was always fully expanded inline. Updated the pre-existing widget test
to tap-then-assert instead of asserting immediate visibility.

Item 9b.3 (the eyebrow literally reading "EN CURSO") is NOT
implemented: the eyebrow STYLING is already correct (audit 9b.4), and
the residual copy gap is a shared ARB string
(vacationSummaryActiveCountdown) also used compactly in
pantalla_alarmas.dart's vacation summary row -- diverging its wording
just for this screen's eyebrow, or forcing a shoutier tone into that
other compact usage, is not worth it for a trivial-rated copy nuance.
This commit is contained in:
2026-07-30 16:53:04 +02:00
parent 36d7d5f692
commit f61b0b9163
29 changed files with 414 additions and 24 deletions
+240 -23
View File
@@ -31,9 +31,40 @@ class PantallaVacaciones extends StatelessWidget {
return PluriPushScaffold(
title: l10n.vacationRangesTitle,
// Audit 9b.1 (t4:446): a solid brand-teal "Add" header action --
// the prototype's OWN mid-page CTA (audit 9b.6, still present below,
// now dashed) is a SECOND, additional entry point in the prototype,
// not a replacement for this one.
actions: [
Padding(
padding: const EdgeInsets.only(right: 8),
child: FilledButton.icon(
key: const ValueKey('vacation-add-header'),
style: FilledButton.styleFrom(
backgroundColor: PluriWaveTokens.brand,
foregroundColor: const Color(0xFF062126),
padding: const EdgeInsets.symmetric(horizontal: 13, vertical: 8),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(11),
),
textStyle: const TextStyle(
fontSize: 12.5,
fontWeight: FontWeight.w800,
),
),
onPressed: () => _abrirAlta(context),
icon: const Icon(Icons.add_rounded, size: 17),
label: Text(l10n.addAction),
),
),
],
body: ListView(
padding: PluriLayout.pageContentPadding,
children: [
// Audit 9b.2 (t4:448): the explanatory banner is ALWAYS visible
// -- never rendered anywhere before.
_BannerExplicativo(texto: l10n.vacationExplainerBanner),
const SizedBox(height: 18),
if (activo != null)
_HeroRangoActivo(estado: estado, rango: activo)
else
@@ -41,15 +72,10 @@ class PantallaVacaciones extends StatelessWidget {
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),
// Audit 9b.6 (t4:487): a dashed border + `date_range` icon --
// was a solid `OutlinedButton` with an `add` glyph.
_CtaAnadirRango(onTap: () => _abrirAlta(context)),
const SizedBox(height: 14),
_SeccionRangosPasados(pasadas: pasadas),
],
),
@@ -67,6 +93,136 @@ class PantallaVacaciones extends StatelessWidget {
}
}
/// Audit 9b.2 (t4:448): teal-tinted explainer banner, always visible above
/// the active-range hero.
class _BannerExplicativo extends StatelessWidget {
const _BannerExplicativo({required this.texto});
final String texto;
@override
Widget build(BuildContext context) {
final tokens = context.pluriTokens;
return DecoratedBox(
decoration: BoxDecoration(
color: tokens.liveGreen.withValues(alpha: 0.09),
borderRadius: BorderRadius.circular(16),
border: Border.all(color: tokens.liveGreen.withValues(alpha: 0.26)),
),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 15, vertical: 13),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(Icons.info_outline_rounded, size: 20, color: tokens.liveGreen),
const SizedBox(width: 11),
Expanded(
child: Text(
texto,
style: TextStyle(
fontSize: 12,
height: 1.5,
color: Theme.of(
context,
).colorScheme.onSurface.withValues(alpha: 0.72),
),
),
),
],
),
),
);
}
}
/// Audit 9b.6 (t4:487): dashed-border CTA with a `date_range` glyph --
/// reuses the same dashed-painter shape already established in
/// `pantalla_favoritos.dart`'s custom-station CTA (audit 4.5), duplicated
/// rather than shared (small, self-contained, matching this codebase's own
/// precedent for tiny per-screen painters).
class _CtaAnadirRango extends StatelessWidget {
const _CtaAnadirRango({required this.onTap});
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final colorTexto = Theme.of(
context,
).colorScheme.onSurface.withValues(alpha: 0.6);
return CustomPaint(
painter: _DashedBorderPainter(
color: Colors.white.withValues(alpha: 0.16),
),
child: Material(
color: Colors.transparent,
child: InkWell(
borderRadius: BorderRadius.circular(16),
onTap: onTap,
child: Padding(
padding: const EdgeInsets.all(15),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.date_range_rounded, size: 20, color: colorTexto),
const SizedBox(width: 8),
Text(
l10n.addVacationRangeCta,
style: TextStyle(
fontSize: 13.5,
fontWeight: FontWeight.w800,
color: colorTexto,
),
),
],
),
),
),
),
);
}
}
class _DashedBorderPainter extends CustomPainter {
const _DashedBorderPainter({required this.color});
final Color color;
static const _radius = 16.0;
static const _dashWidth = 6.0;
static const _gapWidth = 4.0;
@override
void paint(Canvas canvas, Size size) {
final rrect = RRect.fromRectAndRadius(
Offset.zero & size,
const Radius.circular(_radius),
);
final path = Path()..addRRect(rrect);
final paint =
Paint()
..color = color
..style = PaintingStyle.stroke
..strokeWidth = 1.5;
for (final metric in path.computeMetrics()) {
var distance = 0.0;
while (distance < metric.length) {
final next = distance + _dashWidth;
canvas.drawPath(
metric.extractPath(distance, next.clamp(0.0, metric.length)),
paint,
);
distance = next + _gapWidth;
}
}
}
@override
bool shouldRepaint(covariant _DashedBorderPainter oldDelegate) =>
oldDelegate.color != color;
}
/// 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
@@ -184,31 +340,92 @@ class _SeccionProgramados extends StatelessWidget {
}
}
class _SeccionRangosPasados extends StatelessWidget {
/// Audit 9b.7 (t4:489): a collapsible row -- icon, title, count, chevron
/// -- COLLAPSED by default; tapping reveals the full list below it. Was
/// always fully expanded inline.
class _SeccionRangosPasados extends StatefulWidget {
const _SeccionRangosPasados({required this.pasadas});
final List<RangoVacaciones> pasadas;
@override
State<_SeccionRangosPasados> createState() => _SeccionRangosPasadosState();
}
class _SeccionRangosPasadosState extends State<_SeccionRangosPasados> {
/// Ephemeral UI state only (design's "State is for ephemeral UI only"
/// ruling) -- collapsed by default, matching the prototype's own count
/// only row.
bool _expandido = false;
@override
Widget build(BuildContext context) {
if (pasadas.isEmpty) return const SizedBox.shrink();
if (widget.pasadas.isEmpty) return const SizedBox.shrink();
final l10n = AppLocalizations.of(context);
final type = context.pluriType;
return PluriGlassSurface(
padding: EdgeInsets.zero,
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),
],
Material(
type: MaterialType.transparency,
child: InkWell(
onTap: () => setState(() => _expandido = !_expandido),
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 14,
vertical: 12,
),
child: Row(
children: [
Icon(
Icons.history_rounded,
size: 20,
color: Theme.of(
context,
).colorScheme.onSurface.withValues(alpha: 0.6),
),
const SizedBox(width: 12),
Expanded(
child: Text(
l10n.vacationPastSectionTitle,
style: context.pluriType.cardTitle,
),
),
Text(
'${widget.pasadas.length}',
style: TextStyle(
fontSize: 12.5,
color: Theme.of(
context,
).colorScheme.onSurface.withValues(alpha: 0.5),
),
),
const SizedBox(width: 4),
Icon(
_expandido
? Icons.expand_less_rounded
: Icons.chevron_right_rounded,
size: 19,
color: Theme.of(
context,
).colorScheme.onSurface.withValues(alpha: 0.4),
),
],
),
),
),
),
if (_expandido)
Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
child: PluriPanelColumn(
gap: 10,
children: [
for (final rango in widget.pasadas)
_TarjetaRangoVacaciones(rango: rango),
],
),
),
],
),
);