fix(alarmas): New pill, banner layout, vacation pill, card recurrence

Audit 7.1 (t4:325): the "New" action is a solid brand-teal pill with a
plain add glyph -- was a tonal button with auto_awesome.

Audit 7.2 (t4:326-330): the next-alarm banner is warmCoral-tinted with
the Skip chip BESIDE the text on the same row -- was an opaque default
card with the skip action stacked below as an OutlinedButton.

Audit 7.3 (t4:332): the vacation row gains a trailing "d-d MON"
date-range pill for the active-or-next range, built from the existing
EstadoAlarmas.rangoVacacionesActivo/vacacionesProximas() accessors --
new formato_fechas.dart helper, no new state.

Audit 7.4 (t4:337-345): the alarm card now shows a recurrence label
next to the giant time (reusing the existing oneTimeOption/
dailyOption/weekdaysOption strings) and a themed station-icon slot
next to the station name. The real per-station favicon is NOT
rendered here -- same network-image hazard already documented for the
ringing screen's audit 9.2 (Emisora.favicon is a network URL;
Image.network hangs widget tests without a mocked HttpClient). The
custom switch shape (52x32/26px thumb) is also left as Switch.adaptive
-- a disclosed sub-gap, not a silent drop.

Item 7.6 (_AccesoDiagnostico, an Android-reliability debug row) is a
pre-approved addition not in the prototype -- informational only, no
action needed.
This commit is contained in:
2026-07-30 16:30:20 +02:00
parent dcd8488874
commit c7d137c82e
3 changed files with 441 additions and 57 deletions
+230 -57
View File
@@ -45,9 +45,26 @@ class PantallaAlarmas extends StatelessWidget {
title: l10n.alarmScreenTitle,
onSleepTimer: () => showPluriSleepTimerSheet(context),
actions: [
FilledButton.tonalIcon(
// Audit 7.1 (t4:325): a solid brand-teal pill with a plain
// `add` glyph -- was a tonal button with `auto_awesome`.
FilledButton.icon(
style: FilledButton.styleFrom(
backgroundColor: PluriWaveTokens.brand,
foregroundColor: const Color(0xFF062126),
padding: const EdgeInsets.symmetric(
horizontal: 14,
vertical: 9,
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
textStyle: const TextStyle(
fontSize: 13,
fontWeight: FontWeight.w800,
),
),
onPressed: () => _abrirEditor(context),
icon: const Icon(Icons.auto_awesome_rounded, size: 18),
icon: const Icon(Icons.add_rounded, size: 18),
label: Text(l10n.createAlarmAction),
),
],
@@ -105,6 +122,7 @@ class _PanelProximaAlarma extends StatelessWidget {
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final tokens = context.pluriTokens;
final proxima = estado.proximaAlarma;
final activasSinProxima =
estado.alarmas
@@ -112,56 +130,87 @@ class _PanelProximaAlarma extends StatelessWidget {
.length;
final proximaProgramable = proxima?.proximaProgramable;
return PluriGlassSurface(
glowColor: context.pluriTokens.warmCoral.withValues(alpha: 0.28),
child: Row(
children: [
_AssetIcon(
'assets/icons/alarmas/alarm_music.png',
size: 72,
semanticLabel: l10n.alarmIconLabel,
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
proxima == null
? activasSinProxima > 0
? l10n.activeAlarmsWithoutNextTitle
: l10n.noActiveAlarms
: l10n.nextAlarmTitle,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w900,
),
),
const SizedBox(height: 4),
Text(
proxima == null
? activasSinProxima > 0
? l10n.activeAlarmsWithoutNextSubtitle(
activasSinProxima,
)
: l10n.createAlarmHint
: '${_nombreVisibleAlarma(l10n, proxima)} · ${_fechaHora(l10n, proximaProgramable!)}',
),
if (proxima != null) ...[
const SizedBox(height: 10),
Align(
alignment: Alignment.centerLeft,
child: OutlinedButton.icon(
key: const ValueKey('hero-skip-next'),
onPressed: () => _saltarDesdeHero(context, proxima),
icon: const Icon(Icons.skip_next_rounded, size: 18),
label: Text(l10n.alarmHeroSkipAction),
// Audit 7.2 (t4:326-330): warmCoral-tinted card, `alarm_on` icon at
// 26px, and the "Saltar" chip BESIDE the text on the same row -- was
// an opaque default card with a 72px PNG and the skip action stacked
// BELOW the text as an OutlinedButton.
return DecoratedBox(
key: const ValueKey('next-alarm-banner'),
decoration: BoxDecoration(
color: tokens.warmCoral.withValues(alpha: 0.13),
borderRadius: BorderRadius.circular(20),
border: Border.all(color: tokens.warmCoral.withValues(alpha: 0.34)),
),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Icon(Icons.alarm_on, size: 26, color: tokens.warmCoral),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
proxima == null
? activasSinProxima > 0
? l10n.activeAlarmsWithoutNextTitle
: l10n.noActiveAlarms
: l10n.nextAlarmTitle,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w900,
),
),
const SizedBox(height: 4),
Text(
proxima == null
? activasSinProxima > 0
? l10n.activeAlarmsWithoutNextSubtitle(
activasSinProxima,
)
: l10n.createAlarmHint
: '${_nombreVisibleAlarma(l10n, proxima)} · ${_fechaHora(l10n, proximaProgramable!)}',
),
],
],
),
),
if (proxima != null) ...[
const SizedBox(width: 8),
_ChipSaltar(onTap: () => _saltarDesdeHero(context, proxima)),
],
],
),
),
);
}
}
/// Audit 7.2 (t4:329): `padding:8px 12px;radius:10;rgba(255,255,255,.08)` --
/// plain text, no icon, unlike the previous `OutlinedButton.icon`.
class _ChipSaltar extends StatelessWidget {
const _ChipSaltar({required this.onTap});
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
return Material(
color: Colors.white.withValues(alpha: 0.08),
borderRadius: BorderRadius.circular(10),
child: InkWell(
key: const ValueKey('hero-skip-next'),
borderRadius: BorderRadius.circular(10),
onTap: onTap,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
child: Text(
l10n.alarmHeroSkipAction,
style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w800),
),
],
),
),
);
}
@@ -243,17 +292,83 @@ class _TarjetaAlarma extends StatelessWidget {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
_hora(alarma),
style: Theme.of(
context,
).textTheme.displaySmall?.copyWith(
fontWeight: FontWeight.w900,
letterSpacing: -1.5,
),
// Audit 7.4 (t4:339): the recurrence label sits on
// the SAME baseline as the giant time -- it used to
// be missing entirely.
Row(
crossAxisAlignment: CrossAxisAlignment.baseline,
textBaseline: TextBaseline.alphabetic,
children: [
Text(
_hora(alarma),
style: Theme.of(
context,
).textTheme.displaySmall?.copyWith(
fontWeight: FontWeight.w900,
letterSpacing: -1.5,
),
),
const SizedBox(width: 8),
Text(
_recurrenciaCorta(l10n, alarma),
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w700,
color: Theme.of(
context,
).colorScheme.onSurface.withValues(alpha: 0.5),
),
),
],
),
const SizedBox(height: 7),
// Audit 7.4 (t4:341): a small station-art slot
// inline with the name -- was the name alone.
// `Emisora.favicon` is a network URL (the same
// hazard documented for the ringing screen's audit
// 9.2 -- `Image.network` here would hang widget
// tests without a mocked HttpClient), so this is a
// themed fallback icon, not the real per-station
// artwork, mirroring the recordings-row precedent
// (`pantalla_grabaciones.dart`'s `_FilaGrabacion`).
Row(
children: [
if (alarma.emisora != null) ...[
DecoratedBox(
key: const ValueKey('tarjeta-alarma-arte'),
decoration: BoxDecoration(
color: tokens.listSurface,
borderRadius: BorderRadius.circular(6),
),
child: SizedBox(
width: 22,
height: 22,
child: Icon(
Icons.radio_rounded,
size: 14,
color: Theme.of(context)
.colorScheme
.onSurface
.withValues(alpha: 0.75),
),
),
),
const SizedBox(width: 7),
],
Flexible(
child: Text(
estacion,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 12.5,
fontWeight: FontWeight.w600,
color: Theme.of(context).colorScheme.onSurface
.withValues(alpha: 0.75),
),
),
),
],
),
const SizedBox(height: 2),
Text(estacion, overflow: TextOverflow.ellipsis),
],
),
),
@@ -1089,6 +1204,12 @@ class _PanelVacaciones extends StatelessWidget {
final l10n = AppLocalizations.of(context);
final tokens = context.pluriTokens;
final resumen = _resumenVacaciones(l10n, estado.vacaciones);
// Audit 7.3 (t4:332): a trailing date-range pill ("4-18 AGO") for the
// active-or-next range -- never rendered anywhere before.
final proximas = estado.vacacionesProximas();
final rangoRelevante =
estado.rangoVacacionesActivo() ??
(proximas.isEmpty ? null : proximas.first);
return PluriGlassSurface(
glowColor: PluriWaveTokens.skyBlue.withValues(alpha: 0.22),
padding: EdgeInsets.zero,
@@ -1122,6 +1243,10 @@ class _PanelVacaciones extends StatelessWidget {
],
),
),
if (rangoRelevante != null) ...[
const SizedBox(width: 8),
_PildoraFechasVacaciones(rango: rangoRelevante),
],
const Icon(Icons.chevron_right_rounded),
],
),
@@ -1168,6 +1293,40 @@ class _PanelVacaciones extends StatelessWidget {
}
}
/// Audit 7.3 (t4:332): "4-18 AGO" -- `radius:999`, `liveGreen@.16` fill,
/// `liveGreen@.38` border, 10.5px/w800 in `liveGreen` (matches the
/// prototype's own `rgba(126,228,194,...)` teal, not the alarm banner's
/// warmCoral).
class _PildoraFechasVacaciones extends StatelessWidget {
const _PildoraFechasVacaciones({required this.rango});
final RangoVacaciones rango;
@override
Widget build(BuildContext context) {
final tokens = context.pluriTokens;
final locale = AppLocalizations.of(context).localeName;
return DecoratedBox(
decoration: BoxDecoration(
color: tokens.liveGreen.withValues(alpha: 0.16),
borderRadius: BorderRadius.circular(999),
border: Border.all(color: tokens.liveGreen.withValues(alpha: 0.38)),
),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 9, vertical: 4),
child: Text(
rangoFechasCorto(locale, rango.inicioDia, rango.finDia),
style: TextStyle(
fontSize: 10.5,
fontWeight: FontWeight.w800,
color: tokens.liveGreen,
),
),
),
);
}
}
/// Audit 8.4 (t4 lines 383-390): one circular weekday button in the alarm
/// editor's REPETIR row — was a `FilterChip`. `aspect-ratio:1` in the
/// prototype is achieved here by the caller wrapping each instance in an
@@ -1375,3 +1534,17 @@ String _weekdayShort(AppLocalizations l10n, int day) => switch (day) {
// S5-R4: short dates follow the active locale (en-US = M/D/Y, ja = Y/M/D).
String _fechaCorta(AppLocalizations l10n, DateTime fecha) =>
fechaCortaLocalizada(l10n.localeName, fecha);
/// Audit 7.4 (t4:339): a compact recurrence label next to the alarm card's
/// giant time. Reuses the SAME generic labels the editor's own
/// `TipoProgramacionAlarma` `SegmentedButton` already shows (`oneTimeOption`
/// / `dailyOption` / `weekdaysOption`) rather than inventing a new, more
/// specific ARB string -- honest given the space (12px, next to a 34px
/// time) genuinely only fits a short word, not a full weekday list.
String _recurrenciaCorta(AppLocalizations l10n, AlarmaMusical alarma) {
return switch (alarma.tipoProgramacion) {
TipoProgramacionAlarma.diaria => l10n.dailyOption,
TipoProgramacionAlarma.diasSemana => l10n.weekdaysOption,
TipoProgramacionAlarma.unica => l10n.oneTimeOption,
};
}