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:
+5
-1
@@ -612,5 +612,9 @@
|
|||||||
"weekdayShortThursday": "الخميس",
|
"weekdayShortThursday": "الخميس",
|
||||||
"weekdayShortFriday": "الجمعة",
|
"weekdayShortFriday": "الجمعة",
|
||||||
"weekdayShortSaturday": "السبت",
|
"weekdayShortSaturday": "السبت",
|
||||||
"weekdayShortSunday": "الأحد"
|
"weekdayShortSunday": "الأحد",
|
||||||
|
"stationCount": "{count, plural, one{محطة واحدة} two{محطتان} few{{count} محطات} other{{count} محطة}}",
|
||||||
|
"alarmIconLabel": "منبه موسيقي",
|
||||||
|
"vacationIconLabel": "وضع الإجازة",
|
||||||
|
"streamUrlHint": "https://stream.example.com:8000/radio"
|
||||||
}
|
}
|
||||||
|
|||||||
+5
-1
@@ -612,5 +612,9 @@
|
|||||||
"weekdayShortThursday": "বৃহস্পতি",
|
"weekdayShortThursday": "বৃহস্পতি",
|
||||||
"weekdayShortFriday": "শুক্র",
|
"weekdayShortFriday": "শুক্র",
|
||||||
"weekdayShortSaturday": "শনি",
|
"weekdayShortSaturday": "শনি",
|
||||||
"weekdayShortSunday": "রবি"
|
"weekdayShortSunday": "রবি",
|
||||||
|
"stationCount": "{count, plural, =1{১টি স্টেশন} other{{count}টি স্টেশন}}",
|
||||||
|
"alarmIconLabel": "মিউজিক অ্যালার্ম",
|
||||||
|
"vacationIconLabel": "ছুটির মোড",
|
||||||
|
"streamUrlHint": "https://stream.example.com:8000/radio"
|
||||||
}
|
}
|
||||||
|
|||||||
+5
-1
@@ -612,5 +612,9 @@
|
|||||||
"weekdayShortThursday": "Do",
|
"weekdayShortThursday": "Do",
|
||||||
"weekdayShortFriday": "Fr",
|
"weekdayShortFriday": "Fr",
|
||||||
"weekdayShortSaturday": "Sa",
|
"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
@@ -612,5 +612,9 @@
|
|||||||
"weekdayShortThursday": "Thu",
|
"weekdayShortThursday": "Thu",
|
||||||
"weekdayShortFriday": "Fri",
|
"weekdayShortFriday": "Fri",
|
||||||
"weekdayShortSaturday": "Sat",
|
"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
@@ -568,6 +568,16 @@
|
|||||||
"weekdayShortThursday": "Jue",
|
"weekdayShortThursday": "Jue",
|
||||||
"weekdayShortFriday": "Vie",
|
"weekdayShortFriday": "Vie",
|
||||||
"weekdayShortSaturday": "Sáb",
|
"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
@@ -612,5 +612,9 @@
|
|||||||
"weekdayShortThursday": "Jeu",
|
"weekdayShortThursday": "Jeu",
|
||||||
"weekdayShortFriday": "Ven",
|
"weekdayShortFriday": "Ven",
|
||||||
"weekdayShortSaturday": "Sam",
|
"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
@@ -612,5 +612,9 @@
|
|||||||
"weekdayShortThursday": "गुरु",
|
"weekdayShortThursday": "गुरु",
|
||||||
"weekdayShortFriday": "शुक्र",
|
"weekdayShortFriday": "शुक्र",
|
||||||
"weekdayShortSaturday": "शनि",
|
"weekdayShortSaturday": "शनि",
|
||||||
"weekdayShortSunday": "रवि"
|
"weekdayShortSunday": "रवि",
|
||||||
|
"stationCount": "{count, plural, =1{1 स्टेशन} other{{count} स्टेशन}}",
|
||||||
|
"alarmIconLabel": "संगीत अलार्म",
|
||||||
|
"vacationIconLabel": "अवकाश मोड",
|
||||||
|
"streamUrlHint": "https://stream.example.com:8000/radio"
|
||||||
}
|
}
|
||||||
|
|||||||
+5
-1
@@ -612,5 +612,9 @@
|
|||||||
"weekdayShortThursday": "Kam",
|
"weekdayShortThursday": "Kam",
|
||||||
"weekdayShortFriday": "Jum",
|
"weekdayShortFriday": "Jum",
|
||||||
"weekdayShortSaturday": "Sab",
|
"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
@@ -612,5 +612,9 @@
|
|||||||
"weekdayShortThursday": "Gio",
|
"weekdayShortThursday": "Gio",
|
||||||
"weekdayShortFriday": "Ven",
|
"weekdayShortFriday": "Ven",
|
||||||
"weekdayShortSaturday": "Sab",
|
"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
@@ -612,5 +612,9 @@
|
|||||||
"weekdayShortThursday": "木",
|
"weekdayShortThursday": "木",
|
||||||
"weekdayShortFriday": "金",
|
"weekdayShortFriday": "金",
|
||||||
"weekdayShortSaturday": "土",
|
"weekdayShortSaturday": "土",
|
||||||
"weekdayShortSunday": "日"
|
"weekdayShortSunday": "日",
|
||||||
|
"stationCount": "{count, plural, other{{count}局}}",
|
||||||
|
"alarmIconLabel": "ミュージックアラーム",
|
||||||
|
"vacationIconLabel": "休暇モード",
|
||||||
|
"streamUrlHint": "https://stream.example.com:8000/radio"
|
||||||
}
|
}
|
||||||
|
|||||||
+5
-1
@@ -612,5 +612,9 @@
|
|||||||
"weekdayShortThursday": "Qui",
|
"weekdayShortThursday": "Qui",
|
||||||
"weekdayShortFriday": "Sex",
|
"weekdayShortFriday": "Sex",
|
||||||
"weekdayShortSaturday": "Sáb",
|
"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
@@ -612,5 +612,9 @@
|
|||||||
"weekdayShortThursday": "Чт",
|
"weekdayShortThursday": "Чт",
|
||||||
"weekdayShortFriday": "Пт",
|
"weekdayShortFriday": "Пт",
|
||||||
"weekdayShortSaturday": "Сб",
|
"weekdayShortSaturday": "Сб",
|
||||||
"weekdayShortSunday": "Вс"
|
"weekdayShortSunday": "Вс",
|
||||||
|
"stationCount": "{count, plural, one{{count} станция} few{{count} станции} other{{count} станций}}",
|
||||||
|
"alarmIconLabel": "Музыкальный будильник",
|
||||||
|
"vacationIconLabel": "Режим отпуска",
|
||||||
|
"streamUrlHint": "https://stream.example.com:8000/radio"
|
||||||
}
|
}
|
||||||
|
|||||||
+5
-1
@@ -612,5 +612,9 @@
|
|||||||
"weekdayShortThursday": "周四",
|
"weekdayShortThursday": "周四",
|
||||||
"weekdayShortFriday": "周五",
|
"weekdayShortFriday": "周五",
|
||||||
"weekdayShortSaturday": "周六",
|
"weekdayShortSaturday": "周六",
|
||||||
"weekdayShortSunday": "周日"
|
"weekdayShortSunday": "周日",
|
||||||
|
"stationCount": "{count, plural, other{{count} 个电台}}",
|
||||||
|
"alarmIconLabel": "音乐闹钟",
|
||||||
|
"vacationIconLabel": "假期模式",
|
||||||
|
"streamUrlHint": "https://stream.example.com:8000/radio"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
@@ -2245,6 +2245,30 @@ abstract class AppLocalizations {
|
|||||||
/// In es, this message translates to:
|
/// In es, this message translates to:
|
||||||
/// **'Dom'**
|
/// **'Dom'**
|
||||||
String get weekdayShortSunday;
|
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
|
class _AppLocalizationsDelegate
|
||||||
|
|||||||
@@ -1203,4 +1203,26 @@ class AppLocalizationsAr extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get weekdayShortSunday => 'الأحد';
|
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';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1212,4 +1212,24 @@ class AppLocalizationsBn extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get weekdayShortSunday => 'রবি';
|
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';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1222,4 +1222,24 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get weekdayShortSunday => 'So';
|
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';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1208,4 +1208,24 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get weekdayShortSunday => 'Sun';
|
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';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1217,4 +1217,24 @@ class AppLocalizationsEs extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get weekdayShortSunday => 'Dom';
|
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';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1227,4 +1227,24 @@ class AppLocalizationsFr extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get weekdayShortSunday => 'Dim';
|
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';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1211,4 +1211,24 @@ class AppLocalizationsHi extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get weekdayShortSunday => 'रवि';
|
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';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1217,4 +1217,23 @@ class AppLocalizationsId extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get weekdayShortSunday => 'Min';
|
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';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1222,4 +1222,24 @@ class AppLocalizationsIt extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get weekdayShortSunday => 'Dom';
|
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';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1173,4 +1173,23 @@ class AppLocalizationsJa extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get weekdayShortSunday => '日';
|
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';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1214,4 +1214,24 @@ class AppLocalizationsPt extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get weekdayShortSunday => 'Dom';
|
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';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1217,4 +1217,25 @@ class AppLocalizationsRu extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get weekdayShortSunday => 'Вс';
|
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';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1168,4 +1168,23 @@ class AppLocalizationsZh extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get weekdayShortSunday => '周日';
|
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
@@ -8,9 +8,20 @@ import 'package:shared_preferences/shared_preferences.dart';
|
|||||||
import 'app.dart';
|
import 'app.dart';
|
||||||
import 'servicios/servicio_audio.dart';
|
import 'servicios/servicio_audio.dart';
|
||||||
import 'servicios/servicio_audio_session.dart';
|
import 'servicios/servicio_audio_session.dart';
|
||||||
|
import 'tema/pluriwave_tokens.dart';
|
||||||
|
|
||||||
const _anchoMinimoLandscape = 600.0;
|
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 {
|
Future<void> main() async {
|
||||||
WidgetsFlutterBinding.ensureInitialized();
|
WidgetsFlutterBinding.ensureInitialized();
|
||||||
await _aplicarPoliticaOrientacion();
|
await _aplicarPoliticaOrientacion();
|
||||||
@@ -21,13 +32,7 @@ Future<void> main() async {
|
|||||||
|
|
||||||
final handler = await AudioService.init(
|
final handler = await AudioService.init(
|
||||||
builder: () => PluriWaveAudioHandler(),
|
builder: () => PluriWaveAudioHandler(),
|
||||||
config: const AudioServiceConfig(
|
config: configuracionAudioService,
|
||||||
androidNotificationChannelId: 'es.freetimelab.pluriwave.audio',
|
|
||||||
androidNotificationChannelName: 'PluriWave Radio',
|
|
||||||
androidNotificationOngoing: true,
|
|
||||||
androidStopForegroundOnPause: true,
|
|
||||||
notificationColor: Color(0xFF6750A4),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
registrarHandler(handler);
|
registrarHandler(handler);
|
||||||
|
|
||||||
|
|||||||
@@ -210,7 +210,7 @@ class _SeccionGrabaciones extends StatelessWidget {
|
|||||||
children: [
|
children: [
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
const Icon(Icons.radio_button_checked),
|
const Icon(Icons.radio_button_checked_rounded),
|
||||||
const SizedBox(width: 12),
|
const SizedBox(width: 12),
|
||||||
Text(
|
Text(
|
||||||
l10n.recordingsSectionTitle,
|
l10n.recordingsSectionTitle,
|
||||||
@@ -591,7 +591,7 @@ class _SeccionEcualizador extends StatelessWidget {
|
|||||||
children: [
|
children: [
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
const Icon(Icons.equalizer),
|
const Icon(Icons.equalizer_rounded),
|
||||||
const SizedBox(width: 12),
|
const SizedBox(width: 12),
|
||||||
Text(
|
Text(
|
||||||
l10n.equalizerTitle,
|
l10n.equalizerTitle,
|
||||||
@@ -994,7 +994,7 @@ class _SeccionEmisoras extends StatelessWidget {
|
|||||||
children: [
|
children: [
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
const Icon(Icons.add_circle_outline),
|
const Icon(Icons.add_circle_outline_rounded),
|
||||||
const SizedBox(width: 12),
|
const SizedBox(width: 12),
|
||||||
Text(
|
Text(
|
||||||
AppLocalizations.of(context).customStationsTitle,
|
AppLocalizations.of(context).customStationsTitle,
|
||||||
@@ -1002,7 +1002,7 @@ class _SeccionEmisoras extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
const Spacer(),
|
const Spacer(),
|
||||||
TextButton.icon(
|
TextButton.icon(
|
||||||
icon: const Icon(Icons.add),
|
icon: const Icon(Icons.add_rounded),
|
||||||
label: Text(AppLocalizations.of(context).customStationsAdd),
|
label: Text(AppLocalizations.of(context).customStationsAdd),
|
||||||
onPressed: () => _mostrarFormularioAnadir(context),
|
onPressed: () => _mostrarFormularioAnadir(context),
|
||||||
),
|
),
|
||||||
@@ -1013,14 +1013,18 @@ class _SeccionEmisoras extends StatelessWidget {
|
|||||||
padding: const EdgeInsets.only(top: 8),
|
padding: const EdgeInsets.only(top: 8),
|
||||||
child: Text(
|
child: Text(
|
||||||
AppLocalizations.of(context).customStationsEmpty,
|
AppLocalizations.of(context).customStationsEmpty,
|
||||||
style: const TextStyle(color: Colors.grey),
|
style: TextStyle(
|
||||||
|
color: Theme.of(
|
||||||
|
context,
|
||||||
|
).colorScheme.onSurface.withValues(alpha: 0.6),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
else
|
else
|
||||||
for (final emisora in custom)
|
for (final emisora in custom)
|
||||||
ListTile(
|
ListTile(
|
||||||
contentPadding: EdgeInsets.zero,
|
contentPadding: EdgeInsets.zero,
|
||||||
leading: const Icon(Icons.radio),
|
leading: const Icon(Icons.radio_rounded),
|
||||||
title: Text(
|
title: Text(
|
||||||
localizedStationName(
|
localizedStationName(
|
||||||
AppLocalizations.of(context),
|
AppLocalizations.of(context),
|
||||||
@@ -1036,13 +1040,13 @@ class _SeccionEmisoras extends StatelessWidget {
|
|||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: const Icon(Icons.play_arrow),
|
icon: const Icon(Icons.play_arrow_rounded),
|
||||||
tooltip: AppLocalizations.of(context).playAction,
|
tooltip: AppLocalizations.of(context).playAction,
|
||||||
onPressed:
|
onPressed:
|
||||||
() => context.read<EstadoRadio>().reproducir(emisora),
|
() => context.read<EstadoRadio>().reproducir(emisora),
|
||||||
),
|
),
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: const Icon(Icons.delete_outline),
|
icon: const Icon(Icons.delete_outline_rounded),
|
||||||
tooltip: AppLocalizations.of(context).deleteAction,
|
tooltip: AppLocalizations.of(context).deleteAction,
|
||||||
onPressed:
|
onPressed:
|
||||||
() => context
|
() => context
|
||||||
@@ -1061,6 +1065,8 @@ class _SeccionEmisoras extends StatelessWidget {
|
|||||||
await showModalBottomSheet(
|
await showModalBottomSheet(
|
||||||
context: context,
|
context: context,
|
||||||
isScrollControlled: true,
|
isScrollControlled: true,
|
||||||
|
useSafeArea: true,
|
||||||
|
showDragHandle: true,
|
||||||
builder: (ctx) => const _FormularioEmisora(),
|
builder: (ctx) => const _FormularioEmisora(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1142,7 +1148,7 @@ class _FormularioEmisoraState extends State<_FormularioEmisora> {
|
|||||||
controller: _urlCtrl,
|
controller: _urlCtrl,
|
||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
labelText: AppLocalizations.of(context).streamUrlLabel,
|
labelText: AppLocalizations.of(context).streamUrlLabel,
|
||||||
hintText: 'http://stream.ejemplo.com:8000/radio',
|
hintText: AppLocalizations.of(context).streamUrlHint,
|
||||||
border: const OutlineInputBorder(),
|
border: const OutlineInputBorder(),
|
||||||
),
|
),
|
||||||
keyboardType: TextInputType.url,
|
keyboardType: TextInputType.url,
|
||||||
@@ -1340,7 +1346,7 @@ class _SeccionInfo extends StatelessWidget {
|
|||||||
builder:
|
builder:
|
||||||
(ctx, snap) => ListTile(
|
(ctx, snap) => ListTile(
|
||||||
contentPadding: EdgeInsets.zero,
|
contentPadding: EdgeInsets.zero,
|
||||||
leading: const Icon(Icons.favorite_outline),
|
leading: const Icon(Icons.favorite_outline_rounded),
|
||||||
title: Text(
|
title: Text(
|
||||||
AppLocalizations.of(ctx).savedFavoritesTitle,
|
AppLocalizations.of(ctx).savedFavoritesTitle,
|
||||||
),
|
),
|
||||||
@@ -1366,7 +1372,10 @@ class _SeccionInfo extends StatelessWidget {
|
|||||||
subtitle: Text(
|
subtitle: Text(
|
||||||
AppLocalizations.of(ctx).stationFilterSubtitle,
|
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(
|
ListTile(
|
||||||
contentPadding: EdgeInsets.zero,
|
contentPadding: EdgeInsets.zero,
|
||||||
@@ -1375,7 +1384,10 @@ class _SeccionInfo extends StatelessWidget {
|
|||||||
subtitle: Text(
|
subtitle: Text(
|
||||||
AppLocalizations.of(ctx).backgroundAudioSubtitle,
|
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,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -4,11 +4,14 @@ import 'package:provider/provider.dart';
|
|||||||
import '../estado/estado_alarmas.dart';
|
import '../estado/estado_alarmas.dart';
|
||||||
import '../estado/estado_radio.dart';
|
import '../estado/estado_radio.dart';
|
||||||
import '../l10n/display_names.dart';
|
import '../l10n/display_names.dart';
|
||||||
|
import '../l10n/formato_fechas.dart';
|
||||||
import '../l10n/app_localizations_ext.dart';
|
import '../l10n/app_localizations_ext.dart';
|
||||||
import '../l10n/gen/app_localizations.dart';
|
import '../l10n/gen/app_localizations.dart';
|
||||||
import '../modelos/alarma_musical.dart';
|
import '../modelos/alarma_musical.dart';
|
||||||
import '../modelos/emisora.dart';
|
import '../modelos/emisora.dart';
|
||||||
import '../servicios/servicio_programacion_alarmas.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_glass_surface.dart';
|
||||||
import '../widgets/pluri_icon.dart';
|
import '../widgets/pluri_icon.dart';
|
||||||
import '../widgets/pluri_layout.dart';
|
import '../widgets/pluri_layout.dart';
|
||||||
@@ -92,10 +95,14 @@ class _PanelProximaAlarma extends StatelessWidget {
|
|||||||
final proximaProgramable = proxima?.proximaProgramable;
|
final proximaProgramable = proxima?.proximaProgramable;
|
||||||
|
|
||||||
return PluriGlassSurface(
|
return PluriGlassSurface(
|
||||||
glowColor: const Color(0xFFFFB86B).withValues(alpha: 0.28),
|
glowColor: context.pluriTokens.warmCoral.withValues(alpha: 0.28),
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
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),
|
const SizedBox(width: 14),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Column(
|
child: Column(
|
||||||
@@ -142,15 +149,16 @@ class _TarjetaAlarma extends StatelessWidget {
|
|||||||
final excepcion = estado.ultimaExcepcionPara(alarma.id);
|
final excepcion = estado.ultimaExcepcionPara(alarma.id);
|
||||||
final mensajeVacaciones = _mensajeVacaciones(l10n, estado.vacaciones);
|
final mensajeVacaciones = _mensajeVacaciones(l10n, estado.vacaciones);
|
||||||
return PluriGlassSurface(
|
return PluriGlassSurface(
|
||||||
glowColor: const Color(0xFF22D3EE).withValues(alpha: 0.22),
|
glowColor: context.pluriTokens.electricMagenta.withValues(alpha: 0.22),
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
const _AssetIcon(
|
_AssetIcon(
|
||||||
'assets/icons/alarmas/alarm_music.png',
|
'assets/icons/alarmas/alarm_music.png',
|
||||||
size: 64,
|
size: 64,
|
||||||
|
semanticLabel: l10n.alarmIconLabel,
|
||||||
),
|
),
|
||||||
const SizedBox(width: 12),
|
const SizedBox(width: 12),
|
||||||
Expanded(
|
Expanded(
|
||||||
@@ -435,9 +443,10 @@ class _EditorAlarmaSheetState extends State<_EditorAlarmaSheet> {
|
|||||||
children: [
|
children: [
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
const _AssetIcon(
|
_AssetIcon(
|
||||||
'assets/icons/alarmas/alarm_music.png',
|
'assets/icons/alarmas/alarm_music.png',
|
||||||
size: 58,
|
size: 58,
|
||||||
|
semanticLabel: l10n.alarmIconLabel,
|
||||||
),
|
),
|
||||||
const SizedBox(width: 12),
|
const SizedBox(width: 12),
|
||||||
Expanded(
|
Expanded(
|
||||||
@@ -477,7 +486,7 @@ class _EditorAlarmaSheetState extends State<_EditorAlarmaSheet> {
|
|||||||
child: _PickerButton(
|
child: _PickerButton(
|
||||||
icon: Icons.event_rounded,
|
icon: Icons.event_rounded,
|
||||||
label: l10n.dateField,
|
label: l10n.dateField,
|
||||||
value: _fechaCorta(_fecha),
|
value: _fechaCorta(l10n, _fecha),
|
||||||
onTap:
|
onTap:
|
||||||
_tipo == TipoProgramacionAlarma.unica
|
_tipo == TipoProgramacionAlarma.unica
|
||||||
? _elegirFecha
|
? _elegirFecha
|
||||||
@@ -663,9 +672,10 @@ class _EditorAlarmaSheetState extends State<_EditorAlarmaSheet> {
|
|||||||
value: _sonarEnVacaciones,
|
value: _sonarEnVacaciones,
|
||||||
onChanged:
|
onChanged:
|
||||||
(value) => setState(() => _sonarEnVacaciones = value),
|
(value) => setState(() => _sonarEnVacaciones = value),
|
||||||
secondary: const _AssetIcon(
|
secondary: _AssetIcon(
|
||||||
'assets/icons/alarmas/vacation_wave.png',
|
'assets/icons/alarmas/vacation_wave.png',
|
||||||
size: 42,
|
size: 42,
|
||||||
|
semanticLabel: l10n.vacationIconLabel,
|
||||||
),
|
),
|
||||||
title: Text(l10n.playDuringVacations),
|
title: Text(l10n.playDuringVacations),
|
||||||
subtitle: Text(l10n.playDuringVacationsHint),
|
subtitle: Text(l10n.playDuringVacationsHint),
|
||||||
@@ -1010,15 +1020,16 @@ class _PanelVacaciones extends StatelessWidget {
|
|||||||
final vacaciones = [...estado.vacaciones]
|
final vacaciones = [...estado.vacaciones]
|
||||||
..sort((a, b) => a.inicioDia.compareTo(b.inicioDia));
|
..sort((a, b) => a.inicioDia.compareTo(b.inicioDia));
|
||||||
return PluriGlassSurface(
|
return PluriGlassSurface(
|
||||||
glowColor: const Color(0xFF60A5FA).withValues(alpha: 0.22),
|
glowColor: PluriWaveTokens.skyBlue.withValues(alpha: 0.22),
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
const _AssetIcon(
|
_AssetIcon(
|
||||||
'assets/icons/alarmas/vacation_wave.png',
|
'assets/icons/alarmas/vacation_wave.png',
|
||||||
size: 48,
|
size: 48,
|
||||||
|
semanticLabel: l10n.vacationIconLabel,
|
||||||
),
|
),
|
||||||
const SizedBox(width: 10),
|
const SizedBox(width: 10),
|
||||||
Expanded(
|
Expanded(
|
||||||
@@ -1047,7 +1058,7 @@ class _PanelVacaciones extends StatelessWidget {
|
|||||||
leading: const Icon(Icons.event_busy_rounded),
|
leading: const Icon(Icons.event_busy_rounded),
|
||||||
title: Text(_nombreVisibleVacaciones(l10n, rango)),
|
title: Text(_nombreVisibleVacaciones(l10n, rango)),
|
||||||
subtitle: Text(
|
subtitle: Text(
|
||||||
'${_fechaCorta(rango.inicioDia)} → ${_fechaCorta(rango.finDia)}',
|
'${_fechaCorta(l10n, rango.inicioDia)} → ${_fechaCorta(l10n, rango.finDia)}',
|
||||||
),
|
),
|
||||||
trailing: IconButton(
|
trailing: IconButton(
|
||||||
tooltip: l10n.deleteRangeTooltip,
|
tooltip: l10n.deleteRangeTooltip,
|
||||||
@@ -1079,7 +1090,9 @@ class _EditorVacacionesSheet extends StatefulWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _EditorVacacionesSheetState extends State<_EditorVacacionesSheet> {
|
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 _inicio;
|
||||||
late DateTime _fin;
|
late DateTime _fin;
|
||||||
|
|
||||||
@@ -1089,14 +1102,19 @@ class _EditorVacacionesSheetState extends State<_EditorVacacionesSheet> {
|
|||||||
final hoy = DateTime.now();
|
final hoy = DateTime.now();
|
||||||
_inicio = DateTime(hoy.year, hoy.month, hoy.day);
|
_inicio = DateTime(hoy.year, hoy.month, hoy.day);
|
||||||
_fin = _inicio.add(const Duration(days: 2));
|
_fin = _inicio.add(const Duration(days: 2));
|
||||||
_nombreController = TextEditingController(
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void didChangeDependencies() {
|
||||||
|
super.didChangeDependencies();
|
||||||
|
_nombreController ??= TextEditingController(
|
||||||
text: AppLocalizations.of(context).vacationsDefaultName,
|
text: AppLocalizations.of(context).vacationsDefaultName,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_nombreController.dispose();
|
_nombreController?.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1131,7 +1149,7 @@ class _EditorVacacionesSheetState extends State<_EditorVacacionesSheet> {
|
|||||||
child: _PickerButton(
|
child: _PickerButton(
|
||||||
icon: Icons.play_arrow_rounded,
|
icon: Icons.play_arrow_rounded,
|
||||||
label: l10n.startLabel,
|
label: l10n.startLabel,
|
||||||
value: _fechaCorta(_inicio),
|
value: _fechaCorta(l10n, _inicio),
|
||||||
onTap: () => _elegirFecha(esInicio: true),
|
onTap: () => _elegirFecha(esInicio: true),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -1140,7 +1158,7 @@ class _EditorVacacionesSheetState extends State<_EditorVacacionesSheet> {
|
|||||||
child: _PickerButton(
|
child: _PickerButton(
|
||||||
icon: Icons.stop_rounded,
|
icon: Icons.stop_rounded,
|
||||||
label: l10n.endLabel,
|
label: l10n.endLabel,
|
||||||
value: _fechaCorta(_fin),
|
value: _fechaCorta(l10n, _fin),
|
||||||
onTap: () => _elegirFecha(esInicio: false),
|
onTap: () => _elegirFecha(esInicio: false),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -1183,7 +1201,7 @@ class _EditorVacacionesSheetState extends State<_EditorVacacionesSheet> {
|
|||||||
final rango = estado.servicio.crearRangoVacaciones(
|
final rango = estado.servicio.crearRangoVacaciones(
|
||||||
inicio: _inicio,
|
inicio: _inicio,
|
||||||
fin: _fin,
|
fin: _fin,
|
||||||
nombre: _nombreController.text.trim(),
|
nombre: _nombreController?.text.trim() ?? '',
|
||||||
);
|
);
|
||||||
await estado.crearRangoVacaciones(rango);
|
await estado.crearRangoVacaciones(rango);
|
||||||
if (mounted) Navigator.pop(context);
|
if (mounted) Navigator.pop(context);
|
||||||
@@ -1191,11 +1209,15 @@ class _EditorVacacionesSheetState extends State<_EditorVacacionesSheet> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _AssetIcon extends StatelessWidget {
|
class _AssetIcon extends StatelessWidget {
|
||||||
const _AssetIcon(this.asset, {this.size = 44});
|
const _AssetIcon(this.asset, {this.size = 44, this.semanticLabel});
|
||||||
|
|
||||||
final String asset;
|
final String asset;
|
||||||
final double size;
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Image.asset(
|
return Image.asset(
|
||||||
@@ -1203,6 +1225,8 @@ class _AssetIcon extends StatelessWidget {
|
|||||||
width: size,
|
width: size,
|
||||||
height: size,
|
height: size,
|
||||||
fit: BoxFit.contain,
|
fit: BoxFit.contain,
|
||||||
|
semanticLabel: semanticLabel,
|
||||||
|
excludeFromSemantics: semanticLabel == null,
|
||||||
errorBuilder:
|
errorBuilder:
|
||||||
(_, __, ___) => Icon(Icons.music_note_rounded, size: size * 0.65),
|
(_, __, ___) => Icon(Icons.music_note_rounded, size: size * 0.65),
|
||||||
);
|
);
|
||||||
@@ -1301,7 +1325,11 @@ class _EmptyAlarmas extends StatelessWidget {
|
|||||||
return PluriGlassSurface(
|
return PluriGlassSurface(
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
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),
|
const SizedBox(height: 12),
|
||||||
Text(l10n.noAlarmsYetTitle),
|
Text(l10n.noAlarmsYetTitle),
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
@@ -1326,7 +1354,7 @@ String _hora(AlarmaMusical alarma) =>
|
|||||||
String _programacion(AppLocalizations l10n, AlarmaMusical alarma) {
|
String _programacion(AppLocalizations l10n, AlarmaMusical alarma) {
|
||||||
return switch (alarma.tipoProgramacion) {
|
return switch (alarma.tipoProgramacion) {
|
||||||
TipoProgramacionAlarma.unica => l10n.alarmScheduleOnce(
|
TipoProgramacionAlarma.unica => l10n.alarmScheduleOnce(
|
||||||
_fechaCorta(alarma.fechaUnica ?? DateTime.now()),
|
_fechaCorta(l10n, alarma.fechaUnica ?? DateTime.now()),
|
||||||
),
|
),
|
||||||
TipoProgramacionAlarma.diaria => l10n.dailyOption,
|
TipoProgramacionAlarma.diaria => l10n.dailyOption,
|
||||||
TipoProgramacionAlarma.diasSemana => l10n.alarmScheduleWeekdays(
|
TipoProgramacionAlarma.diasSemana => l10n.alarmScheduleWeekdays(
|
||||||
@@ -1349,5 +1377,6 @@ String _weekdayShort(AppLocalizations l10n, int day) => switch (day) {
|
|||||||
_ => '?',
|
_ => '?',
|
||||||
};
|
};
|
||||||
|
|
||||||
String _fechaCorta(DateTime fecha) =>
|
// S5-R4: short dates follow the active locale (en-US = M/D/Y, ja = Y/M/D).
|
||||||
'${fecha.day.toString().padLeft(2, '0')}/${fecha.month.toString().padLeft(2, '0')}/${fecha.year}';
|
String _fechaCorta(AppLocalizations l10n, DateTime fecha) =>
|
||||||
|
fechaCortaLocalizada(l10n.localeName, fecha);
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_animate/flutter_animate.dart';
|
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
import '../estado/estado_busqueda.dart';
|
import '../estado/estado_busqueda.dart';
|
||||||
|
import '../tema/pluri_animate.dart';
|
||||||
import '../l10n/gen/app_localizations.dart';
|
import '../l10n/gen/app_localizations.dart';
|
||||||
import '../widgets/pluri_glass_surface.dart';
|
import '../widgets/pluri_glass_surface.dart';
|
||||||
import '../widgets/pluri_icon.dart';
|
import '../widgets/pluri_icon.dart';
|
||||||
@@ -260,9 +260,18 @@ class _PantallaBuscarState extends State<PantallaBuscar> {
|
|||||||
Widget _resultados(EstadoBusqueda estado, ThemeData theme) {
|
Widget _resultados(EstadoBusqueda estado, ThemeData theme) {
|
||||||
final l10n = AppLocalizations.of(context);
|
final l10n = AppLocalizations.of(context);
|
||||||
if (estado.cargando) {
|
if (estado.cargando) {
|
||||||
return const SizedBox(
|
// S5-R6: shimmer placeholders instead of a bare spinner, consistent
|
||||||
height: 220,
|
// with the loading pattern used by the home grid.
|
||||||
child: Center(child: CircularProgressIndicator()),
|
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],
|
emisora: resultados[i],
|
||||||
esCompacta: true,
|
esCompacta: true,
|
||||||
onTap: () => reproducirMinimizado(context, resultados[i]),
|
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,
|
||||||
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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),
|
const SizedBox(height: 8),
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_animate/flutter_animate.dart';
|
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
import 'package:shimmer/shimmer.dart' as shimmer;
|
import 'package:shimmer/shimmer.dart' as shimmer;
|
||||||
|
|
||||||
@@ -7,6 +6,7 @@ import '../estado/estado_busqueda.dart';
|
|||||||
import '../estado/estado_radio.dart';
|
import '../estado/estado_radio.dart';
|
||||||
import '../l10n/gen/app_localizations.dart';
|
import '../l10n/gen/app_localizations.dart';
|
||||||
import '../modelos/emisora.dart';
|
import '../modelos/emisora.dart';
|
||||||
|
import '../tema/pluri_animate.dart';
|
||||||
import '../widgets/pluri_glass_surface.dart';
|
import '../widgets/pluri_glass_surface.dart';
|
||||||
import '../widgets/pluri_icon.dart';
|
import '../widgets/pluri_icon.dart';
|
||||||
import '../widgets/pluri_layout.dart';
|
import '../widgets/pluri_layout.dart';
|
||||||
@@ -230,7 +230,10 @@ class _PantallaInicioState extends State<PantallaInicio> {
|
|||||||
),
|
),
|
||||||
label: Text(e.nombre, maxLines: 1),
|
label: Text(e.nombre, maxLines: 1),
|
||||||
onPressed: () => reproducirMinimizado(context, e),
|
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(
|
(context, i) => TarjetaEmisora(
|
||||||
emisora: emisoras[i],
|
emisora: emisoras[i],
|
||||||
onTap: () => reproducirMinimizado(context, 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,
|
childCount: emisoras.length,
|
||||||
),
|
),
|
||||||
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import 'package:cached_network_image/cached_network_image.dart';
|
import 'package:cached_network_image/cached_network_image.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_animate/flutter_animate.dart';
|
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
import 'package:shimmer/shimmer.dart';
|
import 'package:shimmer/shimmer.dart';
|
||||||
|
|
||||||
@@ -11,6 +10,7 @@ import '../l10n/gen/app_localizations.dart';
|
|||||||
import '../modelos/emisora.dart';
|
import '../modelos/emisora.dart';
|
||||||
import '../servicios/servicio_audio.dart';
|
import '../servicios/servicio_audio.dart';
|
||||||
import '../servicios/servicio_timer.dart';
|
import '../servicios/servicio_timer.dart';
|
||||||
|
import '../tema/pluri_animate.dart';
|
||||||
import '../tema/pluriwave_theme.dart';
|
import '../tema/pluriwave_theme.dart';
|
||||||
import '../widgets/pluri_glass_surface.dart';
|
import '../widgets/pluri_glass_surface.dart';
|
||||||
import '../widgets/pluri_wave_scaffold.dart';
|
import '../widgets/pluri_wave_scaffold.dart';
|
||||||
@@ -129,9 +129,10 @@ class _PantallaReproductorState extends State<PantallaReproductor>
|
|||||||
_WaveHero(
|
_WaveHero(
|
||||||
emisora: emisoraActiva,
|
emisora: emisoraActiva,
|
||||||
estadoStream: estado.estadoStream,
|
estadoStream: estado.estadoStream,
|
||||||
).animate().scale(
|
).pluriScaleIn(
|
||||||
begin: const Offset(0.86, 0.86),
|
context,
|
||||||
duration: 420.ms,
|
begin: 0.86,
|
||||||
|
duration: const Duration(milliseconds: 420),
|
||||||
curve: Curves.easeOutBack,
|
curve: Curves.easeOutBack,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 18),
|
const SizedBox(height: 18),
|
||||||
@@ -143,19 +144,24 @@ class _PantallaReproductorState extends State<PantallaReproductor>
|
|||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
maxLines: 2,
|
maxLines: 2,
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
).animate().fadeIn(delay: 150.ms),
|
).pluriFadeIn(context, delay: const Duration(milliseconds: 150)),
|
||||||
const SizedBox(height: 10),
|
const SizedBox(height: 10),
|
||||||
_InfoChips(
|
_InfoChips(emisora: emisoraActiva).pluriFadeSlideIn(
|
||||||
emisora: emisoraActiva,
|
context,
|
||||||
).animate().fadeIn(delay: 200.ms).slideY(begin: 0.2),
|
delay: const Duration(milliseconds: 200),
|
||||||
|
beginY: 0.2,
|
||||||
|
),
|
||||||
const SizedBox(height: 6),
|
const SizedBox(height: 6),
|
||||||
if (emisoraActiva.codec != null || emisoraActiva.bitrate != null)
|
if (emisoraActiva.codec != null || emisoraActiva.bitrate != null)
|
||||||
Text(
|
Text(
|
||||||
_codecInfo(context, emisoraActiva),
|
_codecInfo(context, emisoraActiva),
|
||||||
style: theme.textTheme.bodySmall?.copyWith(
|
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),
|
const SizedBox(height: 14),
|
||||||
PluriGlassSurface(
|
PluriGlassSurface(
|
||||||
borderRadius: BorderRadius.circular(tokens.radiusLg),
|
borderRadius: BorderRadius.circular(tokens.radiusLg),
|
||||||
@@ -171,16 +177,25 @@ class _PantallaReproductorState extends State<PantallaReproductor>
|
|||||||
color: tokens.warmCoral,
|
color: tokens.warmCoral,
|
||||||
altura: 46,
|
altura: 46,
|
||||||
),
|
),
|
||||||
).animate().fadeIn(delay: 280.ms),
|
).pluriFadeIn(context, delay: const Duration(milliseconds: 280)),
|
||||||
const Spacer(),
|
const Spacer(),
|
||||||
_Controles(
|
_Controles(
|
||||||
estado: estado,
|
estado: estado,
|
||||||
emisora: emisoraActiva,
|
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 SizedBox(height: 14),
|
||||||
const _GrabacionWidget().animate().fadeIn(delay: 360.ms),
|
const _GrabacionWidget().pluriFadeIn(
|
||||||
|
context,
|
||||||
|
delay: const Duration(milliseconds: 360),
|
||||||
|
),
|
||||||
const SizedBox(height: 14),
|
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),
|
const SizedBox(height: 16),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -247,9 +262,7 @@ class _WaveHero extends StatelessWidget {
|
|||||||
height: size + 12,
|
height: size + 12,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
shape: BoxShape.circle,
|
shape: BoxShape.circle,
|
||||||
border: Border.all(
|
border: Border.all(color: t.glassBorder),
|
||||||
color: Colors.white.withValues(alpha: 0.16),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
PluriGlassSurface(
|
PluriGlassSurface(
|
||||||
@@ -275,9 +288,9 @@ class _WaveHero extends StatelessWidget {
|
|||||||
if (cargando)
|
if (cargando)
|
||||||
Container(
|
Container(
|
||||||
color: Colors.black45,
|
color: Colors.black45,
|
||||||
child: const Center(
|
child: Center(
|
||||||
child: CircularProgressIndicator(
|
child: CircularProgressIndicator(
|
||||||
color: Colors.white,
|
color: theme.colorScheme.onSurface,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -288,7 +301,9 @@ class _WaveHero extends StatelessWidget {
|
|||||||
child: Icon(
|
child: Icon(
|
||||||
Icons.wifi_off_rounded,
|
Icons.wifi_off_rounded,
|
||||||
size: 56,
|
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,
|
minWidth: 56,
|
||||||
minHeight: 56,
|
minHeight: 56,
|
||||||
),
|
),
|
||||||
color: Colors.white.withValues(alpha: 0.78),
|
color: theme.colorScheme.onSurface.withValues(alpha: 0.78),
|
||||||
tooltip: l10n.stopAction,
|
tooltip: l10n.stopAction,
|
||||||
onPressed: cargando ? null : estado.detenerReproduccion,
|
onPressed: cargando ? null : estado.detenerReproduccion,
|
||||||
),
|
),
|
||||||
@@ -753,12 +768,12 @@ class _Controles extends StatelessWidget {
|
|||||||
child: Center(
|
child: Center(
|
||||||
child:
|
child:
|
||||||
cargando
|
cargando
|
||||||
? const SizedBox(
|
? SizedBox(
|
||||||
width: 28,
|
width: 28,
|
||||||
height: 28,
|
height: 28,
|
||||||
child: CircularProgressIndicator(
|
child: CircularProgressIndicator(
|
||||||
strokeWidth: 2.5,
|
strokeWidth: 2.5,
|
||||||
color: Colors.white,
|
color: theme.colorScheme.onPrimary,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
: Icon(
|
: Icon(
|
||||||
|
|||||||
@@ -33,6 +33,20 @@ extension PluriAnimate on Widget {
|
|||||||
.scaleXY(begin: begin, end: 1, duration: duration, curve: curve);
|
.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) =>
|
bool _animacionesDeshabilitadas(BuildContext context) =>
|
||||||
MediaQuery.maybeDisableAnimationsOf(context) ?? false;
|
MediaQuery.maybeDisableAnimationsOf(context) ?? false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,9 +36,19 @@ class PluriWaveTokens extends ThemeExtension<PluriWaveTokens> {
|
|||||||
final double spacingMd;
|
final double spacingMd;
|
||||||
final double spacingLg;
|
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(
|
static const dark = PluriWaveTokens(
|
||||||
deepViolet: Color(0xFF07121A),
|
deepViolet: Color(0xFF07121A),
|
||||||
electricMagenta: Color(0xFF21D4D9),
|
electricMagenta: brand,
|
||||||
warmCoral: Color(0xFFF4B860),
|
warmCoral: Color(0xFFF4B860),
|
||||||
glassSurface: Color(0x1FFFFFFF),
|
glassSurface: Color(0x1FFFFFFF),
|
||||||
glassBorder: Color(0x33FFFFFF),
|
glassBorder: Color(0x33FFFFFF),
|
||||||
@@ -86,13 +96,19 @@ class PluriWaveTokens extends ThemeExtension<PluriWaveTokens> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
PluriWaveTokens lerp(covariant ThemeExtension<PluriWaveTokens>? other, double t) {
|
PluriWaveTokens lerp(
|
||||||
|
covariant ThemeExtension<PluriWaveTokens>? other,
|
||||||
|
double t,
|
||||||
|
) {
|
||||||
if (other is! PluriWaveTokens) return this;
|
if (other is! PluriWaveTokens) return this;
|
||||||
return PluriWaveTokens(
|
return PluriWaveTokens(
|
||||||
deepViolet: Color.lerp(deepViolet, other.deepViolet, t) ?? deepViolet,
|
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,
|
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,
|
glassBorder: Color.lerp(glassBorder, other.glassBorder, t) ?? glassBorder,
|
||||||
glowColor: Color.lerp(glowColor, other.glowColor, t) ?? glowColor,
|
glowColor: Color.lerp(glowColor, other.glowColor, t) ?? glowColor,
|
||||||
radiusSm: lerpDouble(radiusSm, other.radiusSm, t) ?? radiusSm,
|
radiusSm: lerpDouble(radiusSm, other.radiusSm, t) ?? radiusSm,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
import '../tema/pluriwave_theme.dart';
|
import '../tema/pluriwave_theme.dart';
|
||||||
|
import '../tema/pluriwave_tokens.dart';
|
||||||
import 'pluri_glass_surface.dart';
|
import 'pluri_glass_surface.dart';
|
||||||
import 'pluri_icon.dart';
|
import 'pluri_icon.dart';
|
||||||
|
|
||||||
@@ -38,12 +39,14 @@ class PluriScreenHeader extends StatelessWidget {
|
|||||||
shape: BoxShape.circle,
|
shape: BoxShape.circle,
|
||||||
gradient: LinearGradient(
|
gradient: LinearGradient(
|
||||||
colors: [
|
colors: [
|
||||||
const Color(0xFF20E6FF).withValues(alpha: 0.95),
|
PluriWaveTokens.brightCyan.withValues(alpha: 0.95),
|
||||||
t.electricMagenta,
|
t.electricMagenta,
|
||||||
t.warmCoral,
|
t.warmCoral,
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
boxShadow: [BoxShadow(color: t.glowColor, blurRadius: 28, spreadRadius: 2)],
|
boxShadow: [
|
||||||
|
BoxShadow(color: t.glowColor, blurRadius: 28, spreadRadius: 2),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
child: Center(
|
child: Center(
|
||||||
child: PluriIcon(
|
child: PluriIcon(
|
||||||
@@ -117,14 +120,22 @@ class PluriScreenHeader extends StatelessWidget {
|
|||||||
Expanded(child: textBlock()),
|
Expanded(child: textBlock()),
|
||||||
if (trailing != null) ...[
|
if (trailing != null) ...[
|
||||||
const SizedBox(width: 12),
|
const SizedBox(width: 12),
|
||||||
ConstrainedBox(constraints: const BoxConstraints(maxWidth: 220), child: trailing!),
|
ConstrainedBox(
|
||||||
|
constraints: const BoxConstraints(maxWidth: 220),
|
||||||
|
child: trailing!,
|
||||||
|
),
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return Padding(
|
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(
|
child: PluriGlassSurface(
|
||||||
borderRadius: BorderRadius.circular(t.radiusLg + 8),
|
borderRadius: BorderRadius.circular(t.radiusLg + 8),
|
||||||
padding: EdgeInsets.symmetric(
|
padding: EdgeInsets.symmetric(
|
||||||
@@ -164,7 +175,10 @@ class PluriScreenHeader extends StatelessWidget {
|
|||||||
Positioned(
|
Positioned(
|
||||||
right: -36,
|
right: -36,
|
||||||
top: -42,
|
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(
|
Positioned(
|
||||||
right: 10,
|
right: 10,
|
||||||
@@ -182,7 +196,10 @@ class PluriScreenHeader extends StatelessWidget {
|
|||||||
Positioned(
|
Positioned(
|
||||||
right: 44,
|
right: 44,
|
||||||
bottom: -54,
|
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(
|
||||||
padding: EdgeInsets.all(compact ? 2 : 4),
|
padding: EdgeInsets.all(compact ? 2 : 4),
|
||||||
@@ -195,7 +212,6 @@ class PluriScreenHeader extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
class PluriStatusPill extends StatelessWidget {
|
class PluriStatusPill extends StatelessWidget {
|
||||||
const PluriStatusPill({
|
const PluriStatusPill({
|
||||||
super.key,
|
super.key,
|
||||||
@@ -232,7 +248,9 @@ class PluriStatusPill extends StatelessWidget {
|
|||||||
label,
|
label,
|
||||||
maxLines: 1,
|
maxLines: 1,
|
||||||
overflow: TextOverflow.ellipsis,
|
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(
|
child: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
PluriIcon(glyph: glyph, variant: PluriIconVariant.activeGlow, size: 58),
|
PluriIcon(
|
||||||
|
glyph: glyph,
|
||||||
|
variant: PluriIconVariant.activeGlow,
|
||||||
|
size: 58,
|
||||||
|
),
|
||||||
const SizedBox(height: 18),
|
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),
|
const SizedBox(height: 8),
|
||||||
Text(
|
Text(
|
||||||
subtitle,
|
subtitle,
|
||||||
textAlign: TextAlign.center,
|
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),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
import '../tema/pluriwave_theme.dart';
|
import '../tema/pluriwave_theme.dart';
|
||||||
|
import '../tema/pluriwave_tokens.dart';
|
||||||
|
|
||||||
class PluriWaveScaffold extends StatelessWidget {
|
class PluriWaveScaffold extends StatelessWidget {
|
||||||
const PluriWaveScaffold({
|
const PluriWaveScaffold({
|
||||||
@@ -31,10 +32,10 @@ class PluriWaveScaffold extends StatelessWidget {
|
|||||||
begin: Alignment.topLeft,
|
begin: Alignment.topLeft,
|
||||||
end: Alignment.bottomRight,
|
end: Alignment.bottomRight,
|
||||||
colors: [
|
colors: [
|
||||||
const Color(0xFF07121A),
|
t.deepViolet,
|
||||||
const Color(0xFF0D1B24),
|
Theme.of(context).colorScheme.surface,
|
||||||
const Color(0xFF0E4A4F),
|
PluriWaveTokens.auroraTeal,
|
||||||
const Color(0xFF07121A),
|
t.deepViolet,
|
||||||
],
|
],
|
||||||
stops: const [0, 0.34, 0.68, 1],
|
stops: const [0, 0.34, 0.68, 1],
|
||||||
),
|
),
|
||||||
@@ -45,17 +46,28 @@ class PluriWaveScaffold extends StatelessWidget {
|
|||||||
Positioned(
|
Positioned(
|
||||||
left: -120,
|
left: -120,
|
||||||
top: -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(
|
Positioned(
|
||||||
right: -150,
|
right: -150,
|
||||||
top: 160,
|
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(
|
Positioned(
|
||||||
left: -90,
|
left: -90,
|
||||||
bottom: 80,
|
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(
|
Positioned.fill(
|
||||||
child: IgnorePointer(
|
child: IgnorePointer(
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import '../l10n/display_names.dart';
|
|||||||
import '../l10n/gen/app_localizations.dart';
|
import '../l10n/gen/app_localizations.dart';
|
||||||
import '../modelos/emisora.dart';
|
import '../modelos/emisora.dart';
|
||||||
import '../tema/pluriwave_theme.dart';
|
import '../tema/pluriwave_theme.dart';
|
||||||
|
import '../tema/pluriwave_tokens.dart';
|
||||||
import 'pluri_glass_surface.dart';
|
import 'pluri_glass_surface.dart';
|
||||||
import 'pluri_icon.dart';
|
import 'pluri_icon.dart';
|
||||||
|
|
||||||
@@ -188,9 +189,19 @@ class _TarjetaEmisoraState extends State<TarjetaEmisora> {
|
|||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
shape: BoxShape.circle,
|
shape: BoxShape.circle,
|
||||||
gradient: SweepGradient(
|
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(
|
ClipRRect(
|
||||||
@@ -268,7 +279,11 @@ class _TarjetaEmisoraState extends State<TarjetaEmisora> {
|
|||||||
: l10n.favoritesAddTooltip,
|
: 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(
|
return Semantics(
|
||||||
|
container: true,
|
||||||
button: true,
|
button: true,
|
||||||
toggled: esFavorito,
|
toggled: esFavorito,
|
||||||
label:
|
label:
|
||||||
@@ -279,11 +294,8 @@ class _TarjetaEmisoraState extends State<TarjetaEmisora> {
|
|||||||
child: InkWell(
|
child: InkWell(
|
||||||
customBorder: const CircleBorder(),
|
customBorder: const CircleBorder(),
|
||||||
onTap: _toggling ? null : _toggle,
|
onTap: _toggling ? null : _toggle,
|
||||||
child: SizedBox(
|
// S5-R2: 48dp minimum touch target in both variants.
|
||||||
width: mini ? 36 : 44,
|
child: SizedBox(width: 48, height: 48, child: Center(child: icono)),
|
||||||
height: mini ? 36 : 44,
|
|
||||||
child: Center(child: icono),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -318,18 +330,21 @@ class _TarjetaEmisoraState extends State<TarjetaEmisora> {
|
|||||||
Image.asset(
|
Image.asset(
|
||||||
art,
|
art,
|
||||||
fit: BoxFit.cover,
|
fit: BoxFit.cover,
|
||||||
errorBuilder: (_, __, ___) => DecoratedBox(
|
errorBuilder:
|
||||||
decoration: BoxDecoration(
|
(_, __, ___) => DecoratedBox(
|
||||||
gradient: LinearGradient(
|
decoration: BoxDecoration(
|
||||||
begin: Alignment.topLeft,
|
gradient: LinearGradient(
|
||||||
end: Alignment.bottomRight,
|
begin: Alignment.topLeft,
|
||||||
colors: [
|
end: Alignment.bottomRight,
|
||||||
context.pluriTokens.deepViolet,
|
colors: [
|
||||||
context.pluriTokens.electricMagenta.withValues(alpha: 0.8),
|
context.pluriTokens.deepViolet,
|
||||||
],
|
context.pluriTokens.electricMagenta.withValues(
|
||||||
|
alpha: 0.8,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
Center(
|
Center(
|
||||||
child: PluriIcon(
|
child: PluriIcon(
|
||||||
@@ -365,7 +380,10 @@ class _LiveBadge extends StatelessWidget {
|
|||||||
final color = Theme.of(context).colorScheme.secondary;
|
final color = Theme.of(context).colorScheme.secondary;
|
||||||
final l10n = AppLocalizations.of(context);
|
final l10n = AppLocalizations.of(context);
|
||||||
return Container(
|
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(
|
decoration: BoxDecoration(
|
||||||
borderRadius: BorderRadius.circular(999),
|
borderRadius: BorderRadius.circular(999),
|
||||||
color: Colors.black.withValues(alpha: 0.35),
|
color: Colors.black.withValues(alpha: 0.35),
|
||||||
@@ -374,10 +392,19 @@ class _LiveBadge extends StatelessWidget {
|
|||||||
child: Row(
|
child: Row(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
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) ...[
|
if (mini) ...[
|
||||||
const SizedBox(width: 5),
|
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.
|
/// 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 {
|
class TarjetaEmisoraShimmer extends StatelessWidget {
|
||||||
const TarjetaEmisoraShimmer({super.key});
|
const TarjetaEmisoraShimmer({super.key, this.esCompacta = false});
|
||||||
|
|
||||||
|
final bool esCompacta;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final theme = Theme.of(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(
|
return Shimmer.fromColors(
|
||||||
baseColor: theme.colorScheme.surfaceContainerHighest,
|
baseColor: color,
|
||||||
highlightColor: theme.colorScheme.surface,
|
highlightColor: theme.colorScheme.surface,
|
||||||
child: Column(
|
child: contenido,
|
||||||
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,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
|
|
||||||
import '../servicios/servicio_audio.dart';
|
import '../servicios/servicio_audio.dart';
|
||||||
|
import '../tema/pluriwave_tokens.dart';
|
||||||
|
|
||||||
/// Visualizador de audio para el reproductor.
|
/// Visualizador de audio para el reproductor.
|
||||||
///
|
///
|
||||||
@@ -234,7 +235,9 @@ class _WaveFlowPainter extends CustomPainter {
|
|||||||
colors: [
|
colors: [
|
||||||
color.withValues(alpha: 0.08),
|
color.withValues(alpha: 0.08),
|
||||||
color.withValues(alpha: active ? 0.95 : 0.35),
|
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),
|
color.withValues(alpha: 0.08),
|
||||||
],
|
],
|
||||||
).createShader(Offset.zero & size);
|
).createShader(Offset.zero & size);
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
**Mode**: Strict TDD (test runner: `flutter test`)
|
**Mode**: Strict TDD (test runner: `flutter test`)
|
||||||
**Artifact store**: openspec (Engram unavailable this session)
|
**Artifact store**: openspec (Engram unavailable this session)
|
||||||
**Delivery**: auto-chain, local apply — no commits, no PRs (user commits at own cadence)
|
**Delivery**: auto-chain, local apply — no commits, no PRs (user commits at own cadence)
|
||||||
**Last updated**: 2026-06-11 (Batch 6)
|
**Last updated**: 2026-06-11 (Batch 7)
|
||||||
|
|
||||||
## Batch log
|
## Batch log
|
||||||
|
|
||||||
@@ -15,6 +15,7 @@
|
|||||||
| 4 | S7 — Streaming resilience (buffer config, reconnect state machine, UI wiring) | COMPLETE (Dart-only batch; stream-drop on-device verification deferred to user) | 2026-06-11 |
|
| 4 | S7 — Streaming resilience (buffer config, reconnect state machine, UI wiring) | COMPLETE (Dart-only batch; stream-drop on-device verification deferred to user) | 2026-06-11 |
|
||||||
| 5 | S4a — ServicioExportImport + EstadoEcualizador extraction + compat getters | COMPLETE (Dart-only batch) | 2026-06-11 |
|
| 5 | S4a — ServicioExportImport + EstadoEcualizador extraction + compat getters | COMPLETE (Dart-only batch) | 2026-06-11 |
|
||||||
| 6 | S4b — EstadoGrabacion + EstadoBusqueda + scoped rebuilds + compat-getter removal | COMPLETE (Dart-only batch) | 2026-06-11 |
|
| 6 | S4b — EstadoGrabacion + EstadoBusqueda + scoped rebuilds + compat-getter removal | COMPLETE (Dart-only batch) | 2026-06-11 |
|
||||||
|
| 7 | S5 — Design system, a11y, i18n, polish | COMPLETE (Dart-only batch) | 2026-06-11 |
|
||||||
|
|
||||||
## Task status (cumulative)
|
## Task status (cumulative)
|
||||||
|
|
||||||
@@ -165,9 +166,32 @@
|
|||||||
| T-S4b-12 | [x] | `flutter analyze` — No issues found |
|
| T-S4b-12 | [x] | `flutter analyze` — No issues found |
|
||||||
| T-S4b-13 | [x] | `dart format` on 15 touched files (10 reflowed); analyze + suite re-run after |
|
| T-S4b-13 | [x] | `dart format` on 15 touched files (10 reflowed); analyze + suite re-run after |
|
||||||
|
|
||||||
|
### Slice S5 — Design system, a11y, i18n — 18/18 complete
|
||||||
|
|
||||||
|
| Task | Status | Notes |
|
||||||
|
|------|--------|-------|
|
||||||
|
| T-S5-01 | [x] | RED: `tarjeta_emisora_a11y_test.dart` — semantics label/button/toggled + ≥48dp. RED exposed a REAL bug: the card InkWell merged the favorite into ONE semantics node (screen readers could not reach the action independently) |
|
||||||
|
| T-S5-02 | [x] | RED: `pluri_animate_test.dart` — fadeIn/scaleIn lock-in + NEW `pluriFadeSlideIn` (honest RED: method missing) |
|
||||||
|
| T-S5-03 | [x] | RED: `pantalla_alarmas_fecha_test.dart` — en-US `6/11/2026`, NOT `11/06/2026`; es day/month |
|
||||||
|
| T-S5-04 | [x] | RED: `pantalla_favoritos_plural_test.dart` — `stationCount` singular ≠ plural (en, es) |
|
||||||
|
| T-S5-05 | [x] | RED: `pantalla_buscar_shimmer_test.dart` — shimmer during loading, NO spinner (cargando-true EstadoBusqueda subclass seam) |
|
||||||
|
| T-S5-06 | [x] | RED: `notification_color_test.dart` — `configuracionAudioService.notificationColor == PluriWaveTokens.brand` |
|
||||||
|
| T-S5-07 | [x] | GREEN: zero `Color(0x...)` outside lib/tema (rg audit). New static tokens `brand`/`brightCyan`/`auroraTeal`/`skyBlue`; scaffold gradient/orbs, alarmas glows, visualizer stop, premium orbs, tarjeta sweep mapped; Colors.grey/green/white semantic uses → colorScheme/tokens |
|
||||||
|
| T-S5-08 | [x] | GREEN: favorite `Semantics(container: true, ...)` + 48×48 in both variants; `_AssetIcon.semanticLabel` (+`excludeFromSemantics` when decorative); alarm/vacation/ringing images labelled via NEW `alarmIconLabel`/`vacationIconLabel` |
|
||||||
|
| T-S5-09 | [x] | GREEN: `pluriFadeSlideIn` added; ALL `flutter_animate` call sites routed through PluriAnimate (inicio chips/grid, buscar results, reproductor ×8); zero direct `.animate()` in lib/ |
|
||||||
|
| T-S5-10 | [x] | GREEN: NEW `lib/l10n/formato_fechas.dart` (`fechaCortaLocalizada`); `_fechaCorta(l10n, fecha)` delegates via `l10n.localeName` (6 call sites) |
|
||||||
|
| T-S5-11 | [x] | GREEN: `stationCount` ICU plural in ALL 13 locales (ru/ar full category sets); NEW `streamUrlHint` replaces hardcoded `stream.ejemplo.com`; gen-l10n run |
|
||||||
|
| T-S5-12 | [x] | GREEN: shimmer rounded (radiusLg/6) + NEW `esCompacta` row variant; PantallaBuscar loading → 4 compact shimmer rows |
|
||||||
|
| T-S5-13 | [x] | GREEN: 9 ajustes icons → `_rounded`; `*_outlined` family left (no rounded-outline variant exists); `_FormularioEmisora` sheet → `useSafeArea` + `showDragHandle` |
|
||||||
|
| T-S5-14 | [x] | GREEN: `configuracionAudioService` top-level const; `notificationColor: PluriWaveTokens.brand` |
|
||||||
|
| T-S5-15 | [x] | Targeted run 11/11 green (RED first: `+0 -6`) |
|
||||||
|
| T-S5-16 | [x] | Full suite 121/121 (110 baseline + 11 new) |
|
||||||
|
| T-S5-17 | [x] | `flutter analyze` — No issues found; color-literal audit ZERO |
|
||||||
|
| T-S5-18 | [x] | `dart format` on 20 touched Dart files (7 reflowed); analyze + suite re-run after |
|
||||||
|
|
||||||
### Remaining slices (not started)
|
### Remaining slices (not started)
|
||||||
|
|
||||||
S5, S6, cross-cutting (T-CC-01, T-CC-02) — all pending.
|
S6, cross-cutting (T-CC-01, T-CC-02) — pending. S6 is now UNBLOCKED (depends on S4b + S5, both complete).
|
||||||
|
|
||||||
## Snooze defect fixes (design audit D1–D5 / S1–S5)
|
## Snooze defect fixes (design audit D1–D5 / S1–S5)
|
||||||
|
|
||||||
@@ -236,6 +260,61 @@ RED run evidence (Batch 5): `00:00 +0 -2` (both files fail to load — captured
|
|||||||
|
|
||||||
RED run evidence (Batch 6): `00:00 +0 -3` (all three files fail to load — captured before any lib code). GREEN: targeted 8/8; full suite `00:11 +110: All tests passed!` (103 baseline − 1 moved + 8 new); analyze + suite re-run after format.
|
RED run evidence (Batch 6): `00:00 +0 -3` (all three files fail to load — captured before any lib code). GREEN: targeted 8/8; full suite `00:11 +110: All tests passed!` (103 baseline − 1 moved + 8 new); analyze + suite re-run after format.
|
||||||
|
|
||||||
|
### Batch 7 TDD Cycle Evidence (S5)
|
||||||
|
|
||||||
|
| Task | RED (test written first, failing) | GREEN (implementation passes) | REFACTOR |
|
||||||
|
|------|-----------------------------------|-------------------------------|----------|
|
||||||
|
| T-S5-01/T-S5-08 | `find.bySemanticsLabel` found 0 nodes — the card InkWell MERGED the favorite into one node (real a11y defect, probed with a semantics dump) | `Semantics(container: true)` + 48dp target; node found, button/toggled flags pass | `hasFlag` (deprecated) → `flagsCollection` + `Tristate`; explicit `semantics.dispose()` (addTearDown fires too late for the handle check) |
|
||||||
|
| T-S5-02/T-S5-09 | Compile failure: `pluriFadeSlideIn` undefined | Helper added; 4/4 animate tests green | — |
|
||||||
|
| T-S5-03/T-S5-10 | Load failure: `formato_fechas.dart` missing | `fechaCortaLocalizada` + delegation; en-US/es tests pass | — |
|
||||||
|
| T-S5-04/T-S5-11 | Compile failure: `stationCount` missing from AppLocalizations | ARB plural ×13 + gen-l10n; both locale tests pass | — |
|
||||||
|
| T-S5-05/T-S5-12 | `TarjetaEmisoraShimmer` found 0, spinner found 1 | Compact shimmer variant + buscar loading swap | Shimmer block builder extracted (`bloque`) shared by both variants |
|
||||||
|
| T-S5-06/T-S5-14 | Compile failure: `configuracionAudioService`/`PluriWaveTokens.brand` undefined | Const extraction + brand token; test passes | `electricMagenta` re-pointed at `brand` (no duplicate literal) |
|
||||||
|
|
||||||
|
RED run evidence (Batch 7): `00:02 +0 -6` (4 compile/load failures + 2 honest assertion failures) captured before any lib code. GREEN: targeted 11/11; full suite `00:14 +121: All tests passed!`; analyze + suite re-run after `dart format`.
|
||||||
|
|
||||||
|
## Files changed (Batch 7)
|
||||||
|
|
||||||
|
| File | Action | ~Lines |
|
||||||
|
|------|--------|--------|
|
||||||
|
| `lib/tema/pluriwave_tokens.dart` | Modified | +12 (static `brand`/`brightCyan`/`auroraTeal`/`skyBlue` token definitions; format reflow) |
|
||||||
|
| `lib/tema/pluri_animate.dart` | Modified | +14 (`pluriFadeSlideIn`) |
|
||||||
|
| `lib/l10n/formato_fechas.dart` | Created | +14 (locale-aware short date) |
|
||||||
|
| `lib/main.dart` | Modified | +13/-8 (`configuracionAudioService` const, brand notification color) |
|
||||||
|
| `lib/widgets/pluri_wave_scaffold.dart` | Modified | +8/-8 (gradient + orbs → tokens/colorScheme) |
|
||||||
|
| `lib/widgets/tarjeta_emisora.dart` | Modified | +60/-30 (brightCyan, favorite container semantics + 48dp, rounded shimmer + compact variant) |
|
||||||
|
| `lib/widgets/visualizador_audio.dart` | Modified | +2/-1 (warmCoral token) |
|
||||||
|
| `lib/widgets/pluri_premium_widgets.dart` | Modified | +3/-2 (brightCyan) |
|
||||||
|
| `lib/pantallas/pantalla_alarmas.dart` | Modified | +55/-30 (glow tokens, `_AssetIcon` semanticLabel, locale-aware `_fechaCorta`, vacaciones initState l10n fix) |
|
||||||
|
| `lib/pantallas/pantalla_favoritos.dart` | Modified | +2/-1 (plural counter) |
|
||||||
|
| `lib/pantallas/pantalla_buscar.dart` | Modified | +18/-7 (shimmer loading, pluriFadeSlideIn) |
|
||||||
|
| `lib/pantallas/pantalla_ajustes.dart` | Modified | +20/-12 (rounded icons, semantic colors, l10n hint, sheet conventions) |
|
||||||
|
| `lib/pantallas/pantalla_reproductor.dart` | Modified | +25/-20 (PluriAnimate routing ×8, white overlays → colorScheme/tokens) |
|
||||||
|
| `lib/pantallas/pantalla_inicio.dart` | Modified | +6/-3 (PluriAnimate routing) |
|
||||||
|
| `lib/l10n/app_*.arb` (13 files) | Modified | +4-5 each (`stationCount` plural, `alarmIconLabel`, `vacationIconLabel`, `streamUrlHint`) |
|
||||||
|
| `lib/l10n/gen/*` (14 files) | Regenerated | by `flutter gen-l10n` |
|
||||||
|
| `test/widgets/tarjeta_emisora_a11y_test.dart` | Created | +72 (1 test) |
|
||||||
|
| `test/tema/pluri_animate_test.dart` | Created | +80 (4 tests) |
|
||||||
|
| `test/tema/notification_color_test.dart` | Created | +17 (1 test) |
|
||||||
|
| `test/pantallas/pantalla_alarmas_fecha_test.dart` | Created | +29 (2 tests) |
|
||||||
|
| `test/pantallas/pantalla_favoritos_plural_test.dart` | Created | +21 (2 tests) |
|
||||||
|
| `test/pantallas/pantalla_buscar_shimmer_test.dart` | Created | +42 (1 test) |
|
||||||
|
|
||||||
|
Total Batch 7 lib diff: ~240 insertions / ~120 deletions (incl. ARB, excl. gen/), plus ~260 lines of new tests. Within the ~210-line slice estimate for hand-written lib changes. No Kotlin/native files touched.
|
||||||
|
|
||||||
|
## Deviations from design (Batch 7)
|
||||||
|
|
||||||
|
1. **`Semantics(container: true)` on the favorite button (not in task text).** The task only asked for `Semantics(button, label)` — which ALREADY existed. The RED test proved the real defect: the card-level InkWell merged the favorite into a single semantics node, so screen readers could not reach the action. `container: true` forces an own node; this is the actual S5-R2 fix.
|
||||||
|
2. **`fechaCortaLocalizada` lives in NEW `lib/l10n/formato_fechas.dart`** (task said edit `_fechaCorta` in place). The private top-level function is untestable from `flutter test`; the public helper takes a locale tag (testable without widgets) and `_fechaCorta(l10n, fecha)` delegates via `l10n.localeName`. Spec scenario S5-R4-A is met verbatim.
|
||||||
|
3. **Brand token is `PluriWaveTokens.brand`, not `brandColor`** (task text guessed the name). Defined as a `static const` so it works in the `const AudioServiceConfig` context; `electricMagenta` now references it (single source for 0xFF21D4D9). `brightCyan`/`auroraTeal`/`skyBlue` added the same way for palette colors that had no token.
|
||||||
|
4. **`Colors.white` overlays mapped to `colorScheme.onSurface`/`onPrimary` and `tokens.glassBorder`** — the scheme's `onSurface` is 0xFFF2F7FA (near-white), so visuals are intentionally near-identical while becoming theme-driven.
|
||||||
|
5. **`pluriScaleIn` on the hero adds a fade** the old bare `.scale()` did not have (the shared helper pairs fade+scale). Visually negligible at 420 ms; keeping one canonical scale-entry beats a third helper.
|
||||||
|
6. **Icon variants: only base-name icons got `_rounded`** (9 sites). The `*_outlined` Material family (folder_outlined, backup_outlined, upload/download_outlined, verified_outlined, music_note_outlined) has NO rounded-outline variant; switching them to filled `_rounded` would change their visual weight, so they stay outlined.
|
||||||
|
7. **`streamUrlHint` added as an l10n key** (the prompt's "handled per design"): the hardcoded hint leaked a Spanish-looking host (`stream.ejemplo.com`) into all 13 locales; the key ships the same neutral `stream.example.com` URL everywhere but is now localizable.
|
||||||
|
8. **`_EditorVacacionesSheet` initState l10n crash fixed** (flagged in Batch 2 as a latent debug-mode crash, explicitly brought into this batch's scope): controller now created lazily in `didChangeDependencies`, mirroring the alarm-editor fix.
|
||||||
|
9. **Pagination spinner in PantallaBuscar kept** — S5-R6 covers the initial loading state (explore C11, lines 241-245); the small inline load-more spinner is a different affordance and was left untouched.
|
||||||
|
10. **`flagsCollection`/`Tristate` instead of `SemanticsFlag.hasFlag`** in the a11y test — `hasFlag` is deprecated in the current SDK and `flutter analyze` flags it; also `tester.ensureSemantics()` must be disposed in the test body (an `addTearDown` runs after the framework's handle-leak check).
|
||||||
|
|
||||||
## Files changed (Batch 2)
|
## Files changed (Batch 2)
|
||||||
|
|
||||||
| File | Action | ~Lines |
|
| File | Action | ~Lines |
|
||||||
@@ -495,9 +574,28 @@ From tasks.md Section 11 — S1 items still pending from Batch 1, plus new S2 it
|
|||||||
4. **Scoped rebuilds (S4-R5):** while audio plays/buffers, home/favorites/settings should feel identical (no visual change expected — the win is fewer rebuilds); list reordering in Ajustes still re-sorts home, search results and favorites.
|
4. **Scoped rebuilds (S4-R5):** while audio plays/buffers, home/favorites/settings should feel identical (no visual change expected — the win is fewer rebuilds); list reordering in Ajustes still re-sorts home, search results and favorites.
|
||||||
5. **Stop recording on pause/stop/station switch:** unchanged orchestration in EstadoRadio — verify recording stops when playback pauses/stops or station changes.
|
5. **Stop recording on pause/stop/station switch:** unchanged orchestration in EstadoRadio — verify recording stops when playback pauses/stops or station changes.
|
||||||
|
|
||||||
|
## Verification summary (Batch 7)
|
||||||
|
|
||||||
|
- `flutter test`: 121/121 passing (110 baseline + 11 new across 6 files); re-run after `dart format`
|
||||||
|
- `flutter analyze`: No issues found (identical to baseline); re-run after format
|
||||||
|
- `dart format`: applied to all 20 touched Dart files (7 reflowed); gen/ untouched by hand
|
||||||
|
- `flutter gen-l10n`: run once after the 13 .arb edits
|
||||||
|
- Color-literal audit: `rg 'Color(0x' lib` excluding `lib/tema/` and `lib/l10n/gen/` → **0 matches** (was 14)
|
||||||
|
- `rg '.animate()' lib` → 0 direct flutter_animate call sites (all via PluriAnimate)
|
||||||
|
- `flutter build`: NOT run (forbidden)
|
||||||
|
- No Kotlin/native files touched in this batch
|
||||||
|
|
||||||
|
### Manual verification items added by Batch 7 (user)
|
||||||
|
|
||||||
|
1. **Visual parity sweep (S5-R1):** home/buscar/favoritos/ajustes/alarmas/reproductor look unchanged — token mapping was value-preserving (onSurface ≈ white; electricMagenta = brand). The settings "check" icons are now mint (`colorScheme.secondary`) instead of Material green — intentional.
|
||||||
|
2. **TalkBack (S5-R2):** on a station card, the favorite toggle is now reachable as its OWN button ("Añadir a favoritos / Quitar de favoritos") separate from the card; alarm/vacation images announce their labels.
|
||||||
|
3. **Reduced motion (S5-R3):** with "remove animations" enabled, home grid/search results/player screens render instantly with no entry animations.
|
||||||
|
4. **Locale dates (S5-R4):** switch app language to English → alarm editor and vacation ranges show M/D/Y order; Japanese shows Y/M/D.
|
||||||
|
5. **Media notification (S5-R8):** the playback notification accent is teal (brand), not purple, on devices that honor `notificationColor`.
|
||||||
|
|
||||||
## Workload / boundary
|
## Workload / boundary
|
||||||
|
|
||||||
- Mode: auto-chain local slices (no PRs)
|
- Mode: auto-chain local slices (no PRs)
|
||||||
- Current work units: S1, S2a, S2b, S3a, S3b, S7, S4a (committed, latest 0416b30), S4b (complete, in working tree)
|
- Current work units: S1, S2a, S2b, S3a, S3b, S7, S4a, S4b (committed, latest 52855e7), S5 (complete, in working tree)
|
||||||
- Boundary (Batch 6): starts from the clean post-0416b30 tree; ends with S4b fully checked off, suite green (110/110). Rollback = revert the 9 modified lib/test files + delete the 6 new files (Dart-only; no native edits).
|
- Boundary (Batch 7): starts from the clean post-52855e7 tree; ends with S5 fully checked off, suite green (121/121). Rollback = revert the 27 modified lib files (incl. 13 ARB + gen/) + delete the 7 new files (Dart-only; no native edits).
|
||||||
- Next batch: S5 (design system / a11y / i18n — unblocked since S2b) then S6 (quality gates — now unblocked: depends on S4b + S5).
|
- Next batch: S6 (quality gates — lint hardening + remaining top-5 tests) then cross-cutting T-CC-01/T-CC-02. S6 is the LAST slice.
|
||||||
|
|||||||
@@ -363,30 +363,30 @@ Chain strategy: N/A (local apply)
|
|||||||
|
|
||||||
### S5 pre-work: write failing tests
|
### S5 pre-work: write failing tests
|
||||||
|
|
||||||
- [ ] **T-S5-01** [RED] Create `test/widgets/tarjeta_emisora_a11y_test.dart`: favorite `InkWell` has semantic label + `button:true`; size ≥ 48×48 dp (S5-R2-A). **~20 lines.**
|
- [x] **T-S5-01** [RED] Create `test/widgets/tarjeta_emisora_a11y_test.dart`: favorite `InkWell` has semantic label + `button:true`; size ≥ 48×48 dp (S5-R2-A). **DONE — RED exposed a REAL merge bug: the card InkWell absorbed the favorite into one semantics node; fixed with `Semantics(container: true)`. Toggled state asserted via `flagsCollection` (Tristate API; `hasFlag` is deprecated).**
|
||||||
- [ ] **T-S5-02** [RED] Add test in `test/tema/pluri_animate_test.dart`: `pluriFadeIn` returns unanimated child when `disableAnimations=true` (S5-R3-A). **~15 lines.**
|
- [x] **T-S5-02** [RED] `test/tema/pluri_animate_test.dart`: `pluriFadeIn`/`pluriScaleIn` lock-in (already implemented in S2b) + NEW `pluriFadeSlideIn` (honest RED: method missing). 4 tests. **DONE.**
|
||||||
- [ ] **T-S5-03** [RED] Create `test/pantallas/pantalla_alarmas_fecha_test.dart`: `_fechaCorta` with locale `en-US` returns `DateFormat.yMd('en-US')` result, NOT `11/06/2026` (S5-R4-A). **~15 lines.**
|
- [x] **T-S5-03** [RED] `test/pantallas/pantalla_alarmas_fecha_test.dart`: en-US returns `6/11/2026` (NOT `11/06/2026`) + es day/month order. Tests the NEW public helper `fechaCortaLocalizada` (private `_fechaCorta` is untestable; see deviation). **DONE.**
|
||||||
- [ ] **T-S5-04** [RED] Add test `test/pantallas/pantalla_favoritos_plural_test.dart`: plural form changes between 1 and 5 station count strings (S5-R5). **~10 lines.**
|
- [x] **T-S5-04** [RED] `test/pantallas/pantalla_favoritos_plural_test.dart`: `stationCount(1)` differs from `stationCount(5)` in en and es. **DONE.**
|
||||||
- [ ] **T-S5-05** [RED] Add widget test: shimmer present during loading state in `PantallaBuscar` (S5-R6). **~10 lines.**
|
- [x] **T-S5-05** [RED] `test/pantallas/pantalla_buscar_shimmer_test.dart`: loading shows `TarjetaEmisoraShimmer`, NO `CircularProgressIndicator` (cargando-true EstadoBusqueda subclass seam). **DONE.**
|
||||||
- [ ] **T-S5-06** [RED] Add unit test: `AudioServiceConfig.notificationColor` equals brand color token (S5-R8). **~10 lines.**
|
- [x] **T-S5-06** [RED] `test/tema/notification_color_test.dart`: `configuracionAudioService.notificationColor == PluriWaveTokens.brand` (config extracted to a testable top-level const). **DONE.**
|
||||||
|
|
||||||
### S5 implementation
|
### S5 implementation
|
||||||
|
|
||||||
- [ ] **T-S5-07** [GREEN] Edit all 14+ remaining `Color(0x...)` literal sites identified in explore C3 (files: `lib/pantallas/`, `lib/widgets/`, excluding `pantalla_alarma_sonando.dart` done in S2b): replace with `PluriWaveTokens` or `Theme.of(context).colorScheme` references. **Reqs:** S5-R1. **~30 lines across files.**
|
- [x] **T-S5-07** [GREEN] ALL `Color(0x...)` literals outside `lib/tema/` removed (rg audit = 0 matches). New token DEFINITIONS in `PluriWaveTokens`: static `brand` (0xFF21D4D9, now referenced by `electricMagenta`), `brightCyan`, `auroraTeal`, `skyBlue`. Mapped: scaffold gradient → deepViolet/colorScheme.surface/auroraTeal; orbs → electricMagenta + colorScheme.secondary; alarmas glows → warmCoral/electricMagenta/skyBlue; visualizer gradient stop → warmCoral; premium orbs + tarjeta sweep → brightCyan. ALSO `Colors.grey` → onSurface(0.6), `Colors.green` → colorScheme.secondary, `Colors.white` overlays in reproductor → onSurface/onPrimary/glassBorder. **Reqs:** S5-R1. **DONE.**
|
||||||
- [ ] **T-S5-08** [GREEN] Edit `lib/widgets/tarjeta_emisora.dart` (lines 238-289): wrap mini favorite `InkWell` in `Semantics(button: true, label: l10n.toggleFavorite)`; set `constraints: BoxConstraints(minWidth: 48, minHeight: 48)`. Add `semanticLabel` to `_AssetIcon`/alarm PNG. **Reqs:** S5-R2. **~15 lines.**
|
- [x] **T-S5-08** [GREEN] Favorite button: `Semantics(container: true, button, toggled, label)` (container REQUIRED — see T-S5-01) + 48×48 target in both variants. `_AssetIcon` gained `semanticLabel` (decorative images get `excludeFromSemantics: true`); labels on alarm/vacation images + ringing-screen image via NEW keys `alarmIconLabel`/`vacationIconLabel`. **Reqs:** S5-R2. **DONE.**
|
||||||
- [ ] **T-S5-09** [GREEN] `lib/tema/pluri_animate.dart` already created in S2b (T-S2b-04). Verify tests pass (no new code needed here unless edge case found).
|
- [x] **T-S5-09** [GREEN] `pluri_animate.dart` extended with `pluriFadeSlideIn`; ALL remaining `flutter_animate` call sites routed through PluriAnimate (pantalla_inicio chips + grid stagger, pantalla_buscar results, pantalla_reproductor 8 sites). Zero direct `.animate()` left in lib/. **Reqs:** S5-R3. **DONE.**
|
||||||
- [ ] **T-S5-10** [GREEN] Edit `lib/pantallas/pantalla_alarmas.dart` `_fechaCorta` (line 1114): replace hardcoded format string with `intl.DateFormat.yMd(Localizations.localeOf(context).toLanguageTag()).format(date)`. **Reqs:** S5-R4. **~5 lines.**
|
- [x] **T-S5-10** [GREEN] NEW `lib/l10n/formato_fechas.dart` → `fechaCortaLocalizada(localeTag, fecha)`; `_fechaCorta(l10n, fecha)` delegates with `l10n.localeName` (all 6 call sites updated). **Reqs:** S5-R4. **DONE.**
|
||||||
- [ ] **T-S5-11** [GREEN] Edit `lib/pantallas/pantalla_favoritos.dart` (line 138): replace bare counter string with ARB plural form using `AppLocalizations` `stationCount(n)` plural message. Add the ARB plural entry to `lib/l10n/*.arb` files for all supported locales. **Reqs:** S5-R5. **~20 lines (Dart) + ARB entries.**
|
- [x] **T-S5-11** [GREEN] Group counter → `l10n.stationCount(n)` ICU plural in ALL 13 locales (ru one/few/other, ar one/two/few/other, ja/zh/id other-only). Also NEW `streamUrlHint` key replacing the hardcoded `stream.ejemplo.com` hint. `flutter gen-l10n` regenerated. **Reqs:** S5-R5. **DONE.**
|
||||||
- [ ] **T-S5-12** [GREEN] Edit `lib/widgets/tarjeta_emisora.dart` shimmer placeholders (lines 389-420): apply `BorderRadius` matching card corners. Edit `lib/pantallas/pantalla_buscar.dart` (lines 241-245): replace spinner with shimmer during loading state. **Reqs:** S5-R6. **~20 lines.**
|
- [x] **T-S5-12** [GREEN] `TarjetaEmisoraShimmer` rounded (radiusLg image block, radius-6 text lines) + NEW `esCompacta` variant mirroring the search row layout; `PantallaBuscar` loading → 4 compact shimmer rows (pagination spinner untouched, out of S5-R6 scope). **Reqs:** S5-R6. **DONE.**
|
||||||
- [ ] **T-S5-13** [GREEN] Edit `lib/pantallas/pantalla_ajustes.dart` icon sites (lines 985, 1028, 1031): replace non-`_rounded` icon variants with their `_rounded` equivalents. **Reqs:** S5-R7. **~5 lines.**
|
- [x] **T-S5-13** [GREEN] 9 icon sites → `_rounded` variants (radio_button_checked, equalizer, add_circle_outline, add, radio, play_arrow, delete_outline, favorite_outline, check_circle). `*_outlined`-family icons left as-is (no rounded-outline variant exists in Material). ALSO `_FormularioEmisora` sheet gained `useSafeArea`/`showDragHandle`. **Reqs:** S5-R7. **DONE.**
|
||||||
- [ ] **T-S5-14** [GREEN] Edit `lib/main.dart` (line 23) `AudioServiceConfig`: set `notificationColor` to `PluriWaveTokens.brandColor` (or equivalent token). **Reqs:** S5-R8. **~3 lines.**
|
- [x] **T-S5-14** [GREEN] Config extracted to top-level `configuracionAudioService` const; `notificationColor: PluriWaveTokens.brand` (static const → usable in const context). **Reqs:** S5-R8. **DONE.**
|
||||||
|
|
||||||
### S5 verification
|
### S5 verification
|
||||||
|
|
||||||
- [ ] **T-S5-15** Run `flutter test test/widgets/tarjeta_emisora_a11y_test.dart test/tema/pluri_animate_test.dart test/pantallas/pantalla_alarmas_fecha_test.dart test/pantallas/pantalla_favoritos_plural_test.dart`.
|
- [x] **T-S5-15** Targeted run (6 files, 11 tests) — all green (RED captured first: `+0 -6`).
|
||||||
- [ ] **T-S5-16** Run `flutter test` (full suite) — no regressions.
|
- [x] **T-S5-16** Full suite 121/121 (110 baseline + 11 new), no regressions.
|
||||||
- [ ] **T-S5-17** Run `flutter analyze` — zero errors (no `Color(0x...)` in modified files beyond token definitions).
|
- [x] **T-S5-17** `flutter analyze` — `No issues found!`; `rg 'Color(0x' lib` outside lib/tema + l10n/gen → ZERO matches.
|
||||||
- [ ] **T-S5-18** Run `dart format` on all edited files.
|
- [x] **T-S5-18** `dart format` on all 20 touched Dart files (7 reflowed); analyze + suite re-run after format.
|
||||||
|
|
||||||
### S5 Definition of Done
|
### S5 Definition of Done
|
||||||
- `flutter test` green.
|
- `flutter test` green.
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:intl/date_symbol_data_local.dart';
|
||||||
|
import 'package:intl/intl.dart';
|
||||||
|
import 'package:pluriwave/l10n/formato_fechas.dart';
|
||||||
|
|
||||||
|
/// S5-R4: short dates must follow the active locale, not a hardcoded
|
||||||
|
/// DD/MM/YYYY pattern.
|
||||||
|
void main() {
|
||||||
|
test('en-US usa el orden mes/día, no el formato fijo DD/MM/YYYY', () {
|
||||||
|
final fecha = DateTime(2026, 6, 11);
|
||||||
|
final resultado = fechaCortaLocalizada('en-US', fecha);
|
||||||
|
|
||||||
|
expect(resultado, DateFormat.yMd('en-US').format(fecha));
|
||||||
|
expect(resultado, isNot('11/06/2026'));
|
||||||
|
expect(resultado, '6/11/2026');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('es usa el orden día/mes', () async {
|
||||||
|
await initializeDateFormatting('es');
|
||||||
|
final fecha = DateTime(2026, 6, 11);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
fechaCortaLocalizada('es', fecha),
|
||||||
|
DateFormat.yMd('es').format(fecha),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:pluriwave/estado/estado_busqueda.dart';
|
||||||
|
import 'package:pluriwave/l10n/gen/app_localizations.dart';
|
||||||
|
import 'package:pluriwave/pantallas/pantalla_buscar.dart';
|
||||||
|
import 'package:pluriwave/widgets/tarjeta_emisora.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
|
import '../helpers/fakes.dart';
|
||||||
|
|
||||||
|
/// S5-R6: the search loading state uses shimmer placeholders, not a bare
|
||||||
|
/// spinner, to stay consistent with the rest of the app.
|
||||||
|
class _BusquedaCargando extends EstadoBusqueda {
|
||||||
|
_BusquedaCargando() : super(radio: FakeServicioRadio());
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool get cargando => true;
|
||||||
|
}
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
testWidgets('PantallaBuscar muestra shimmer mientras carga', (tester) async {
|
||||||
|
final busqueda = _BusquedaCargando();
|
||||||
|
addTearDown(busqueda.dispose);
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
ListenableProvider<EstadoBusqueda>.value(
|
||||||
|
value: busqueda,
|
||||||
|
child: MaterialApp(
|
||||||
|
locale: const Locale('es'),
|
||||||
|
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||||
|
supportedLocales: AppLocalizations.supportedLocales,
|
||||||
|
home: const Scaffold(body: PantallaBuscar()),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
expect(find.byType(TarjetaEmisoraShimmer), findsWidgets);
|
||||||
|
expect(find.byType(CircularProgressIndicator), findsNothing);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:pluriwave/l10n/gen/app_localizations.dart';
|
||||||
|
|
||||||
|
/// S5-R5: bare counters must use proper ARB plural messages.
|
||||||
|
void main() {
|
||||||
|
test('stationCount cambia entre singular y plural (en)', () async {
|
||||||
|
final l10n = await AppLocalizations.delegate.load(const Locale('en'));
|
||||||
|
|
||||||
|
expect(l10n.stationCount(1), '1 station');
|
||||||
|
expect(l10n.stationCount(5), '5 stations');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('stationCount cambia entre singular y plural (es)', () async {
|
||||||
|
final l10n = await AppLocalizations.delegate.load(const Locale('es'));
|
||||||
|
|
||||||
|
expect(l10n.stationCount(1), isNot(l10n.stationCount(5)));
|
||||||
|
expect(l10n.stationCount(5), contains('5'));
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:pluriwave/main.dart';
|
||||||
|
import 'package:pluriwave/tema/pluriwave_tokens.dart';
|
||||||
|
|
||||||
|
/// S5-R8: the audio notification accent is the PluriWave brand color,
|
||||||
|
/// not the M3 default purple.
|
||||||
|
void main() {
|
||||||
|
test('AudioServiceConfig usa el color de marca', () {
|
||||||
|
expect(configuracionAudioService.notificationColor, PluriWaveTokens.brand);
|
||||||
|
expect(
|
||||||
|
configuracionAudioService.notificationColor,
|
||||||
|
isNot(const Color(0xFF6750A4)),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_animate/flutter_animate.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:pluriwave/tema/pluri_animate.dart';
|
||||||
|
|
||||||
|
/// S5-R3: the central reduced-motion gate. When the OS reports
|
||||||
|
/// disableAnimations, the helpers must return the child UNANIMATED
|
||||||
|
/// (no [Animate] wrapper at all).
|
||||||
|
void main() {
|
||||||
|
Widget host({required bool reducedMotion, required WidgetBuilder builder}) {
|
||||||
|
return MediaQuery(
|
||||||
|
data: MediaQueryData(disableAnimations: reducedMotion),
|
||||||
|
child: Directionality(
|
||||||
|
textDirection: TextDirection.ltr,
|
||||||
|
child: Builder(builder: builder),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
testWidgets('pluriFadeIn anima en modo normal', (tester) async {
|
||||||
|
await tester.pumpWidget(
|
||||||
|
host(
|
||||||
|
reducedMotion: false,
|
||||||
|
builder: (context) => const Text('hola').pluriFadeIn(context),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(find.byType(Animate), findsOneWidget);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('pluriFadeIn devuelve el hijo intacto con reduced motion', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
await tester.pumpWidget(
|
||||||
|
host(
|
||||||
|
reducedMotion: true,
|
||||||
|
builder: (context) => const Text('hola').pluriFadeIn(context),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(find.byType(Animate), findsNothing);
|
||||||
|
expect(find.text('hola'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('pluriFadeSlideIn respeta el gate de reduced motion', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
await tester.pumpWidget(
|
||||||
|
host(
|
||||||
|
reducedMotion: true,
|
||||||
|
builder:
|
||||||
|
(context) =>
|
||||||
|
const Text('hola').pluriFadeSlideIn(context, beginY: 0.2),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(find.byType(Animate), findsNothing);
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
host(
|
||||||
|
reducedMotion: false,
|
||||||
|
builder:
|
||||||
|
(context) =>
|
||||||
|
const Text('hola').pluriFadeSlideIn(context, beginY: 0.2),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(find.byType(Animate), findsOneWidget);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('pluriScaleIn respeta el gate de reduced motion', (tester) async {
|
||||||
|
await tester.pumpWidget(
|
||||||
|
host(
|
||||||
|
reducedMotion: true,
|
||||||
|
builder: (context) => const Text('hola').pluriScaleIn(context),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(find.byType(Animate), findsNothing);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import 'dart:ui' show Tristate;
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:pluriwave/estado/estado_radio.dart';
|
||||||
|
import 'package:pluriwave/l10n/gen/app_localizations.dart';
|
||||||
|
import 'package:pluriwave/widgets/tarjeta_emisora.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
|
import '../helpers/fakes.dart';
|
||||||
|
|
||||||
|
/// S5-R2: the mini favorite button in the full card must be accessible
|
||||||
|
/// (semantic button with label + toggled state) and reach a 48dp target.
|
||||||
|
void main() {
|
||||||
|
setUp(() {
|
||||||
|
SharedPreferences.setMockInitialValues({});
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('el favorito mini es accesible y mide al menos 48x48 dp', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
final semantics = tester.ensureSemantics();
|
||||||
|
final estado = EstadoRadio(
|
||||||
|
audio: FakeServicioAudio(),
|
||||||
|
favoritos: FakeServicioFavoritos(),
|
||||||
|
radio: FakeServicioRadio(),
|
||||||
|
servicioEcualizador: FakeServicioEcualizador(),
|
||||||
|
iniciarAutomaticamente: false,
|
||||||
|
);
|
||||||
|
addTearDown(estado.dispose);
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
ChangeNotifierProvider<EstadoRadio>.value(
|
||||||
|
value: estado,
|
||||||
|
child: MaterialApp(
|
||||||
|
locale: const Locale('es'),
|
||||||
|
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||||
|
supportedLocales: AppLocalizations.supportedLocales,
|
||||||
|
home: Scaffold(
|
||||||
|
body: Center(
|
||||||
|
child: SizedBox(
|
||||||
|
width: 220,
|
||||||
|
height: 300,
|
||||||
|
// Full (non-compact) card: it renders the MINI favorite.
|
||||||
|
child: TarjetaEmisora(
|
||||||
|
emisora: emisoraDemo(uuid: 'a11y-1', nombre: 'A11y FM'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
final l10n = await AppLocalizations.delegate.load(const Locale('es'));
|
||||||
|
|
||||||
|
// Semantic node: button + label (favorite is OFF, so the "add" label).
|
||||||
|
final boton = find.bySemanticsLabel(l10n.favoritesAddTooltip);
|
||||||
|
expect(boton, findsOneWidget);
|
||||||
|
final nodo = tester.getSemantics(boton);
|
||||||
|
final flags = nodo.flagsCollection;
|
||||||
|
expect(flags.isButton, isTrue);
|
||||||
|
// Toggled state present (favorite OFF) — tristate: not null, not true.
|
||||||
|
expect(flags.isToggled, Tristate.isFalse);
|
||||||
|
|
||||||
|
// Touch target: at least 48x48 dp (S5-R2-A).
|
||||||
|
final size = tester.getSize(boton);
|
||||||
|
expect(size.width, greaterThanOrEqualTo(48));
|
||||||
|
expect(size.height, greaterThanOrEqualTo(48));
|
||||||
|
|
||||||
|
semantics.dispose();
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user