feat(ui): design token discipline, accessibility and i18n pass

- Replace all hardcoded Color literals outside lib/tema with theme tokens (new static brand palette in PluriWaveTokens); media notification uses the brand color instead of the Material default purple
- Favorite button on station cards grows to a 48dp target and becomes an independent semantics node for screen readers (Semantics container fix)
- All flutter_animate call sites route through the PluriAnimate reduced-motion gate (zero direct .animate() left)
- Locale-aware short dates via intl DateFormat (new lib/l10n/formato_fechas.dart) replacing the hardcoded DD/MM/YYYY; proper plural messages for the favorites counter; example stream URL as a localized key - all 13 locales
- Rounded shimmer placeholders matching card radii; shimmer loading state in search instead of a bare spinner; rounded icon variants unified in settings; bottom-sheet conventions on the custom station form
- Fix latent debug crash: vacation editor read AppLocalizations in initState
- 11 new tests (121 total green), flutter analyze clean
This commit is contained in:
2026-06-11 23:42:16 +02:00
parent 52855e75c2
commit 202bef3539
49 changed files with 1108 additions and 175 deletions
+50 -21
View File
@@ -4,11 +4,14 @@ import 'package:provider/provider.dart';
import '../estado/estado_alarmas.dart';
import '../estado/estado_radio.dart';
import '../l10n/display_names.dart';
import '../l10n/formato_fechas.dart';
import '../l10n/app_localizations_ext.dart';
import '../l10n/gen/app_localizations.dart';
import '../modelos/alarma_musical.dart';
import '../modelos/emisora.dart';
import '../servicios/servicio_programacion_alarmas.dart';
import '../tema/pluriwave_theme.dart';
import '../tema/pluriwave_tokens.dart';
import '../widgets/pluri_glass_surface.dart';
import '../widgets/pluri_icon.dart';
import '../widgets/pluri_layout.dart';
@@ -92,10 +95,14 @@ class _PanelProximaAlarma extends StatelessWidget {
final proximaProgramable = proxima?.proximaProgramable;
return PluriGlassSurface(
glowColor: const Color(0xFFFFB86B).withValues(alpha: 0.28),
glowColor: context.pluriTokens.warmCoral.withValues(alpha: 0.28),
child: Row(
children: [
const _AssetIcon('assets/icons/alarmas/alarm_music.png', size: 72),
_AssetIcon(
'assets/icons/alarmas/alarm_music.png',
size: 72,
semanticLabel: l10n.alarmIconLabel,
),
const SizedBox(width: 14),
Expanded(
child: Column(
@@ -142,15 +149,16 @@ class _TarjetaAlarma extends StatelessWidget {
final excepcion = estado.ultimaExcepcionPara(alarma.id);
final mensajeVacaciones = _mensajeVacaciones(l10n, estado.vacaciones);
return PluriGlassSurface(
glowColor: const Color(0xFF22D3EE).withValues(alpha: 0.22),
glowColor: context.pluriTokens.electricMagenta.withValues(alpha: 0.22),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const _AssetIcon(
_AssetIcon(
'assets/icons/alarmas/alarm_music.png',
size: 64,
semanticLabel: l10n.alarmIconLabel,
),
const SizedBox(width: 12),
Expanded(
@@ -435,9 +443,10 @@ class _EditorAlarmaSheetState extends State<_EditorAlarmaSheet> {
children: [
Row(
children: [
const _AssetIcon(
_AssetIcon(
'assets/icons/alarmas/alarm_music.png',
size: 58,
semanticLabel: l10n.alarmIconLabel,
),
const SizedBox(width: 12),
Expanded(
@@ -477,7 +486,7 @@ class _EditorAlarmaSheetState extends State<_EditorAlarmaSheet> {
child: _PickerButton(
icon: Icons.event_rounded,
label: l10n.dateField,
value: _fechaCorta(_fecha),
value: _fechaCorta(l10n, _fecha),
onTap:
_tipo == TipoProgramacionAlarma.unica
? _elegirFecha
@@ -663,9 +672,10 @@ class _EditorAlarmaSheetState extends State<_EditorAlarmaSheet> {
value: _sonarEnVacaciones,
onChanged:
(value) => setState(() => _sonarEnVacaciones = value),
secondary: const _AssetIcon(
secondary: _AssetIcon(
'assets/icons/alarmas/vacation_wave.png',
size: 42,
semanticLabel: l10n.vacationIconLabel,
),
title: Text(l10n.playDuringVacations),
subtitle: Text(l10n.playDuringVacationsHint),
@@ -1010,15 +1020,16 @@ class _PanelVacaciones extends StatelessWidget {
final vacaciones = [...estado.vacaciones]
..sort((a, b) => a.inicioDia.compareTo(b.inicioDia));
return PluriGlassSurface(
glowColor: const Color(0xFF60A5FA).withValues(alpha: 0.22),
glowColor: PluriWaveTokens.skyBlue.withValues(alpha: 0.22),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const _AssetIcon(
_AssetIcon(
'assets/icons/alarmas/vacation_wave.png',
size: 48,
semanticLabel: l10n.vacationIconLabel,
),
const SizedBox(width: 10),
Expanded(
@@ -1047,7 +1058,7 @@ class _PanelVacaciones extends StatelessWidget {
leading: const Icon(Icons.event_busy_rounded),
title: Text(_nombreVisibleVacaciones(l10n, rango)),
subtitle: Text(
'${_fechaCorta(rango.inicioDia)}${_fechaCorta(rango.finDia)}',
'${_fechaCorta(l10n, rango.inicioDia)}${_fechaCorta(l10n, rango.finDia)}',
),
trailing: IconButton(
tooltip: l10n.deleteRangeTooltip,
@@ -1079,7 +1090,9 @@ class _EditorVacacionesSheet extends StatefulWidget {
}
class _EditorVacacionesSheetState extends State<_EditorVacacionesSheet> {
late final TextEditingController _nombreController;
// Created lazily: AppLocalizations.of(context) cannot be read in
// initState (inherited-widget lookup assert in debug builds).
TextEditingController? _nombreController;
late DateTime _inicio;
late DateTime _fin;
@@ -1089,14 +1102,19 @@ class _EditorVacacionesSheetState extends State<_EditorVacacionesSheet> {
final hoy = DateTime.now();
_inicio = DateTime(hoy.year, hoy.month, hoy.day);
_fin = _inicio.add(const Duration(days: 2));
_nombreController = TextEditingController(
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
_nombreController ??= TextEditingController(
text: AppLocalizations.of(context).vacationsDefaultName,
);
}
@override
void dispose() {
_nombreController.dispose();
_nombreController?.dispose();
super.dispose();
}
@@ -1131,7 +1149,7 @@ class _EditorVacacionesSheetState extends State<_EditorVacacionesSheet> {
child: _PickerButton(
icon: Icons.play_arrow_rounded,
label: l10n.startLabel,
value: _fechaCorta(_inicio),
value: _fechaCorta(l10n, _inicio),
onTap: () => _elegirFecha(esInicio: true),
),
),
@@ -1140,7 +1158,7 @@ class _EditorVacacionesSheetState extends State<_EditorVacacionesSheet> {
child: _PickerButton(
icon: Icons.stop_rounded,
label: l10n.endLabel,
value: _fechaCorta(_fin),
value: _fechaCorta(l10n, _fin),
onTap: () => _elegirFecha(esInicio: false),
),
),
@@ -1183,7 +1201,7 @@ class _EditorVacacionesSheetState extends State<_EditorVacacionesSheet> {
final rango = estado.servicio.crearRangoVacaciones(
inicio: _inicio,
fin: _fin,
nombre: _nombreController.text.trim(),
nombre: _nombreController?.text.trim() ?? '',
);
await estado.crearRangoVacaciones(rango);
if (mounted) Navigator.pop(context);
@@ -1191,11 +1209,15 @@ class _EditorVacacionesSheetState extends State<_EditorVacacionesSheet> {
}
class _AssetIcon extends StatelessWidget {
const _AssetIcon(this.asset, {this.size = 44});
const _AssetIcon(this.asset, {this.size = 44, this.semanticLabel});
final String asset;
final double size;
/// S5-R2: meaningful images carry a label; without one the image is
/// treated as decorative and excluded from the semantics tree.
final String? semanticLabel;
@override
Widget build(BuildContext context) {
return Image.asset(
@@ -1203,6 +1225,8 @@ class _AssetIcon extends StatelessWidget {
width: size,
height: size,
fit: BoxFit.contain,
semanticLabel: semanticLabel,
excludeFromSemantics: semanticLabel == null,
errorBuilder:
(_, __, ___) => Icon(Icons.music_note_rounded, size: size * 0.65),
);
@@ -1301,7 +1325,11 @@ class _EmptyAlarmas extends StatelessWidget {
return PluriGlassSurface(
child: Column(
children: [
const _AssetIcon('assets/icons/alarmas/alarm_music.png', size: 92),
_AssetIcon(
'assets/icons/alarmas/alarm_music.png',
size: 92,
semanticLabel: l10n.alarmIconLabel,
),
const SizedBox(height: 12),
Text(l10n.noAlarmsYetTitle),
const SizedBox(height: 4),
@@ -1326,7 +1354,7 @@ String _hora(AlarmaMusical alarma) =>
String _programacion(AppLocalizations l10n, AlarmaMusical alarma) {
return switch (alarma.tipoProgramacion) {
TipoProgramacionAlarma.unica => l10n.alarmScheduleOnce(
_fechaCorta(alarma.fechaUnica ?? DateTime.now()),
_fechaCorta(l10n, alarma.fechaUnica ?? DateTime.now()),
),
TipoProgramacionAlarma.diaria => l10n.dailyOption,
TipoProgramacionAlarma.diasSemana => l10n.alarmScheduleWeekdays(
@@ -1349,5 +1377,6 @@ String _weekdayShort(AppLocalizations l10n, int day) => switch (day) {
_ => '?',
};
String _fechaCorta(DateTime fecha) =>
'${fecha.day.toString().padLeft(2, '0')}/${fecha.month.toString().padLeft(2, '0')}/${fecha.year}';
// 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);