From c7d137c82ecad978a03554cb15b143d1b46d6ed5 Mon Sep 17 00:00:00 2001 From: freetlab Date: Thu, 30 Jul 2026 16:30:20 +0200 Subject: [PATCH] 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. --- lib/l10n/formato_fechas.dart | 18 ++ lib/pantallas/pantalla_alarmas.dart | 287 ++++++++++++++---- .../pantalla_alarmas_tier5_test.dart | 193 ++++++++++++ 3 files changed, 441 insertions(+), 57 deletions(-) create mode 100644 test/pantallas/pantalla_alarmas_tier5_test.dart diff --git a/lib/l10n/formato_fechas.dart b/lib/l10n/formato_fechas.dart index d027f95..5607177 100644 --- a/lib/l10n/formato_fechas.dart +++ b/lib/l10n/formato_fechas.dart @@ -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 "d–d MON" range pill, locale-aware month abbreviation, uppercased +/// (e.g. "4–18 AGO" for `es`, "4–18 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'; +} diff --git a/lib/pantallas/pantalla_alarmas.dart b/lib/pantallas/pantalla_alarmas.dart index a879918..40b70b6 100644 --- a/lib/pantallas/pantalla_alarmas.dart +++ b/lib/pantallas/pantalla_alarmas.dart @@ -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, + }; +} diff --git a/test/pantallas/pantalla_alarmas_tier5_test.dart b/test/pantallas/pantalla_alarmas_tier5_test.dart new file mode 100644 index 0000000..2570dc1 --- /dev/null +++ b/test/pantallas/pantalla_alarmas_tier5_test.dart @@ -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 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.value(value: estadoRadio), + ChangeNotifierProvider.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(find.byType(FilledButton)); + final resuelto = boton.style?.backgroundColor?.resolve({}); + 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( + 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('4–18 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, + ); + }, + ); + }); +}