From 225537429131b5eae23c22ed18e52ee9f2d22205 Mon Sep 17 00:00:00 2001 From: freetlab Date: Thu, 30 Jul 2026 10:08:54 +0200 Subject: [PATCH] fix(alarmas): restyle the editor sheet as the prototype draws it Audit 8.1-8.4: opaque bottom-anchored sheet with a grab handle, a framed time card and 7 circular day buttons. The date field, fallback-station picker and sound dropdown all stay reachable -- the prototype omits them, but presentation changes never remove capability. --- lib/pantallas/pantalla_alarmas.dart | 170 +++++++++++++++--- .../pantalla_alarmas_editor_test.dart | 108 +++++++++++ 2 files changed, 250 insertions(+), 28 deletions(-) diff --git a/lib/pantallas/pantalla_alarmas.dart b/lib/pantallas/pantalla_alarmas.dart index 89f431b..86a591b 100644 --- a/lib/pantallas/pantalla_alarmas.dart +++ b/lib/pantallas/pantalla_alarmas.dart @@ -416,11 +416,18 @@ class _EditorAlarmaSheetState extends State<_EditorAlarmaSheet> { }); } final favoritas = _favoritasConSeleccion(radio.listaFavoritos); - return Padding( - padding: EdgeInsets.fromLTRB(12, 12, 12, bottom + 12), - child: PluriGlassSurface( - borderRadius: BorderRadius.circular(28), - padding: const EdgeInsets.all(18), + // Audit 8.1 (t4 lines 372-374): opaque, bottom-anchored, full-bleed + // sheet with rounded TOP corners only and a grab handle — was a + // floating card (glass, 12px margin on all 4 sides, uniform radius). + return DecoratedBox( + key: const ValueKey('alarm-editor-sheet-surface'), + decoration: const BoxDecoration( + color: Color(0xFF0D1B24), + borderRadius: BorderRadius.vertical(top: Radius.circular(30)), + border: Border(top: BorderSide(color: Color(0x1CFFFFFF))), + ), + child: Padding( + padding: EdgeInsets.fromLTRB(20, 14, 20, bottom + 24), child: Material( type: MaterialType.transparency, child: SingleChildScrollView( @@ -428,6 +435,18 @@ class _EditorAlarmaSheetState extends State<_EditorAlarmaSheet> { mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ + Center( + child: Container( + key: const ValueKey('alarm-editor-grab-handle'), + width: 44, + height: 4, + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.22), + borderRadius: BorderRadius.circular(2), + ), + ), + ), + const SizedBox(height: 14), Row( children: [ _AssetIcon( @@ -461,10 +480,28 @@ class _EditorAlarmaSheetState extends State<_EditorAlarmaSheet> { // WU10: the native showTimePicker dialog is replaced by a // giant inline HH:MM editor (drag/tap to adjust); see // `EditorHoraInline`, standalone-tested on its own. - Center( - child: EditorHoraInline( - value: _hora, - onChanged: (nuevo) => setState(() => _hora = nuevo), + // + // Audit 8.3 (t4 line 378): framed in its own card — radius + // 22, `listSurface` (#102532), a thin border — instead of + // sitting bare on the sheet background. `EditorHoraInline` + // itself is unchanged (no other consumer to keep in sync). + DecoratedBox( + key: const ValueKey('alarm-editor-hour-card'), + decoration: BoxDecoration( + color: context.pluriTokens.listSurface, + borderRadius: BorderRadius.circular(22), + border: Border.all( + color: Colors.white.withValues(alpha: 0.08), + ), + ), + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 18), + child: Center( + child: EditorHoraInline( + value: _hora, + onChanged: (nuevo) => setState(() => _hora = nuevo), + ), + ), ), ), const SizedBox(height: 16), @@ -491,27 +528,39 @@ class _EditorAlarmaSheetState extends State<_EditorAlarmaSheet> { // WU10: weekday circles are now ALWAYS visible (previously // only inserted into the tree in diasSemana mode) — matching // the mockup, which shows them unconditionally under the - // giant time. They stay disabled (onSelected: null, the - // standard Material "greyed out" FilterChip state) outside - // diasSemana mode rather than being wired to silently mutate - // `_diasSemana` while a different `_tipo` is saved — no - // scheduling-data-model change, presentation only. - Wrap( - spacing: 6, + // giant time. They stay disabled (habilitado: false, the + // same "greyed out, non-interactive" state the previous + // FilterChip used) outside diasSemana mode rather than + // being wired to silently mutate `_diasSemana` while a + // different `_tipo` is saved — no scheduling-data-model + // change, presentation only. + // + // Audit 8.4 (t4 lines 383-390): 7 circles (flex:1 each, + // aspect-ratio:1, gap 7) — was a `Wrap` of `FilterChip`s. + Row( + key: const ValueKey('alarm-weekday-circles'), children: [ - for (var i = DateTime.monday; i <= DateTime.sunday; i++) - FilterChip( - label: Text(_weekdayShort(l10n, i)), - selected: _diasSemana.contains(i), - onSelected: - _tipo == TipoProgramacionAlarma.diasSemana - ? (selected) => setState(() { - selected - ? _diasSemana.add(i) - : _diasSemana.remove(i); - }) - : null, + for ( + var i = DateTime.monday; + i <= DateTime.sunday; + i++ + ) ...[ + if (i > DateTime.monday) const SizedBox(width: 7), + Expanded( + child: _CirculoDiaSemana( + label: _weekdayShort(l10n, i), + seleccionado: _diasSemana.contains(i), + habilitado: + _tipo == TipoProgramacionAlarma.diasSemana, + onTap: + () => setState(() { + _diasSemana.contains(i) + ? _diasSemana.remove(i) + : _diasSemana.add(i); + }), + ), ), + ], ], ), const SizedBox(height: 12), @@ -1112,6 +1161,71 @@ class _PanelVacaciones extends StatelessWidget { } } +/// 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 +/// `Expanded` inside an `AspectRatio`-free `Row` — this widget's own +/// `AspectRatio(1)` does the squaring regardless of the column width the +/// `Row` assigns it. +class _CirculoDiaSemana extends StatelessWidget { + const _CirculoDiaSemana({ + required this.label, + required this.seleccionado, + required this.habilitado, + required this.onTap, + }); + + final String label; + final bool seleccionado; + final bool habilitado; + final VoidCallback onTap; + + /// t4 lines 384-388: the selected circle's TEXT is this specific dark + /// literal — this theme's `colorScheme.onPrimary` is `Colors.white` + /// (`pluriwave_theme.dart:23`), which would fail contrast against the + /// bright cyan fill. No existing token matches this exact value. + static const _textoSobreBrand = Color(0xFF062126); + + @override + Widget build(BuildContext context) { + final tokens = context.pluriTokens; + final colorFondo = + seleccionado ? PluriWaveTokens.brand : tokens.listSurface; + final colorTexto = + seleccionado + ? _textoSobreBrand + : Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.6); + + return AspectRatio( + aspectRatio: 1, + child: Material( + color: colorFondo, + shape: CircleBorder( + side: + seleccionado + ? BorderSide.none + : BorderSide(color: Colors.white.withValues(alpha: 0.1)), + ), + child: InkWell( + customBorder: const CircleBorder(), + onTap: habilitado ? onTap : null, + child: Center( + child: Text( + label, + style: TextStyle( + fontSize: 13, + fontWeight: seleccionado ? FontWeight.w800 : FontWeight.w700, + color: + habilitado ? colorTexto : colorTexto.withValues(alpha: 0.4), + ), + ), + ), + ), + ), + ); + } +} + class _AssetIcon extends StatelessWidget { const _AssetIcon(this.asset, {this.size = 44, this.semanticLabel}); diff --git a/test/pantallas/pantalla_alarmas_editor_test.dart b/test/pantallas/pantalla_alarmas_editor_test.dart index 57955fd..01c6ef7 100644 --- a/test/pantallas/pantalla_alarmas_editor_test.dart +++ b/test/pantallas/pantalla_alarmas_editor_test.dart @@ -6,6 +6,7 @@ import 'package:pluriwave/l10n/gen/app_localizations.dart'; import 'package:pluriwave/modelos/alarma_musical.dart'; import 'package:pluriwave/pantallas/pantalla_alarmas.dart'; import 'package:pluriwave/servicios/servicio_alarmas.dart'; +import 'package:pluriwave/widgets/editor_hora_inline.dart'; import 'package:pluriwave/widgets/pluri_push_scaffold.dart'; import 'package:provider/provider.dart'; import 'package:shared_preferences/shared_preferences.dart'; @@ -294,6 +295,113 @@ void main() { expect(volumen.min, 0.0); }); + group('visual fidelity (audit 8.1/8.3/8.4, item 20)', () { + testWidgets( + '8.1: the sheet is an opaque, bottom-anchored surface (radius on the ' + 'top corners only) with a grab handle', + (tester) async { + await _abrirEditor(tester); + + final surface = tester.widget( + find.byKey(const ValueKey('alarm-editor-sheet-surface')), + ); + final decoracion = surface.decoration as BoxDecoration; + expect( + decoracion.color, + const Color(0xFF0D1B24), + reason: 't4 line 373: background:#0D1B24 (opaque, not glass)', + ); + expect( + decoracion.borderRadius, + const BorderRadius.vertical(top: Radius.circular(30)), + reason: + 't4 line 373: border-radius:30px 30px 0 0 — top corners ' + 'only, unlike a floating dialog', + ); + + final asa = tester.widget( + find.byKey(const ValueKey('alarm-editor-grab-handle')), + ); + expect(asa.constraints?.maxWidth, 44); + expect(asa.constraints?.maxHeight, 4); + final asaDecoracion = asa.decoration as BoxDecoration; + expect( + asaDecoracion.borderRadius, + BorderRadius.circular(2), + reason: 't4 line 374: width:44px;height:4px;border-radius:2px', + ); + }, + ); + + testWidgets('8.3: the hour editor sits inside a framed card', ( + tester, + ) async { + await _abrirEditor(tester); + + final tarjeta = tester.widget( + find.ancestor( + of: find.byType(EditorHoraInline), + matching: find.byKey(const ValueKey('alarm-editor-hour-card')), + ), + ); + final decoracion = tarjeta.decoration as BoxDecoration; + expect( + decoracion.borderRadius, + BorderRadius.circular(22), + reason: 't4 line 378: border-radius:22px', + ); + expect( + decoracion.color, + const Color(0xFF102532), + reason: 't4 line 378: background:#102532 (== listSurface token)', + ); + }); + + testWidgets( + '8.4: the weekday selector is exactly 7 circular buttons, not chips', + (tester) async { + await _abrirEditor(tester); + + final fila = find.byKey(const ValueKey('alarm-weekday-circles')); + expect(fila, findsOneWidget); + expect( + find.descendant(of: fila, matching: find.byType(FilterChip)), + findsNothing, + reason: 't4 lines 383-390: 7 circles, not FilterChips', + ); + final circulos = find.descendant( + of: fila, + matching: find.byWidgetPredicate( + (w) => w is Material && w.shape is CircleBorder, + ), + ); + expect(circulos, findsNWidgets(7)); + }, + ); + + testWidgets( + 'capacity guard: the date field, the fallback-station picker and the ' + 'sound dropdown all stay reachable behind Advanced', + (tester) async { + await _abrirEditor(tester); + + await tester.ensureVisible(find.text(l10n.alarmAdvancedSectionTitle)); + await tester.tap(find.text(l10n.alarmAdvancedSectionTitle)); + await tester.pumpAndSettle(); + + expect(find.text(l10n.dateField), findsOneWidget); + expect( + find.byKey(const ValueKey('alarm-fallback-station-field')), + findsOneWidget, + ); + expect( + find.byType(DropdownButtonFormField), + findsOneWidget, + ); + }, + ); + }); + group('WU8 — tarjeta de alarma simplificada', () { testWidgets( 'tocar la tarjeta abre el editor precargado con los datos de esa '