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
+41 -11
View File
@@ -1,6 +1,7 @@
import 'package:flutter/material.dart';
import '../tema/pluriwave_theme.dart';
import '../tema/pluriwave_tokens.dart';
import 'pluri_glass_surface.dart';
import 'pluri_icon.dart';
@@ -38,12 +39,14 @@ class PluriScreenHeader extends StatelessWidget {
shape: BoxShape.circle,
gradient: LinearGradient(
colors: [
const Color(0xFF20E6FF).withValues(alpha: 0.95),
PluriWaveTokens.brightCyan.withValues(alpha: 0.95),
t.electricMagenta,
t.warmCoral,
],
),
boxShadow: [BoxShadow(color: t.glowColor, blurRadius: 28, spreadRadius: 2)],
boxShadow: [
BoxShadow(color: t.glowColor, blurRadius: 28, spreadRadius: 2),
],
),
child: Center(
child: PluriIcon(
@@ -117,14 +120,22 @@ class PluriScreenHeader extends StatelessWidget {
Expanded(child: textBlock()),
if (trailing != null) ...[
const SizedBox(width: 12),
ConstrainedBox(constraints: const BoxConstraints(maxWidth: 220), child: trailing!),
ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 220),
child: trailing!,
),
],
],
);
}
return Padding(
padding: EdgeInsets.fromLTRB(t.spacingMd, t.spacingSm, t.spacingMd, t.spacingSm),
padding: EdgeInsets.fromLTRB(
t.spacingMd,
t.spacingSm,
t.spacingMd,
t.spacingSm,
),
child: PluriGlassSurface(
borderRadius: BorderRadius.circular(t.radiusLg + 8),
padding: EdgeInsets.symmetric(
@@ -164,7 +175,10 @@ class PluriScreenHeader extends StatelessWidget {
Positioned(
right: -36,
top: -42,
child: _Orb(color: t.electricMagenta.withValues(alpha: 0.38), size: 128),
child: _Orb(
color: t.electricMagenta.withValues(alpha: 0.38),
size: 128,
),
),
Positioned(
right: 10,
@@ -182,7 +196,10 @@ class PluriScreenHeader extends StatelessWidget {
Positioned(
right: 44,
bottom: -54,
child: _Orb(color: const Color(0xFF20E6FF).withValues(alpha: 0.22), size: 116),
child: _Orb(
color: PluriWaveTokens.brightCyan.withValues(alpha: 0.22),
size: 116,
),
),
Padding(
padding: EdgeInsets.all(compact ? 2 : 4),
@@ -195,7 +212,6 @@ class PluriScreenHeader extends StatelessWidget {
}
}
class PluriStatusPill extends StatelessWidget {
const PluriStatusPill({
super.key,
@@ -232,7 +248,9 @@ class PluriStatusPill extends StatelessWidget {
label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.labelMedium?.copyWith(fontWeight: FontWeight.w800),
style: Theme.of(
context,
).textTheme.labelMedium?.copyWith(fontWeight: FontWeight.w800),
),
),
],
@@ -267,14 +285,26 @@ class PluriEmptyState extends StatelessWidget {
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
PluriIcon(glyph: glyph, variant: PluriIconVariant.activeGlow, size: 58),
PluriIcon(
glyph: glyph,
variant: PluriIconVariant.activeGlow,
size: 58,
),
const SizedBox(height: 18),
Text(title, textAlign: TextAlign.center, style: theme.textTheme.titleLarge?.copyWith(fontWeight: FontWeight.w900)),
Text(
title,
textAlign: TextAlign.center,
style: theme.textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.w900,
),
),
const SizedBox(height: 8),
Text(
subtitle,
textAlign: TextAlign.center,
style: theme.textTheme.bodyMedium?.copyWith(color: theme.colorScheme.onSurface.withValues(alpha: 0.72)),
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurface.withValues(alpha: 0.72),
),
),
],
),
+19 -7
View File
@@ -1,6 +1,7 @@
import 'package:flutter/material.dart';
import '../tema/pluriwave_theme.dart';
import '../tema/pluriwave_tokens.dart';
class PluriWaveScaffold extends StatelessWidget {
const PluriWaveScaffold({
@@ -31,10 +32,10 @@ class PluriWaveScaffold extends StatelessWidget {
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [
const Color(0xFF07121A),
const Color(0xFF0D1B24),
const Color(0xFF0E4A4F),
const Color(0xFF07121A),
t.deepViolet,
Theme.of(context).colorScheme.surface,
PluriWaveTokens.auroraTeal,
t.deepViolet,
],
stops: const [0, 0.34, 0.68, 1],
),
@@ -45,17 +46,28 @@ class PluriWaveScaffold extends StatelessWidget {
Positioned(
left: -120,
top: -120,
child: _AuroraOrb(size: 300, color: const Color(0xFF21D4D9).withValues(alpha: 0.18)),
child: _AuroraOrb(
size: 300,
color: t.electricMagenta.withValues(alpha: 0.18),
),
),
Positioned(
right: -150,
top: 160,
child: _AuroraOrb(size: 340, color: const Color(0xFF7EE4C2).withValues(alpha: 0.12)),
child: _AuroraOrb(
size: 340,
color: Theme.of(
context,
).colorScheme.secondary.withValues(alpha: 0.12),
),
),
Positioned(
left: -90,
bottom: 80,
child: _AuroraOrb(size: 260, color: t.warmCoral.withValues(alpha: 0.10)),
child: _AuroraOrb(
size: 260,
color: t.warmCoral.withValues(alpha: 0.10),
),
),
Positioned.fill(
child: IgnorePointer(
+109 -43
View File
@@ -8,6 +8,7 @@ import '../l10n/display_names.dart';
import '../l10n/gen/app_localizations.dart';
import '../modelos/emisora.dart';
import '../tema/pluriwave_theme.dart';
import '../tema/pluriwave_tokens.dart';
import 'pluri_glass_surface.dart';
import 'pluri_icon.dart';
@@ -188,9 +189,19 @@ class _TarjetaEmisoraState extends State<TarjetaEmisora> {
decoration: BoxDecoration(
shape: BoxShape.circle,
gradient: SweepGradient(
colors: [t.electricMagenta, const Color(0xFF20E6FF), t.warmCoral, t.electricMagenta],
colors: [
t.electricMagenta,
PluriWaveTokens.brightCyan,
t.warmCoral,
t.electricMagenta,
],
),
boxShadow: [BoxShadow(color: t.glowColor.withValues(alpha: 0.24), blurRadius: 22)],
boxShadow: [
BoxShadow(
color: t.glowColor.withValues(alpha: 0.24),
blurRadius: 22,
),
],
),
),
ClipRRect(
@@ -268,7 +279,11 @@ class _TarjetaEmisoraState extends State<TarjetaEmisora> {
: l10n.favoritesAddTooltip,
);
// S5-R2: container forces an OWN semantics node — without it the card's
// InkWell absorbs the favorite into one merged node and screen readers
// cannot reach the action independently.
return Semantics(
container: true,
button: true,
toggled: esFavorito,
label:
@@ -279,11 +294,8 @@ class _TarjetaEmisoraState extends State<TarjetaEmisora> {
child: InkWell(
customBorder: const CircleBorder(),
onTap: _toggling ? null : _toggle,
child: SizedBox(
width: mini ? 36 : 44,
height: mini ? 36 : 44,
child: Center(child: icono),
),
// S5-R2: 48dp minimum touch target in both variants.
child: SizedBox(width: 48, height: 48, child: Center(child: icono)),
),
),
);
@@ -318,18 +330,21 @@ class _TarjetaEmisoraState extends State<TarjetaEmisora> {
Image.asset(
art,
fit: BoxFit.cover,
errorBuilder: (_, __, ___) => DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [
context.pluriTokens.deepViolet,
context.pluriTokens.electricMagenta.withValues(alpha: 0.8),
],
errorBuilder:
(_, __, ___) => DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [
context.pluriTokens.deepViolet,
context.pluriTokens.electricMagenta.withValues(
alpha: 0.8,
),
],
),
),
),
),
),
),
Center(
child: PluriIcon(
@@ -365,7 +380,10 @@ class _LiveBadge extends StatelessWidget {
final color = Theme.of(context).colorScheme.secondary;
final l10n = AppLocalizations.of(context);
return Container(
padding: EdgeInsets.symmetric(horizontal: mini ? 8 : 6, vertical: mini ? 5 : 4),
padding: EdgeInsets.symmetric(
horizontal: mini ? 8 : 6,
vertical: mini ? 5 : 4,
),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(999),
color: Colors.black.withValues(alpha: 0.35),
@@ -374,10 +392,19 @@ class _LiveBadge extends StatelessWidget {
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.fiber_manual_record_rounded, size: mini ? 10 : 8, color: color),
Icon(
Icons.fiber_manual_record_rounded,
size: mini ? 10 : 8,
color: color,
),
if (mini) ...[
const SizedBox(width: 5),
Text(l10n.liveNow, style: Theme.of(context).textTheme.labelSmall?.copyWith(fontWeight: FontWeight.w900)),
Text(
l10n.liveNow,
style: Theme.of(
context,
).textTheme.labelSmall?.copyWith(fontWeight: FontWeight.w900),
),
],
],
),
@@ -386,35 +413,74 @@ class _LiveBadge extends StatelessWidget {
}
/// Placeholder shimmer para listas en carga.
///
/// S5-R6: corners match the real card radii; [esCompacta] mirrors the
/// compact row layout used in search results.
class TarjetaEmisoraShimmer extends StatelessWidget {
const TarjetaEmisoraShimmer({super.key});
const TarjetaEmisoraShimmer({super.key, this.esCompacta = false});
final bool esCompacta;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final t = context.pluriTokens;
final color = theme.colorScheme.surfaceContainerHighest;
Widget bloque({
double? width,
double? height,
double? radius,
BoxShape shape = BoxShape.rectangle,
}) {
return Container(
width: width,
height: height,
decoration: BoxDecoration(
color: color,
shape: shape,
borderRadius:
shape == BoxShape.circle || radius == null
? null
: BorderRadius.circular(radius),
),
);
}
final contenido =
esCompacta
? Row(
children: [
bloque(width: 58, height: 58, shape: BoxShape.circle),
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
bloque(height: 14, radius: 6),
const SizedBox(height: 6),
bloque(height: 12, width: 90, radius: 6),
],
),
),
],
)
: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
AspectRatio(aspectRatio: 1, child: bloque(radius: t.radiusLg)),
const SizedBox(height: 8),
bloque(height: 14, radius: 6),
const SizedBox(height: 4),
bloque(height: 12, width: 60, radius: 6),
],
);
return Shimmer.fromColors(
baseColor: theme.colorScheme.surfaceContainerHighest,
baseColor: color,
highlightColor: theme.colorScheme.surface,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
AspectRatio(
aspectRatio: 1,
child: Container(color: theme.colorScheme.surfaceContainerHighest),
),
const SizedBox(height: 8),
Container(
height: 14,
color: theme.colorScheme.surfaceContainerHighest,
),
const SizedBox(height: 4),
Container(
height: 12,
width: 60,
color: theme.colorScheme.surfaceContainerHighest,
),
],
),
child: contenido,
);
}
}
+4 -1
View File
@@ -5,6 +5,7 @@ import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../servicios/servicio_audio.dart';
import '../tema/pluriwave_tokens.dart';
/// Visualizador de audio para el reproductor.
///
@@ -234,7 +235,9 @@ class _WaveFlowPainter extends CustomPainter {
colors: [
color.withValues(alpha: 0.08),
color.withValues(alpha: active ? 0.95 : 0.35),
const Color(0xFFF4B860).withValues(alpha: active ? 0.75 : 0.20),
PluriWaveTokens.dark.warmCoral.withValues(
alpha: active ? 0.75 : 0.20,
),
color.withValues(alpha: 0.08),
],
).createShader(Offset.zero & size);