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
+5 -1
View File
@@ -612,5 +612,9 @@
"weekdayShortThursday": "الخميس",
"weekdayShortFriday": "الجمعة",
"weekdayShortSaturday": "السبت",
"weekdayShortSunday": "الأحد"
"weekdayShortSunday": "الأحد",
"stationCount": "{count, plural, one{محطة واحدة} two{محطتان} few{{count} محطات} other{{count} محطة}}",
"alarmIconLabel": "منبه موسيقي",
"vacationIconLabel": "وضع الإجازة",
"streamUrlHint": "https://stream.example.com:8000/radio"
}
+5 -1
View File
@@ -612,5 +612,9 @@
"weekdayShortThursday": "বৃহস্পতি",
"weekdayShortFriday": "শুক্র",
"weekdayShortSaturday": "শনি",
"weekdayShortSunday": "রবি"
"weekdayShortSunday": "রবি",
"stationCount": "{count, plural, =1{১টি স্টেশন} other{{count}টি স্টেশন}}",
"alarmIconLabel": "মিউজিক অ্যালার্ম",
"vacationIconLabel": "ছুটির মোড",
"streamUrlHint": "https://stream.example.com:8000/radio"
}
+5 -1
View File
@@ -612,5 +612,9 @@
"weekdayShortThursday": "Do",
"weekdayShortFriday": "Fr",
"weekdayShortSaturday": "Sa",
"weekdayShortSunday": "So"
"weekdayShortSunday": "So",
"stationCount": "{count, plural, =1{1 Sender} other{{count} Sender}}",
"alarmIconLabel": "Musikwecker",
"vacationIconLabel": "Urlaubsmodus",
"streamUrlHint": "https://stream.example.com:8000/radio"
}
+5 -1
View File
@@ -612,5 +612,9 @@
"weekdayShortThursday": "Thu",
"weekdayShortFriday": "Fri",
"weekdayShortSaturday": "Sat",
"weekdayShortSunday": "Sun"
"weekdayShortSunday": "Sun",
"stationCount": "{count, plural, =1{1 station} other{{count} stations}}",
"alarmIconLabel": "Musical alarm",
"vacationIconLabel": "Vacation mode",
"streamUrlHint": "https://stream.example.com:8000/radio"
}
+12 -2
View File
@@ -568,6 +568,16 @@
"weekdayShortThursday": "Jue",
"weekdayShortFriday": "Vie",
"weekdayShortSaturday": "Sáb",
"weekdayShortSunday": "Dom"
"weekdayShortSunday": "Dom",
"stationCount": "{count, plural, =1{1 emisora} other{{count} emisoras}}",
"alarmIconLabel": "Alarma musical",
"vacationIconLabel": "Modo vacaciones",
"streamUrlHint": "https://stream.example.com:8000/radio",
"@stationCount": {
"placeholders": {
"count": {
"type": "int"
}
}
}
}
+5 -1
View File
@@ -612,5 +612,9 @@
"weekdayShortThursday": "Jeu",
"weekdayShortFriday": "Ven",
"weekdayShortSaturday": "Sam",
"weekdayShortSunday": "Dim"
"weekdayShortSunday": "Dim",
"stationCount": "{count, plural, =1{1 station} other{{count} stations}}",
"alarmIconLabel": "Alarme musicale",
"vacationIconLabel": "Mode vacances",
"streamUrlHint": "https://stream.example.com:8000/radio"
}
+5 -1
View File
@@ -612,5 +612,9 @@
"weekdayShortThursday": "गुरु",
"weekdayShortFriday": "शुक्र",
"weekdayShortSaturday": "शनि",
"weekdayShortSunday": "रवि"
"weekdayShortSunday": "रवि",
"stationCount": "{count, plural, =1{1 स्टेशन} other{{count} स्टेशन}}",
"alarmIconLabel": "संगीत अलार्म",
"vacationIconLabel": "अवकाश मोड",
"streamUrlHint": "https://stream.example.com:8000/radio"
}
+5 -1
View File
@@ -612,5 +612,9 @@
"weekdayShortThursday": "Kam",
"weekdayShortFriday": "Jum",
"weekdayShortSaturday": "Sab",
"weekdayShortSunday": "Min"
"weekdayShortSunday": "Min",
"stationCount": "{count, plural, other{{count} stasiun}}",
"alarmIconLabel": "Alarm musik",
"vacationIconLabel": "Mode liburan",
"streamUrlHint": "https://stream.example.com:8000/radio"
}
+5 -1
View File
@@ -612,5 +612,9 @@
"weekdayShortThursday": "Gio",
"weekdayShortFriday": "Ven",
"weekdayShortSaturday": "Sab",
"weekdayShortSunday": "Dom"
"weekdayShortSunday": "Dom",
"stationCount": "{count, plural, =1{1 stazione} other{{count} stazioni}}",
"alarmIconLabel": "Sveglia musicale",
"vacationIconLabel": "Modalità vacanza",
"streamUrlHint": "https://stream.example.com:8000/radio"
}
+5 -1
View File
@@ -612,5 +612,9 @@
"weekdayShortThursday": "木",
"weekdayShortFriday": "金",
"weekdayShortSaturday": "土",
"weekdayShortSunday": "日"
"weekdayShortSunday": "日",
"stationCount": "{count, plural, other{{count}局}}",
"alarmIconLabel": "ミュージックアラーム",
"vacationIconLabel": "休暇モード",
"streamUrlHint": "https://stream.example.com:8000/radio"
}
+5 -1
View File
@@ -612,5 +612,9 @@
"weekdayShortThursday": "Qui",
"weekdayShortFriday": "Sex",
"weekdayShortSaturday": "Sáb",
"weekdayShortSunday": "Dom"
"weekdayShortSunday": "Dom",
"stationCount": "{count, plural, =1{1 estação} other{{count} estações}}",
"alarmIconLabel": "Alarme musical",
"vacationIconLabel": "Modo férias",
"streamUrlHint": "https://stream.example.com:8000/radio"
}
+5 -1
View File
@@ -612,5 +612,9 @@
"weekdayShortThursday": "Чт",
"weekdayShortFriday": "Пт",
"weekdayShortSaturday": "Сб",
"weekdayShortSunday": "Вс"
"weekdayShortSunday": "Вс",
"stationCount": "{count, plural, one{{count} станция} few{{count} станции} other{{count} станций}}",
"alarmIconLabel": "Музыкальный будильник",
"vacationIconLabel": "Режим отпуска",
"streamUrlHint": "https://stream.example.com:8000/radio"
}
+5 -1
View File
@@ -612,5 +612,9 @@
"weekdayShortThursday": "周四",
"weekdayShortFriday": "周五",
"weekdayShortSaturday": "周六",
"weekdayShortSunday": "周日"
"weekdayShortSunday": "周日",
"stationCount": "{count, plural, other{{count} 个电台}}",
"alarmIconLabel": "音乐闹钟",
"vacationIconLabel": "假期模式",
"streamUrlHint": "https://stream.example.com:8000/radio"
}
+13
View File
@@ -0,0 +1,13 @@
import 'package:intl/intl.dart';
/// Locale-aware short date (S5-R4).
///
/// Replaces the old hardcoded `DD/MM/YYYY` pattern, which was wrong for
/// locales like en-US (M/D/Y) or ja (Y/M/D). [localeTag] accepts both
/// BCP-47 ('en-US') and ICU ('en_US') forms intl canonicalizes them.
///
/// Date symbols for the active locale are loaded by
/// GlobalMaterialLocalizations, so any widget below MaterialApp can call
/// this safely.
String fechaCortaLocalizada(String localeTag, DateTime fecha) =>
DateFormat.yMd(localeTag).format(fecha);
+24
View File
@@ -2245,6 +2245,30 @@ abstract class AppLocalizations {
/// In es, this message translates to:
/// **'Dom'**
String get weekdayShortSunday;
/// No description provided for @stationCount.
///
/// In es, this message translates to:
/// **'{count, plural, =1{1 emisora} other{{count} emisoras}}'**
String stationCount(int count);
/// No description provided for @alarmIconLabel.
///
/// In es, this message translates to:
/// **'Alarma musical'**
String get alarmIconLabel;
/// No description provided for @vacationIconLabel.
///
/// In es, this message translates to:
/// **'Modo vacaciones'**
String get vacationIconLabel;
/// No description provided for @streamUrlHint.
///
/// In es, this message translates to:
/// **'https://stream.example.com:8000/radio'**
String get streamUrlHint;
}
class _AppLocalizationsDelegate
+22
View File
@@ -1203,4 +1203,26 @@ class AppLocalizationsAr extends AppLocalizations {
@override
String get weekdayShortSunday => 'الأحد';
@override
String stationCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count محطة',
few: '$count محطات',
two: 'محطتان',
one: 'محطة واحدة',
);
return '$_temp0';
}
@override
String get alarmIconLabel => 'منبه موسيقي';
@override
String get vacationIconLabel => 'وضع الإجازة';
@override
String get streamUrlHint => 'https://stream.example.com:8000/radio';
}
+20
View File
@@ -1212,4 +1212,24 @@ class AppLocalizationsBn extends AppLocalizations {
@override
String get weekdayShortSunday => 'রবি';
@override
String stationCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$countটি স্টেশন',
one: '১টি স্টেশন',
);
return '$_temp0';
}
@override
String get alarmIconLabel => 'মিউজিক অ্যালার্ম';
@override
String get vacationIconLabel => 'ছুটির মোড';
@override
String get streamUrlHint => 'https://stream.example.com:8000/radio';
}
+20
View File
@@ -1222,4 +1222,24 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get weekdayShortSunday => 'So';
@override
String stationCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count Sender',
one: '1 Sender',
);
return '$_temp0';
}
@override
String get alarmIconLabel => 'Musikwecker';
@override
String get vacationIconLabel => 'Urlaubsmodus';
@override
String get streamUrlHint => 'https://stream.example.com:8000/radio';
}
+20
View File
@@ -1208,4 +1208,24 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get weekdayShortSunday => 'Sun';
@override
String stationCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count stations',
one: '1 station',
);
return '$_temp0';
}
@override
String get alarmIconLabel => 'Musical alarm';
@override
String get vacationIconLabel => 'Vacation mode';
@override
String get streamUrlHint => 'https://stream.example.com:8000/radio';
}
+20
View File
@@ -1217,4 +1217,24 @@ class AppLocalizationsEs extends AppLocalizations {
@override
String get weekdayShortSunday => 'Dom';
@override
String stationCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count emisoras',
one: '1 emisora',
);
return '$_temp0';
}
@override
String get alarmIconLabel => 'Alarma musical';
@override
String get vacationIconLabel => 'Modo vacaciones';
@override
String get streamUrlHint => 'https://stream.example.com:8000/radio';
}
+20
View File
@@ -1227,4 +1227,24 @@ class AppLocalizationsFr extends AppLocalizations {
@override
String get weekdayShortSunday => 'Dim';
@override
String stationCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count stations',
one: '1 station',
);
return '$_temp0';
}
@override
String get alarmIconLabel => 'Alarme musicale';
@override
String get vacationIconLabel => 'Mode vacances';
@override
String get streamUrlHint => 'https://stream.example.com:8000/radio';
}
+20
View File
@@ -1211,4 +1211,24 @@ class AppLocalizationsHi extends AppLocalizations {
@override
String get weekdayShortSunday => 'रवि';
@override
String stationCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count स्टेशन',
one: '1 स्टेशन',
);
return '$_temp0';
}
@override
String get alarmIconLabel => 'संगीत अलार्म';
@override
String get vacationIconLabel => 'अवकाश मोड';
@override
String get streamUrlHint => 'https://stream.example.com:8000/radio';
}
+19
View File
@@ -1217,4 +1217,23 @@ class AppLocalizationsId extends AppLocalizations {
@override
String get weekdayShortSunday => 'Min';
@override
String stationCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count stasiun',
);
return '$_temp0';
}
@override
String get alarmIconLabel => 'Alarm musik';
@override
String get vacationIconLabel => 'Mode liburan';
@override
String get streamUrlHint => 'https://stream.example.com:8000/radio';
}
+20
View File
@@ -1222,4 +1222,24 @@ class AppLocalizationsIt extends AppLocalizations {
@override
String get weekdayShortSunday => 'Dom';
@override
String stationCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count stazioni',
one: '1 stazione',
);
return '$_temp0';
}
@override
String get alarmIconLabel => 'Sveglia musicale';
@override
String get vacationIconLabel => 'Modalità vacanza';
@override
String get streamUrlHint => 'https://stream.example.com:8000/radio';
}
+19
View File
@@ -1173,4 +1173,23 @@ class AppLocalizationsJa extends AppLocalizations {
@override
String get weekdayShortSunday => '';
@override
String stationCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count局',
);
return '$_temp0';
}
@override
String get alarmIconLabel => 'ミュージックアラーム';
@override
String get vacationIconLabel => '休暇モード';
@override
String get streamUrlHint => 'https://stream.example.com:8000/radio';
}
+20
View File
@@ -1214,4 +1214,24 @@ class AppLocalizationsPt extends AppLocalizations {
@override
String get weekdayShortSunday => 'Dom';
@override
String stationCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count estações',
one: '1 estação',
);
return '$_temp0';
}
@override
String get alarmIconLabel => 'Alarme musical';
@override
String get vacationIconLabel => 'Modo férias';
@override
String get streamUrlHint => 'https://stream.example.com:8000/radio';
}
+21
View File
@@ -1217,4 +1217,25 @@ class AppLocalizationsRu extends AppLocalizations {
@override
String get weekdayShortSunday => 'Вс';
@override
String stationCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count станций',
few: '$count станции',
one: '$count станция',
);
return '$_temp0';
}
@override
String get alarmIconLabel => 'Музыкальный будильник';
@override
String get vacationIconLabel => 'Режим отпуска';
@override
String get streamUrlHint => 'https://stream.example.com:8000/radio';
}
+19
View File
@@ -1168,4 +1168,23 @@ class AppLocalizationsZh extends AppLocalizations {
@override
String get weekdayShortSunday => '周日';
@override
String stationCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count 个电台',
);
return '$_temp0';
}
@override
String get alarmIconLabel => '音乐闹钟';
@override
String get vacationIconLabel => '假期模式';
@override
String get streamUrlHint => 'https://stream.example.com:8000/radio';
}
+12 -7
View File
@@ -8,9 +8,20 @@ import 'package:shared_preferences/shared_preferences.dart';
import 'app.dart';
import 'servicios/servicio_audio.dart';
import 'servicios/servicio_audio_session.dart';
import 'tema/pluriwave_tokens.dart';
const _anchoMinimoLandscape = 600.0;
/// S5-R8: media notification accent uses the brand color, not the M3
/// default purple. Top-level const so tests can assert it.
const configuracionAudioService = AudioServiceConfig(
androidNotificationChannelId: 'es.freetimelab.pluriwave.audio',
androidNotificationChannelName: 'PluriWave Radio',
androidNotificationOngoing: true,
androidStopForegroundOnPause: true,
notificationColor: PluriWaveTokens.brand,
);
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
await _aplicarPoliticaOrientacion();
@@ -21,13 +32,7 @@ Future<void> main() async {
final handler = await AudioService.init(
builder: () => PluriWaveAudioHandler(),
config: const AudioServiceConfig(
androidNotificationChannelId: 'es.freetimelab.pluriwave.audio',
androidNotificationChannelName: 'PluriWave Radio',
androidNotificationOngoing: true,
androidStopForegroundOnPause: true,
notificationColor: Color(0xFF6750A4),
),
config: configuracionAudioService,
);
registrarHandler(handler);
+24 -12
View File
@@ -210,7 +210,7 @@ class _SeccionGrabaciones extends StatelessWidget {
children: [
Row(
children: [
const Icon(Icons.radio_button_checked),
const Icon(Icons.radio_button_checked_rounded),
const SizedBox(width: 12),
Text(
l10n.recordingsSectionTitle,
@@ -591,7 +591,7 @@ class _SeccionEcualizador extends StatelessWidget {
children: [
Row(
children: [
const Icon(Icons.equalizer),
const Icon(Icons.equalizer_rounded),
const SizedBox(width: 12),
Text(
l10n.equalizerTitle,
@@ -994,7 +994,7 @@ class _SeccionEmisoras extends StatelessWidget {
children: [
Row(
children: [
const Icon(Icons.add_circle_outline),
const Icon(Icons.add_circle_outline_rounded),
const SizedBox(width: 12),
Text(
AppLocalizations.of(context).customStationsTitle,
@@ -1002,7 +1002,7 @@ class _SeccionEmisoras extends StatelessWidget {
),
const Spacer(),
TextButton.icon(
icon: const Icon(Icons.add),
icon: const Icon(Icons.add_rounded),
label: Text(AppLocalizations.of(context).customStationsAdd),
onPressed: () => _mostrarFormularioAnadir(context),
),
@@ -1013,14 +1013,18 @@ class _SeccionEmisoras extends StatelessWidget {
padding: const EdgeInsets.only(top: 8),
child: Text(
AppLocalizations.of(context).customStationsEmpty,
style: const TextStyle(color: Colors.grey),
style: TextStyle(
color: Theme.of(
context,
).colorScheme.onSurface.withValues(alpha: 0.6),
),
),
)
else
for (final emisora in custom)
ListTile(
contentPadding: EdgeInsets.zero,
leading: const Icon(Icons.radio),
leading: const Icon(Icons.radio_rounded),
title: Text(
localizedStationName(
AppLocalizations.of(context),
@@ -1036,13 +1040,13 @@ class _SeccionEmisoras extends StatelessWidget {
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
icon: const Icon(Icons.play_arrow),
icon: const Icon(Icons.play_arrow_rounded),
tooltip: AppLocalizations.of(context).playAction,
onPressed:
() => context.read<EstadoRadio>().reproducir(emisora),
),
IconButton(
icon: const Icon(Icons.delete_outline),
icon: const Icon(Icons.delete_outline_rounded),
tooltip: AppLocalizations.of(context).deleteAction,
onPressed:
() => context
@@ -1061,6 +1065,8 @@ class _SeccionEmisoras extends StatelessWidget {
await showModalBottomSheet(
context: context,
isScrollControlled: true,
useSafeArea: true,
showDragHandle: true,
builder: (ctx) => const _FormularioEmisora(),
);
}
@@ -1142,7 +1148,7 @@ class _FormularioEmisoraState extends State<_FormularioEmisora> {
controller: _urlCtrl,
decoration: InputDecoration(
labelText: AppLocalizations.of(context).streamUrlLabel,
hintText: 'http://stream.ejemplo.com:8000/radio',
hintText: AppLocalizations.of(context).streamUrlHint,
border: const OutlineInputBorder(),
),
keyboardType: TextInputType.url,
@@ -1340,7 +1346,7 @@ class _SeccionInfo extends StatelessWidget {
builder:
(ctx, snap) => ListTile(
contentPadding: EdgeInsets.zero,
leading: const Icon(Icons.favorite_outline),
leading: const Icon(Icons.favorite_outline_rounded),
title: Text(
AppLocalizations.of(ctx).savedFavoritesTitle,
),
@@ -1366,7 +1372,10 @@ class _SeccionInfo extends StatelessWidget {
subtitle: Text(
AppLocalizations.of(ctx).stationFilterSubtitle,
),
trailing: const Icon(Icons.check_circle, color: Colors.green),
trailing: Icon(
Icons.check_circle_rounded,
color: Theme.of(ctx).colorScheme.secondary,
),
),
ListTile(
contentPadding: EdgeInsets.zero,
@@ -1375,7 +1384,10 @@ class _SeccionInfo extends StatelessWidget {
subtitle: Text(
AppLocalizations.of(ctx).backgroundAudioSubtitle,
),
trailing: const Icon(Icons.check_circle, color: Colors.green),
trailing: Icon(
Icons.check_circle_rounded,
color: Theme.of(ctx).colorScheme.secondary,
),
),
],
),
+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);
+18 -5
View File
@@ -1,8 +1,8 @@
import 'package:flutter/material.dart';
import 'package:flutter_animate/flutter_animate.dart';
import 'package:provider/provider.dart';
import '../estado/estado_busqueda.dart';
import '../tema/pluri_animate.dart';
import '../l10n/gen/app_localizations.dart';
import '../widgets/pluri_glass_surface.dart';
import '../widgets/pluri_icon.dart';
@@ -260,9 +260,18 @@ class _PantallaBuscarState extends State<PantallaBuscar> {
Widget _resultados(EstadoBusqueda estado, ThemeData theme) {
final l10n = AppLocalizations.of(context);
if (estado.cargando) {
return const SizedBox(
height: 220,
child: Center(child: CircularProgressIndicator()),
// S5-R6: shimmer placeholders instead of a bare spinner, consistent
// with the loading pattern used by the home grid.
return Padding(
padding: const EdgeInsets.all(PluriLayout.horizontal),
child: Column(
children: [
for (var i = 0; i < 4; i++) ...[
const TarjetaEmisoraShimmer(esCompacta: true),
if (i < 3) const SizedBox(height: 10),
],
],
),
);
}
@@ -310,7 +319,11 @@ class _PantallaBuscarState extends State<PantallaBuscar> {
emisora: resultados[i],
esCompacta: true,
onTap: () => reproducirMinimizado(context, resultados[i]),
).animate().fadeIn(delay: (i.clamp(0, 12) * 20).ms).slideY(begin: 0.08);
).pluriFadeSlideIn(
context,
delay: Duration(milliseconds: i.clamp(0, 12) * 20),
beginY: 0.08,
);
},
);
}
+2 -1
View File
@@ -145,7 +145,8 @@ class _GrupoFavoritosPanel extends StatelessWidget {
),
),
),
Text('${emisoras.length}'),
// S5-R5: proper plural message, not a bare number.
Text(l10n.stationCount(emisoras.length)),
],
),
const SizedBox(height: 8),
+10 -3
View File
@@ -1,5 +1,4 @@
import 'package:flutter/material.dart';
import 'package:flutter_animate/flutter_animate.dart';
import 'package:provider/provider.dart';
import 'package:shimmer/shimmer.dart' as shimmer;
@@ -7,6 +6,7 @@ import '../estado/estado_busqueda.dart';
import '../estado/estado_radio.dart';
import '../l10n/gen/app_localizations.dart';
import '../modelos/emisora.dart';
import '../tema/pluri_animate.dart';
import '../widgets/pluri_glass_surface.dart';
import '../widgets/pluri_icon.dart';
import '../widgets/pluri_layout.dart';
@@ -230,7 +230,10 @@ class _PantallaInicioState extends State<PantallaInicio> {
),
label: Text(e.nombre, maxLines: 1),
onPressed: () => reproducirMinimizado(context, e),
).animate().fadeIn(delay: (i * 50).ms);
).pluriFadeIn(
context,
delay: Duration(milliseconds: i * 50),
);
},
),
),
@@ -354,7 +357,11 @@ class _PantallaInicioState extends State<PantallaInicio> {
(context, i) => TarjetaEmisora(
emisora: emisoras[i],
onTap: () => reproducirMinimizado(context, emisoras[i]),
).animate().fadeIn(delay: (i * 30).ms).slideY(begin: 0.1),
).pluriFadeSlideIn(
context,
delay: Duration(milliseconds: i * 30),
beginY: 0.1,
),
childCount: emisoras.length,
),
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
+38 -23
View File
@@ -1,6 +1,5 @@
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:flutter_animate/flutter_animate.dart';
import 'package:provider/provider.dart';
import 'package:shimmer/shimmer.dart';
@@ -11,6 +10,7 @@ import '../l10n/gen/app_localizations.dart';
import '../modelos/emisora.dart';
import '../servicios/servicio_audio.dart';
import '../servicios/servicio_timer.dart';
import '../tema/pluri_animate.dart';
import '../tema/pluriwave_theme.dart';
import '../widgets/pluri_glass_surface.dart';
import '../widgets/pluri_wave_scaffold.dart';
@@ -129,9 +129,10 @@ class _PantallaReproductorState extends State<PantallaReproductor>
_WaveHero(
emisora: emisoraActiva,
estadoStream: estado.estadoStream,
).animate().scale(
begin: const Offset(0.86, 0.86),
duration: 420.ms,
).pluriScaleIn(
context,
begin: 0.86,
duration: const Duration(milliseconds: 420),
curve: Curves.easeOutBack,
),
const SizedBox(height: 18),
@@ -143,19 +144,24 @@ class _PantallaReproductorState extends State<PantallaReproductor>
textAlign: TextAlign.center,
maxLines: 2,
overflow: TextOverflow.ellipsis,
).animate().fadeIn(delay: 150.ms),
).pluriFadeIn(context, delay: const Duration(milliseconds: 150)),
const SizedBox(height: 10),
_InfoChips(
emisora: emisoraActiva,
).animate().fadeIn(delay: 200.ms).slideY(begin: 0.2),
_InfoChips(emisora: emisoraActiva).pluriFadeSlideIn(
context,
delay: const Duration(milliseconds: 200),
beginY: 0.2,
),
const SizedBox(height: 6),
if (emisoraActiva.codec != null || emisoraActiva.bitrate != null)
Text(
_codecInfo(context, emisoraActiva),
style: theme.textTheme.bodySmall?.copyWith(
color: Colors.white.withValues(alpha: 0.72),
color: theme.colorScheme.onSurface.withValues(alpha: 0.72),
),
).animate().fadeIn(delay: 250.ms),
).pluriFadeIn(
context,
delay: const Duration(milliseconds: 250),
),
const SizedBox(height: 14),
PluriGlassSurface(
borderRadius: BorderRadius.circular(tokens.radiusLg),
@@ -171,16 +177,25 @@ class _PantallaReproductorState extends State<PantallaReproductor>
color: tokens.warmCoral,
altura: 46,
),
).animate().fadeIn(delay: 280.ms),
).pluriFadeIn(context, delay: const Duration(milliseconds: 280)),
const Spacer(),
_Controles(
estado: estado,
emisora: emisoraActiva,
).animate().fadeIn(delay: 300.ms).slideY(begin: 0.3),
).pluriFadeSlideIn(
context,
delay: const Duration(milliseconds: 300),
beginY: 0.3,
),
const SizedBox(height: 14),
const _GrabacionWidget().animate().fadeIn(delay: 360.ms),
const _GrabacionWidget().pluriFadeIn(
context,
delay: const Duration(milliseconds: 360),
),
const SizedBox(height: 14),
_TimerWidget(estado: estado).animate().fadeIn(delay: 400.ms),
_TimerWidget(
estado: estado,
).pluriFadeIn(context, delay: const Duration(milliseconds: 400)),
const SizedBox(height: 16),
],
),
@@ -247,9 +262,7 @@ class _WaveHero extends StatelessWidget {
height: size + 12,
decoration: BoxDecoration(
shape: BoxShape.circle,
border: Border.all(
color: Colors.white.withValues(alpha: 0.16),
),
border: Border.all(color: t.glassBorder),
),
),
PluriGlassSurface(
@@ -275,9 +288,9 @@ class _WaveHero extends StatelessWidget {
if (cargando)
Container(
color: Colors.black45,
child: const Center(
child: Center(
child: CircularProgressIndicator(
color: Colors.white,
color: theme.colorScheme.onSurface,
),
),
),
@@ -288,7 +301,9 @@ class _WaveHero extends StatelessWidget {
child: Icon(
Icons.wifi_off_rounded,
size: 56,
color: Colors.white.withValues(alpha: 0.85),
color: theme.colorScheme.onSurface.withValues(
alpha: 0.85,
),
),
),
),
@@ -706,7 +721,7 @@ class _Controles extends StatelessWidget {
minWidth: 56,
minHeight: 56,
),
color: Colors.white.withValues(alpha: 0.78),
color: theme.colorScheme.onSurface.withValues(alpha: 0.78),
tooltip: l10n.stopAction,
onPressed: cargando ? null : estado.detenerReproduccion,
),
@@ -753,12 +768,12 @@ class _Controles extends StatelessWidget {
child: Center(
child:
cargando
? const SizedBox(
? SizedBox(
width: 28,
height: 28,
child: CircularProgressIndicator(
strokeWidth: 2.5,
color: Colors.white,
color: theme.colorScheme.onPrimary,
),
)
: Icon(
+14
View File
@@ -33,6 +33,20 @@ extension PluriAnimate on Widget {
.scaleXY(begin: begin, end: 1, duration: duration, curve: curve);
}
/// Fade + subtle vertical slide entry animation.
Widget pluriFadeSlideIn(
BuildContext context, {
Duration duration = const Duration(milliseconds: 350),
Duration delay = Duration.zero,
Curve curve = Curves.easeOutCubic,
double beginY = 0.1,
}) {
if (_animacionesDeshabilitadas(context)) return this;
return animate(delay: delay)
.fadeIn(duration: duration, curve: curve)
.slideY(begin: beginY, end: 0, duration: duration, curve: curve);
}
bool _animacionesDeshabilitadas(BuildContext context) =>
MediaQuery.maybeDisableAnimationsOf(context) ?? false;
}
+20 -4
View File
@@ -36,9 +36,19 @@ class PluriWaveTokens extends ThemeExtension<PluriWaveTokens> {
final double spacingMd;
final double spacingLg;
/// Brand accent (S5-R8). Same hue as [electricMagenta]; exposed as a
/// static const so const contexts (e.g. AudioServiceConfig) can use it.
static const Color brand = Color(0xFF21D4D9);
/// Secondary palette used by gradients and decorative orbs (S5-R1).
/// These are token DEFINITIONS the only place raw literals may live.
static const Color brightCyan = Color(0xFF20E6FF);
static const Color auroraTeal = Color(0xFF0E4A4F);
static const Color skyBlue = Color(0xFF60A5FA);
static const dark = PluriWaveTokens(
deepViolet: Color(0xFF07121A),
electricMagenta: Color(0xFF21D4D9),
electricMagenta: brand,
warmCoral: Color(0xFFF4B860),
glassSurface: Color(0x1FFFFFFF),
glassBorder: Color(0x33FFFFFF),
@@ -86,13 +96,19 @@ class PluriWaveTokens extends ThemeExtension<PluriWaveTokens> {
}
@override
PluriWaveTokens lerp(covariant ThemeExtension<PluriWaveTokens>? other, double t) {
PluriWaveTokens lerp(
covariant ThemeExtension<PluriWaveTokens>? other,
double t,
) {
if (other is! PluriWaveTokens) return this;
return PluriWaveTokens(
deepViolet: Color.lerp(deepViolet, other.deepViolet, t) ?? deepViolet,
electricMagenta: Color.lerp(electricMagenta, other.electricMagenta, t) ?? electricMagenta,
electricMagenta:
Color.lerp(electricMagenta, other.electricMagenta, t) ??
electricMagenta,
warmCoral: Color.lerp(warmCoral, other.warmCoral, t) ?? warmCoral,
glassSurface: Color.lerp(glassSurface, other.glassSurface, t) ?? glassSurface,
glassSurface:
Color.lerp(glassSurface, other.glassSurface, t) ?? glassSurface,
glassBorder: Color.lerp(glassBorder, other.glassBorder, t) ?? glassBorder,
glowColor: Color.lerp(glowColor, other.glowColor, t) ?? glowColor,
radiusSm: lerpDouble(radiusSm, other.radiusSm, t) ?? radiusSm,
+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);