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.
This commit is contained in:
2026-07-30 10:08:54 +02:00
parent 653848899f
commit 2255374291
2 changed files with 250 additions and 28 deletions
+142 -28
View File
@@ -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});
@@ -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<DecoratedBox>(
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<Container>(
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<DecoratedBox>(
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<SonidoInternoAlarma>),
findsOneWidget,
);
},
);
});
group('WU8 — tarjeta de alarma simplificada', () {
testWidgets(
'tocar la tarjeta abre el editor precargado con los datos de esa '