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
+18
View File
@@ -22,3 +22,21 @@ String diaMesLocalizado(String localeTag, DateTime fecha) =>
/// audit 9b.4 (t4:459), the small caption under the day-month.
String nombreDiaSemanaLocalizado(String localeTag, DateTime fecha) =>
DateFormat.EEEE(localeTag).format(fecha);
/// Full weekday + month + day, locale-aware (e.g. "lunes, 3 de agosto" for
/// `es`, "Monday, August 3" for `en`) — audit 9.4 (t4:419), the ringing
/// screen's date line between the schedule pill and the hero time.
String fechaLargaConDiaSemana(String localeTag, DateTime fecha) =>
DateFormat.MMMMEEEEd(localeTag).format(fecha);
/// Short "dd MON" range pill, locale-aware month abbreviation, uppercased
/// (e.g. "418 AGO" for `es`, "418 AUG" for `en`) — audit 7.3 (t4:332),
/// the vacation-row date-range pill on the Alarmas root. Always labels the
/// range with the END date's month: vacation ranges are short (days to a
/// couple of weeks), so a cross-month span is the rare case, and the
/// prototype itself only ever shows a single abbreviation.
String rangoFechasCorto(String localeTag, DateTime inicio, DateTime fin) {
final dia = DateFormat.d(localeTag);
final mes = DateFormat.MMM(localeTag).format(fin).toUpperCase();
return '${dia.format(inicio)}${dia.format(fin)} $mes';
}
+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,
};
}
@@ -0,0 +1,193 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:pluriwave/estado/estado_alarmas.dart';
import 'package:pluriwave/estado/estado_radio.dart';
import 'package:pluriwave/l10n/gen/app_localizations.dart';
import 'package:pluriwave/modelos/alarma_musical.dart';
import 'package:pluriwave/modelos/emisora.dart';
import 'package:pluriwave/pantallas/pantalla_alarmas.dart';
import 'package:pluriwave/servicios/servicio_alarmas.dart';
import 'package:pluriwave/tema/pluriwave_tokens.dart';
import 'package:provider/provider.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../helpers/fakes.dart';
import '../helpers/fakes_alarmas.dart';
/// Tier 5 visual-fidelity closeout (items 7.1-7.4, audit id 2521) --
/// screens 4-14 batch.
void main() {
setUp(() {
SharedPreferences.setMockInitialValues({});
});
Future<void> montar(
WidgetTester tester, {
required EstadoAlarmas estadoAlarmas,
EstadoRadio? radio,
}) async {
tester.view.physicalSize = const Size(1440, 3200);
tester.view.devicePixelRatio = 1.0;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
final estadoRadio =
radio ??
EstadoRadio(
audio: FakeServicioAudio(),
favoritos: FakeServicioFavoritos(),
radio: FakeServicioRadio(),
servicioEcualizador: FakeServicioEcualizador(),
servicioGrabacion: FakeServicioGrabacionRadioInactiva(),
iniciarAutomaticamente: false,
);
if (radio == null) addTearDown(estadoRadio.dispose);
await tester.pumpWidget(
MultiProvider(
providers: [
ChangeNotifierProvider<EstadoRadio>.value(value: estadoRadio),
ChangeNotifierProvider<EstadoAlarmas>.value(value: estadoAlarmas),
],
child: MaterialApp(
locale: const Locale('en'),
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: const Scaffold(body: PantallaAlarmas()),
),
),
);
await tester.pump();
await tester.pump(const Duration(milliseconds: 100));
}
EstadoAlarmas crearEstado() {
final android = FakePuertoAlarmasAndroid();
final estado = EstadoAlarmas(
servicio: ServicioAlarmas(reloj: () => DateTime(2026, 6, 11, 6, 0)),
android: android,
iniciarAutomaticamente: false,
);
return estado;
}
group('visual fidelity (audit 7.1): "New" action', () {
testWidgets('is a solid brand-teal pill with an add glyph, not the tonal '
'auto_awesome button (t4:325)', (tester) async {
final estado = crearEstado();
addTearDown(estado.dispose);
await montar(tester, estadoAlarmas: estado);
expect(find.byIcon(Icons.auto_awesome_rounded), findsNothing);
expect(find.byIcon(Icons.add_rounded), findsOneWidget);
final l10n = lookupAppLocalizations(const Locale('en'));
final boton = tester.widget<FilledButton>(find.byType(FilledButton));
final resuelto = boton.style?.backgroundColor?.resolve(<WidgetState>{});
expect(resuelto, PluriWaveTokens.brand);
expect(find.text(l10n.createAlarmAction), findsOneWidget);
});
});
group('visual fidelity (audit 7.2): next-alarm banner', () {
testWidgets(
'is warmCoral-tinted and the Skip chip sits BESIDE the text, not '
'below it (t4:327-331)',
(tester) async {
final estado = crearEstado();
addTearDown(estado.dispose);
await estado.guardarAlarma(
const AlarmaMusical(
id: 'proxima',
nombre: 'Despertar',
hora: 7,
minuto: 30,
tipoProgramacion: TipoProgramacionAlarma.diaria,
diasSemana: [],
),
);
await montar(tester, estadoAlarmas: estado);
final l10n = lookupAppLocalizations(const Locale('en'));
final banner = tester.widget<DecoratedBox>(
find.byKey(const ValueKey('next-alarm-banner')),
);
final decoration = banner.decoration as BoxDecoration;
expect(
decoration.color,
const Color(0xFFF4B860).withValues(alpha: 0.13),
);
final skipY = tester.getCenter(find.text(l10n.alarmHeroSkipAction)).dy;
final titleY = tester.getCenter(find.byIcon(Icons.alarm_on)).dy;
expect(
(skipY - titleY).abs() < 4,
isTrue,
reason: 'the Skip chip sits on the SAME row as the icon/text',
);
},
);
});
group('visual fidelity (audit 7.3): vacation row date pill', () {
testWidgets('shows the upcoming range as a "d-d MON" pill (t4:332)', (
tester,
) async {
final estado = crearEstado();
addTearDown(estado.dispose);
await estado.crearRangoVacaciones(
estado.servicio.crearRangoVacaciones(
inicio: DateTime(2026, 8, 4),
fin: DateTime(2026, 8, 18),
nombre: 'Summer',
),
);
await montar(tester, estadoAlarmas: estado);
expect(find.text('418 AUG'), findsOneWidget);
});
});
group('visual fidelity (audit 7.4): alarm card', () {
testWidgets(
'shows a recurrence label next to the time and a station icon next '
'to the station name (t4:337-345)',
(tester) async {
final estado = crearEstado();
addTearDown(estado.dispose);
await estado.guardarAlarma(
const AlarmaMusical(
id: 'tarjeta',
nombre: 'Despertar',
hora: 7,
minuto: 30,
tipoProgramacion: TipoProgramacionAlarma.diaria,
diasSemana: [],
emisora: Emisora(
uuid: 'e1',
nombre: 'Radio Uno',
url: 'https://radio.example/stream',
),
),
);
await montar(tester, estadoAlarmas: estado);
final l10n = lookupAppLocalizations(const Locale('en'));
expect(
find.descendant(
of: find.byKey(const ValueKey('tarjeta-alarma-tarjeta')),
matching: find.text(l10n.dailyOption),
),
findsOneWidget,
);
expect(
find.descendant(
of: find.byKey(const ValueKey('tarjeta-alarma-tarjeta')),
matching: find.byKey(const ValueKey('tarjeta-alarma-arte')),
),
findsOneWidget,
);
},
);
});
}