From 202bef353906a46271efc78793a9af6b91b7a8b3 Mon Sep 17 00:00:00 2001 From: FreeTLab Date: Thu, 11 Jun 2026 23:42:16 +0200 Subject: [PATCH] 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 --- lib/l10n/app_ar.arb | 6 +- lib/l10n/app_bn.arb | 6 +- lib/l10n/app_de.arb | 6 +- lib/l10n/app_en.arb | 6 +- lib/l10n/app_es.arb | 14 +- lib/l10n/app_fr.arb | 6 +- lib/l10n/app_hi.arb | 6 +- lib/l10n/app_id.arb | 6 +- lib/l10n/app_it.arb | 6 +- lib/l10n/app_ja.arb | 6 +- lib/l10n/app_pt.arb | 6 +- lib/l10n/app_ru.arb | 6 +- lib/l10n/app_zh.arb | 6 +- lib/l10n/formato_fechas.dart | 13 ++ lib/l10n/gen/app_localizations.dart | 24 +++ lib/l10n/gen/app_localizations_ar.dart | 22 +++ lib/l10n/gen/app_localizations_bn.dart | 20 +++ lib/l10n/gen/app_localizations_de.dart | 20 +++ lib/l10n/gen/app_localizations_en.dart | 20 +++ lib/l10n/gen/app_localizations_es.dart | 20 +++ lib/l10n/gen/app_localizations_fr.dart | 20 +++ lib/l10n/gen/app_localizations_hi.dart | 20 +++ lib/l10n/gen/app_localizations_id.dart | 19 +++ lib/l10n/gen/app_localizations_it.dart | 20 +++ lib/l10n/gen/app_localizations_ja.dart | 19 +++ lib/l10n/gen/app_localizations_pt.dart | 20 +++ lib/l10n/gen/app_localizations_ru.dart | 21 +++ lib/l10n/gen/app_localizations_zh.dart | 19 +++ lib/main.dart | 19 ++- lib/pantallas/pantalla_ajustes.dart | 36 +++-- lib/pantallas/pantalla_alarmas.dart | 71 +++++--- lib/pantallas/pantalla_buscar.dart | 23 ++- lib/pantallas/pantalla_favoritos.dart | 3 +- lib/pantallas/pantalla_inicio.dart | 13 +- lib/pantallas/pantalla_reproductor.dart | 61 ++++--- lib/tema/pluri_animate.dart | 14 ++ lib/tema/pluriwave_tokens.dart | 24 ++- lib/widgets/pluri_premium_widgets.dart | 52 ++++-- lib/widgets/pluri_wave_scaffold.dart | 26 ++- lib/widgets/tarjeta_emisora.dart | 152 +++++++++++++----- lib/widgets/visualizador_audio.dart | 5 +- .../apply-progress.md | 108 ++++++++++++- .../app-quality-and-native-alarms/tasks.md | 36 ++--- .../pantalla_alarmas_fecha_test.dart | 27 ++++ .../pantalla_buscar_shimmer_test.dart | 41 +++++ .../pantalla_favoritos_plural_test.dart | 20 +++ test/tema/notification_color_test.dart | 16 ++ test/tema/pluri_animate_test.dart | 78 +++++++++ test/widgets/tarjeta_emisora_a11y_test.dart | 75 +++++++++ 49 files changed, 1108 insertions(+), 175 deletions(-) create mode 100644 lib/l10n/formato_fechas.dart create mode 100644 test/pantallas/pantalla_alarmas_fecha_test.dart create mode 100644 test/pantallas/pantalla_buscar_shimmer_test.dart create mode 100644 test/pantallas/pantalla_favoritos_plural_test.dart create mode 100644 test/tema/notification_color_test.dart create mode 100644 test/tema/pluri_animate_test.dart create mode 100644 test/widgets/tarjeta_emisora_a11y_test.dart diff --git a/lib/l10n/app_ar.arb b/lib/l10n/app_ar.arb index 26847d0..29c168b 100644 --- a/lib/l10n/app_ar.arb +++ b/lib/l10n/app_ar.arb @@ -612,5 +612,9 @@ "weekdayShortThursday": "الخميس", "weekdayShortFriday": "الجمعة", "weekdayShortSaturday": "السبت", - "weekdayShortSunday": "الأحد" + "weekdayShortSunday": "الأحد", + "stationCount": "{count, plural, one{محطة واحدة} two{محطتان} few{{count} محطات} other{{count} محطة}}", + "alarmIconLabel": "منبه موسيقي", + "vacationIconLabel": "وضع الإجازة", + "streamUrlHint": "https://stream.example.com:8000/radio" } diff --git a/lib/l10n/app_bn.arb b/lib/l10n/app_bn.arb index 579114a..d43c46f 100644 --- a/lib/l10n/app_bn.arb +++ b/lib/l10n/app_bn.arb @@ -612,5 +612,9 @@ "weekdayShortThursday": "বৃহস্পতি", "weekdayShortFriday": "শুক্র", "weekdayShortSaturday": "শনি", - "weekdayShortSunday": "রবি" + "weekdayShortSunday": "রবি", + "stationCount": "{count, plural, =1{১টি স্টেশন} other{{count}টি স্টেশন}}", + "alarmIconLabel": "মিউজিক অ্যালার্ম", + "vacationIconLabel": "ছুটির মোড", + "streamUrlHint": "https://stream.example.com:8000/radio" } diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index 78da69b..bc477de 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -612,5 +612,9 @@ "weekdayShortThursday": "Do", "weekdayShortFriday": "Fr", "weekdayShortSaturday": "Sa", - "weekdayShortSunday": "So" + "weekdayShortSunday": "So", + "stationCount": "{count, plural, =1{1 Sender} other{{count} Sender}}", + "alarmIconLabel": "Musikwecker", + "vacationIconLabel": "Urlaubsmodus", + "streamUrlHint": "https://stream.example.com:8000/radio" } diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index cd29deb..1dc9c6e 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -612,5 +612,9 @@ "weekdayShortThursday": "Thu", "weekdayShortFriday": "Fri", "weekdayShortSaturday": "Sat", - "weekdayShortSunday": "Sun" + "weekdayShortSunday": "Sun", + "stationCount": "{count, plural, =1{1 station} other{{count} stations}}", + "alarmIconLabel": "Musical alarm", + "vacationIconLabel": "Vacation mode", + "streamUrlHint": "https://stream.example.com:8000/radio" } diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb index c973297..e36eddd 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -568,6 +568,16 @@ "weekdayShortThursday": "Jue", "weekdayShortFriday": "Vie", "weekdayShortSaturday": "Sáb", - "weekdayShortSunday": "Dom" - + "weekdayShortSunday": "Dom", + "stationCount": "{count, plural, =1{1 emisora} other{{count} emisoras}}", + "alarmIconLabel": "Alarma musical", + "vacationIconLabel": "Modo vacaciones", + "streamUrlHint": "https://stream.example.com:8000/radio", + "@stationCount": { + "placeholders": { + "count": { + "type": "int" + } + } + } } diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index 7faf378..d1e0e86 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -612,5 +612,9 @@ "weekdayShortThursday": "Jeu", "weekdayShortFriday": "Ven", "weekdayShortSaturday": "Sam", - "weekdayShortSunday": "Dim" + "weekdayShortSunday": "Dim", + "stationCount": "{count, plural, =1{1 station} other{{count} stations}}", + "alarmIconLabel": "Alarme musicale", + "vacationIconLabel": "Mode vacances", + "streamUrlHint": "https://stream.example.com:8000/radio" } diff --git a/lib/l10n/app_hi.arb b/lib/l10n/app_hi.arb index ba6c81e..7d0ba70 100644 --- a/lib/l10n/app_hi.arb +++ b/lib/l10n/app_hi.arb @@ -612,5 +612,9 @@ "weekdayShortThursday": "गुरु", "weekdayShortFriday": "शुक्र", "weekdayShortSaturday": "शनि", - "weekdayShortSunday": "रवि" + "weekdayShortSunday": "रवि", + "stationCount": "{count, plural, =1{1 स्टेशन} other{{count} स्टेशन}}", + "alarmIconLabel": "संगीत अलार्म", + "vacationIconLabel": "अवकाश मोड", + "streamUrlHint": "https://stream.example.com:8000/radio" } diff --git a/lib/l10n/app_id.arb b/lib/l10n/app_id.arb index 31a6ec5..c12751e 100644 --- a/lib/l10n/app_id.arb +++ b/lib/l10n/app_id.arb @@ -612,5 +612,9 @@ "weekdayShortThursday": "Kam", "weekdayShortFriday": "Jum", "weekdayShortSaturday": "Sab", - "weekdayShortSunday": "Min" + "weekdayShortSunday": "Min", + "stationCount": "{count, plural, other{{count} stasiun}}", + "alarmIconLabel": "Alarm musik", + "vacationIconLabel": "Mode liburan", + "streamUrlHint": "https://stream.example.com:8000/radio" } diff --git a/lib/l10n/app_it.arb b/lib/l10n/app_it.arb index 95873b0..5468d5a 100644 --- a/lib/l10n/app_it.arb +++ b/lib/l10n/app_it.arb @@ -612,5 +612,9 @@ "weekdayShortThursday": "Gio", "weekdayShortFriday": "Ven", "weekdayShortSaturday": "Sab", - "weekdayShortSunday": "Dom" + "weekdayShortSunday": "Dom", + "stationCount": "{count, plural, =1{1 stazione} other{{count} stazioni}}", + "alarmIconLabel": "Sveglia musicale", + "vacationIconLabel": "Modalità vacanza", + "streamUrlHint": "https://stream.example.com:8000/radio" } diff --git a/lib/l10n/app_ja.arb b/lib/l10n/app_ja.arb index 66e5c7a..fe38b6e 100644 --- a/lib/l10n/app_ja.arb +++ b/lib/l10n/app_ja.arb @@ -612,5 +612,9 @@ "weekdayShortThursday": "木", "weekdayShortFriday": "金", "weekdayShortSaturday": "土", - "weekdayShortSunday": "日" + "weekdayShortSunday": "日", + "stationCount": "{count, plural, other{{count}局}}", + "alarmIconLabel": "ミュージックアラーム", + "vacationIconLabel": "休暇モード", + "streamUrlHint": "https://stream.example.com:8000/radio" } diff --git a/lib/l10n/app_pt.arb b/lib/l10n/app_pt.arb index 5f71997..2b332d4 100644 --- a/lib/l10n/app_pt.arb +++ b/lib/l10n/app_pt.arb @@ -612,5 +612,9 @@ "weekdayShortThursday": "Qui", "weekdayShortFriday": "Sex", "weekdayShortSaturday": "Sáb", - "weekdayShortSunday": "Dom" + "weekdayShortSunday": "Dom", + "stationCount": "{count, plural, =1{1 estação} other{{count} estações}}", + "alarmIconLabel": "Alarme musical", + "vacationIconLabel": "Modo férias", + "streamUrlHint": "https://stream.example.com:8000/radio" } diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index 2f10c2c..761e605 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -612,5 +612,9 @@ "weekdayShortThursday": "Чт", "weekdayShortFriday": "Пт", "weekdayShortSaturday": "Сб", - "weekdayShortSunday": "Вс" + "weekdayShortSunday": "Вс", + "stationCount": "{count, plural, one{{count} станция} few{{count} станции} other{{count} станций}}", + "alarmIconLabel": "Музыкальный будильник", + "vacationIconLabel": "Режим отпуска", + "streamUrlHint": "https://stream.example.com:8000/radio" } diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index e10efff..c31e828 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -612,5 +612,9 @@ "weekdayShortThursday": "周四", "weekdayShortFriday": "周五", "weekdayShortSaturday": "周六", - "weekdayShortSunday": "周日" + "weekdayShortSunday": "周日", + "stationCount": "{count, plural, other{{count} 个电台}}", + "alarmIconLabel": "音乐闹钟", + "vacationIconLabel": "假期模式", + "streamUrlHint": "https://stream.example.com:8000/radio" } diff --git a/lib/l10n/formato_fechas.dart b/lib/l10n/formato_fechas.dart new file mode 100644 index 0000000..12bde48 --- /dev/null +++ b/lib/l10n/formato_fechas.dart @@ -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); diff --git a/lib/l10n/gen/app_localizations.dart b/lib/l10n/gen/app_localizations.dart index 0da5fcf..b365631 100644 --- a/lib/l10n/gen/app_localizations.dart +++ b/lib/l10n/gen/app_localizations.dart @@ -2245,6 +2245,30 @@ abstract class AppLocalizations { /// In es, this message translates to: /// **'Dom'** String get weekdayShortSunday; + + /// No description provided for @stationCount. + /// + /// In es, this message translates to: + /// **'{count, plural, =1{1 emisora} other{{count} emisoras}}'** + String stationCount(int count); + + /// No description provided for @alarmIconLabel. + /// + /// In es, this message translates to: + /// **'Alarma musical'** + String get alarmIconLabel; + + /// No description provided for @vacationIconLabel. + /// + /// In es, this message translates to: + /// **'Modo vacaciones'** + String get vacationIconLabel; + + /// No description provided for @streamUrlHint. + /// + /// In es, this message translates to: + /// **'https://stream.example.com:8000/radio'** + String get streamUrlHint; } class _AppLocalizationsDelegate diff --git a/lib/l10n/gen/app_localizations_ar.dart b/lib/l10n/gen/app_localizations_ar.dart index b4fd192..e32c6e3 100644 --- a/lib/l10n/gen/app_localizations_ar.dart +++ b/lib/l10n/gen/app_localizations_ar.dart @@ -1203,4 +1203,26 @@ class AppLocalizationsAr extends AppLocalizations { @override String get weekdayShortSunday => 'الأحد'; + + @override + String stationCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count محطة', + few: '$count محطات', + two: 'محطتان', + one: 'محطة واحدة', + ); + return '$_temp0'; + } + + @override + String get alarmIconLabel => 'منبه موسيقي'; + + @override + String get vacationIconLabel => 'وضع الإجازة'; + + @override + String get streamUrlHint => 'https://stream.example.com:8000/radio'; } diff --git a/lib/l10n/gen/app_localizations_bn.dart b/lib/l10n/gen/app_localizations_bn.dart index a7c54b4..c3a7da8 100644 --- a/lib/l10n/gen/app_localizations_bn.dart +++ b/lib/l10n/gen/app_localizations_bn.dart @@ -1212,4 +1212,24 @@ class AppLocalizationsBn extends AppLocalizations { @override String get weekdayShortSunday => 'রবি'; + + @override + String stationCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$countটি স্টেশন', + one: '১টি স্টেশন', + ); + return '$_temp0'; + } + + @override + String get alarmIconLabel => 'মিউজিক অ্যালার্ম'; + + @override + String get vacationIconLabel => 'ছুটির মোড'; + + @override + String get streamUrlHint => 'https://stream.example.com:8000/radio'; } diff --git a/lib/l10n/gen/app_localizations_de.dart b/lib/l10n/gen/app_localizations_de.dart index 0527a86..f021b01 100644 --- a/lib/l10n/gen/app_localizations_de.dart +++ b/lib/l10n/gen/app_localizations_de.dart @@ -1222,4 +1222,24 @@ class AppLocalizationsDe extends AppLocalizations { @override String get weekdayShortSunday => 'So'; + + @override + String stationCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count Sender', + one: '1 Sender', + ); + return '$_temp0'; + } + + @override + String get alarmIconLabel => 'Musikwecker'; + + @override + String get vacationIconLabel => 'Urlaubsmodus'; + + @override + String get streamUrlHint => 'https://stream.example.com:8000/radio'; } diff --git a/lib/l10n/gen/app_localizations_en.dart b/lib/l10n/gen/app_localizations_en.dart index 48169bf..ad5ec5a 100644 --- a/lib/l10n/gen/app_localizations_en.dart +++ b/lib/l10n/gen/app_localizations_en.dart @@ -1208,4 +1208,24 @@ class AppLocalizationsEn extends AppLocalizations { @override String get weekdayShortSunday => 'Sun'; + + @override + String stationCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count stations', + one: '1 station', + ); + return '$_temp0'; + } + + @override + String get alarmIconLabel => 'Musical alarm'; + + @override + String get vacationIconLabel => 'Vacation mode'; + + @override + String get streamUrlHint => 'https://stream.example.com:8000/radio'; } diff --git a/lib/l10n/gen/app_localizations_es.dart b/lib/l10n/gen/app_localizations_es.dart index f99d9f7..c04c41d 100644 --- a/lib/l10n/gen/app_localizations_es.dart +++ b/lib/l10n/gen/app_localizations_es.dart @@ -1217,4 +1217,24 @@ class AppLocalizationsEs extends AppLocalizations { @override String get weekdayShortSunday => 'Dom'; + + @override + String stationCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count emisoras', + one: '1 emisora', + ); + return '$_temp0'; + } + + @override + String get alarmIconLabel => 'Alarma musical'; + + @override + String get vacationIconLabel => 'Modo vacaciones'; + + @override + String get streamUrlHint => 'https://stream.example.com:8000/radio'; } diff --git a/lib/l10n/gen/app_localizations_fr.dart b/lib/l10n/gen/app_localizations_fr.dart index 2d79417..b2b52a5 100644 --- a/lib/l10n/gen/app_localizations_fr.dart +++ b/lib/l10n/gen/app_localizations_fr.dart @@ -1227,4 +1227,24 @@ class AppLocalizationsFr extends AppLocalizations { @override String get weekdayShortSunday => 'Dim'; + + @override + String stationCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count stations', + one: '1 station', + ); + return '$_temp0'; + } + + @override + String get alarmIconLabel => 'Alarme musicale'; + + @override + String get vacationIconLabel => 'Mode vacances'; + + @override + String get streamUrlHint => 'https://stream.example.com:8000/radio'; } diff --git a/lib/l10n/gen/app_localizations_hi.dart b/lib/l10n/gen/app_localizations_hi.dart index 3aa6d4f..5cd070a 100644 --- a/lib/l10n/gen/app_localizations_hi.dart +++ b/lib/l10n/gen/app_localizations_hi.dart @@ -1211,4 +1211,24 @@ class AppLocalizationsHi extends AppLocalizations { @override String get weekdayShortSunday => 'रवि'; + + @override + String stationCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count स्टेशन', + one: '1 स्टेशन', + ); + return '$_temp0'; + } + + @override + String get alarmIconLabel => 'संगीत अलार्म'; + + @override + String get vacationIconLabel => 'अवकाश मोड'; + + @override + String get streamUrlHint => 'https://stream.example.com:8000/radio'; } diff --git a/lib/l10n/gen/app_localizations_id.dart b/lib/l10n/gen/app_localizations_id.dart index 7feb575..43ab045 100644 --- a/lib/l10n/gen/app_localizations_id.dart +++ b/lib/l10n/gen/app_localizations_id.dart @@ -1217,4 +1217,23 @@ class AppLocalizationsId extends AppLocalizations { @override String get weekdayShortSunday => 'Min'; + + @override + String stationCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count stasiun', + ); + return '$_temp0'; + } + + @override + String get alarmIconLabel => 'Alarm musik'; + + @override + String get vacationIconLabel => 'Mode liburan'; + + @override + String get streamUrlHint => 'https://stream.example.com:8000/radio'; } diff --git a/lib/l10n/gen/app_localizations_it.dart b/lib/l10n/gen/app_localizations_it.dart index 4a039a9..5b779db 100644 --- a/lib/l10n/gen/app_localizations_it.dart +++ b/lib/l10n/gen/app_localizations_it.dart @@ -1222,4 +1222,24 @@ class AppLocalizationsIt extends AppLocalizations { @override String get weekdayShortSunday => 'Dom'; + + @override + String stationCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count stazioni', + one: '1 stazione', + ); + return '$_temp0'; + } + + @override + String get alarmIconLabel => 'Sveglia musicale'; + + @override + String get vacationIconLabel => 'Modalità vacanza'; + + @override + String get streamUrlHint => 'https://stream.example.com:8000/radio'; } diff --git a/lib/l10n/gen/app_localizations_ja.dart b/lib/l10n/gen/app_localizations_ja.dart index 123e4a2..49b3427 100644 --- a/lib/l10n/gen/app_localizations_ja.dart +++ b/lib/l10n/gen/app_localizations_ja.dart @@ -1173,4 +1173,23 @@ class AppLocalizationsJa extends AppLocalizations { @override String get weekdayShortSunday => '日'; + + @override + String stationCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count局', + ); + return '$_temp0'; + } + + @override + String get alarmIconLabel => 'ミュージックアラーム'; + + @override + String get vacationIconLabel => '休暇モード'; + + @override + String get streamUrlHint => 'https://stream.example.com:8000/radio'; } diff --git a/lib/l10n/gen/app_localizations_pt.dart b/lib/l10n/gen/app_localizations_pt.dart index d40be85..38cc2c2 100644 --- a/lib/l10n/gen/app_localizations_pt.dart +++ b/lib/l10n/gen/app_localizations_pt.dart @@ -1214,4 +1214,24 @@ class AppLocalizationsPt extends AppLocalizations { @override String get weekdayShortSunday => 'Dom'; + + @override + String stationCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count estações', + one: '1 estação', + ); + return '$_temp0'; + } + + @override + String get alarmIconLabel => 'Alarme musical'; + + @override + String get vacationIconLabel => 'Modo férias'; + + @override + String get streamUrlHint => 'https://stream.example.com:8000/radio'; } diff --git a/lib/l10n/gen/app_localizations_ru.dart b/lib/l10n/gen/app_localizations_ru.dart index 8d96d78..57870fc 100644 --- a/lib/l10n/gen/app_localizations_ru.dart +++ b/lib/l10n/gen/app_localizations_ru.dart @@ -1217,4 +1217,25 @@ class AppLocalizationsRu extends AppLocalizations { @override String get weekdayShortSunday => 'Вс'; + + @override + String stationCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count станций', + few: '$count станции', + one: '$count станция', + ); + return '$_temp0'; + } + + @override + String get alarmIconLabel => 'Музыкальный будильник'; + + @override + String get vacationIconLabel => 'Режим отпуска'; + + @override + String get streamUrlHint => 'https://stream.example.com:8000/radio'; } diff --git a/lib/l10n/gen/app_localizations_zh.dart b/lib/l10n/gen/app_localizations_zh.dart index c33bf41..77abae7 100644 --- a/lib/l10n/gen/app_localizations_zh.dart +++ b/lib/l10n/gen/app_localizations_zh.dart @@ -1168,4 +1168,23 @@ class AppLocalizationsZh extends AppLocalizations { @override String get weekdayShortSunday => '周日'; + + @override + String stationCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count 个电台', + ); + return '$_temp0'; + } + + @override + String get alarmIconLabel => '音乐闹钟'; + + @override + String get vacationIconLabel => '假期模式'; + + @override + String get streamUrlHint => 'https://stream.example.com:8000/radio'; } diff --git a/lib/main.dart b/lib/main.dart index 537b64c..3a5f1b4 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -8,9 +8,20 @@ import 'package:shared_preferences/shared_preferences.dart'; import 'app.dart'; import 'servicios/servicio_audio.dart'; import 'servicios/servicio_audio_session.dart'; +import 'tema/pluriwave_tokens.dart'; const _anchoMinimoLandscape = 600.0; +/// S5-R8: media notification accent uses the brand color, not the M3 +/// default purple. Top-level const so tests can assert it. +const configuracionAudioService = AudioServiceConfig( + androidNotificationChannelId: 'es.freetimelab.pluriwave.audio', + androidNotificationChannelName: 'PluriWave Radio', + androidNotificationOngoing: true, + androidStopForegroundOnPause: true, + notificationColor: PluriWaveTokens.brand, +); + Future main() async { WidgetsFlutterBinding.ensureInitialized(); await _aplicarPoliticaOrientacion(); @@ -21,13 +32,7 @@ Future main() async { final handler = await AudioService.init( builder: () => PluriWaveAudioHandler(), - config: const AudioServiceConfig( - androidNotificationChannelId: 'es.freetimelab.pluriwave.audio', - androidNotificationChannelName: 'PluriWave Radio', - androidNotificationOngoing: true, - androidStopForegroundOnPause: true, - notificationColor: Color(0xFF6750A4), - ), + config: configuracionAudioService, ); registrarHandler(handler); diff --git a/lib/pantallas/pantalla_ajustes.dart b/lib/pantallas/pantalla_ajustes.dart index 1dcb161..8e51a05 100644 --- a/lib/pantallas/pantalla_ajustes.dart +++ b/lib/pantallas/pantalla_ajustes.dart @@ -210,7 +210,7 @@ class _SeccionGrabaciones extends StatelessWidget { children: [ Row( children: [ - const Icon(Icons.radio_button_checked), + const Icon(Icons.radio_button_checked_rounded), const SizedBox(width: 12), Text( l10n.recordingsSectionTitle, @@ -591,7 +591,7 @@ class _SeccionEcualizador extends StatelessWidget { children: [ Row( children: [ - const Icon(Icons.equalizer), + const Icon(Icons.equalizer_rounded), const SizedBox(width: 12), Text( l10n.equalizerTitle, @@ -994,7 +994,7 @@ class _SeccionEmisoras extends StatelessWidget { children: [ Row( children: [ - const Icon(Icons.add_circle_outline), + const Icon(Icons.add_circle_outline_rounded), const SizedBox(width: 12), Text( AppLocalizations.of(context).customStationsTitle, @@ -1002,7 +1002,7 @@ class _SeccionEmisoras extends StatelessWidget { ), const Spacer(), TextButton.icon( - icon: const Icon(Icons.add), + icon: const Icon(Icons.add_rounded), label: Text(AppLocalizations.of(context).customStationsAdd), onPressed: () => _mostrarFormularioAnadir(context), ), @@ -1013,14 +1013,18 @@ class _SeccionEmisoras extends StatelessWidget { padding: const EdgeInsets.only(top: 8), child: Text( AppLocalizations.of(context).customStationsEmpty, - style: const TextStyle(color: Colors.grey), + style: TextStyle( + color: Theme.of( + context, + ).colorScheme.onSurface.withValues(alpha: 0.6), + ), ), ) else for (final emisora in custom) ListTile( contentPadding: EdgeInsets.zero, - leading: const Icon(Icons.radio), + leading: const Icon(Icons.radio_rounded), title: Text( localizedStationName( AppLocalizations.of(context), @@ -1036,13 +1040,13 @@ class _SeccionEmisoras extends StatelessWidget { mainAxisSize: MainAxisSize.min, children: [ IconButton( - icon: const Icon(Icons.play_arrow), + icon: const Icon(Icons.play_arrow_rounded), tooltip: AppLocalizations.of(context).playAction, onPressed: () => context.read().reproducir(emisora), ), IconButton( - icon: const Icon(Icons.delete_outline), + icon: const Icon(Icons.delete_outline_rounded), tooltip: AppLocalizations.of(context).deleteAction, onPressed: () => context @@ -1061,6 +1065,8 @@ class _SeccionEmisoras extends StatelessWidget { await showModalBottomSheet( context: context, isScrollControlled: true, + useSafeArea: true, + showDragHandle: true, builder: (ctx) => const _FormularioEmisora(), ); } @@ -1142,7 +1148,7 @@ class _FormularioEmisoraState extends State<_FormularioEmisora> { controller: _urlCtrl, decoration: InputDecoration( labelText: AppLocalizations.of(context).streamUrlLabel, - hintText: 'http://stream.ejemplo.com:8000/radio', + hintText: AppLocalizations.of(context).streamUrlHint, border: const OutlineInputBorder(), ), keyboardType: TextInputType.url, @@ -1340,7 +1346,7 @@ class _SeccionInfo extends StatelessWidget { builder: (ctx, snap) => ListTile( contentPadding: EdgeInsets.zero, - leading: const Icon(Icons.favorite_outline), + leading: const Icon(Icons.favorite_outline_rounded), title: Text( AppLocalizations.of(ctx).savedFavoritesTitle, ), @@ -1366,7 +1372,10 @@ class _SeccionInfo extends StatelessWidget { subtitle: Text( AppLocalizations.of(ctx).stationFilterSubtitle, ), - trailing: const Icon(Icons.check_circle, color: Colors.green), + trailing: Icon( + Icons.check_circle_rounded, + color: Theme.of(ctx).colorScheme.secondary, + ), ), ListTile( contentPadding: EdgeInsets.zero, @@ -1375,7 +1384,10 @@ class _SeccionInfo extends StatelessWidget { subtitle: Text( AppLocalizations.of(ctx).backgroundAudioSubtitle, ), - trailing: const Icon(Icons.check_circle, color: Colors.green), + trailing: Icon( + Icons.check_circle_rounded, + color: Theme.of(ctx).colorScheme.secondary, + ), ), ], ), diff --git a/lib/pantallas/pantalla_alarmas.dart b/lib/pantallas/pantalla_alarmas.dart index 2361621..dc537ce 100644 --- a/lib/pantallas/pantalla_alarmas.dart +++ b/lib/pantallas/pantalla_alarmas.dart @@ -4,11 +4,14 @@ import 'package:provider/provider.dart'; import '../estado/estado_alarmas.dart'; import '../estado/estado_radio.dart'; import '../l10n/display_names.dart'; +import '../l10n/formato_fechas.dart'; import '../l10n/app_localizations_ext.dart'; import '../l10n/gen/app_localizations.dart'; import '../modelos/alarma_musical.dart'; import '../modelos/emisora.dart'; import '../servicios/servicio_programacion_alarmas.dart'; +import '../tema/pluriwave_theme.dart'; +import '../tema/pluriwave_tokens.dart'; import '../widgets/pluri_glass_surface.dart'; import '../widgets/pluri_icon.dart'; import '../widgets/pluri_layout.dart'; @@ -92,10 +95,14 @@ class _PanelProximaAlarma extends StatelessWidget { final proximaProgramable = proxima?.proximaProgramable; return PluriGlassSurface( - glowColor: const Color(0xFFFFB86B).withValues(alpha: 0.28), + glowColor: context.pluriTokens.warmCoral.withValues(alpha: 0.28), child: Row( children: [ - const _AssetIcon('assets/icons/alarmas/alarm_music.png', size: 72), + _AssetIcon( + 'assets/icons/alarmas/alarm_music.png', + size: 72, + semanticLabel: l10n.alarmIconLabel, + ), const SizedBox(width: 14), Expanded( child: Column( @@ -142,15 +149,16 @@ class _TarjetaAlarma extends StatelessWidget { final excepcion = estado.ultimaExcepcionPara(alarma.id); final mensajeVacaciones = _mensajeVacaciones(l10n, estado.vacaciones); return PluriGlassSurface( - glowColor: const Color(0xFF22D3EE).withValues(alpha: 0.22), + glowColor: context.pluriTokens.electricMagenta.withValues(alpha: 0.22), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ - const _AssetIcon( + _AssetIcon( 'assets/icons/alarmas/alarm_music.png', size: 64, + semanticLabel: l10n.alarmIconLabel, ), const SizedBox(width: 12), Expanded( @@ -435,9 +443,10 @@ class _EditorAlarmaSheetState extends State<_EditorAlarmaSheet> { children: [ Row( children: [ - const _AssetIcon( + _AssetIcon( 'assets/icons/alarmas/alarm_music.png', size: 58, + semanticLabel: l10n.alarmIconLabel, ), const SizedBox(width: 12), Expanded( @@ -477,7 +486,7 @@ class _EditorAlarmaSheetState extends State<_EditorAlarmaSheet> { child: _PickerButton( icon: Icons.event_rounded, label: l10n.dateField, - value: _fechaCorta(_fecha), + value: _fechaCorta(l10n, _fecha), onTap: _tipo == TipoProgramacionAlarma.unica ? _elegirFecha @@ -663,9 +672,10 @@ class _EditorAlarmaSheetState extends State<_EditorAlarmaSheet> { value: _sonarEnVacaciones, onChanged: (value) => setState(() => _sonarEnVacaciones = value), - secondary: const _AssetIcon( + secondary: _AssetIcon( 'assets/icons/alarmas/vacation_wave.png', size: 42, + semanticLabel: l10n.vacationIconLabel, ), title: Text(l10n.playDuringVacations), subtitle: Text(l10n.playDuringVacationsHint), @@ -1010,15 +1020,16 @@ class _PanelVacaciones extends StatelessWidget { final vacaciones = [...estado.vacaciones] ..sort((a, b) => a.inicioDia.compareTo(b.inicioDia)); return PluriGlassSurface( - glowColor: const Color(0xFF60A5FA).withValues(alpha: 0.22), + glowColor: PluriWaveTokens.skyBlue.withValues(alpha: 0.22), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ - const _AssetIcon( + _AssetIcon( 'assets/icons/alarmas/vacation_wave.png', size: 48, + semanticLabel: l10n.vacationIconLabel, ), const SizedBox(width: 10), Expanded( @@ -1047,7 +1058,7 @@ class _PanelVacaciones extends StatelessWidget { leading: const Icon(Icons.event_busy_rounded), title: Text(_nombreVisibleVacaciones(l10n, rango)), subtitle: Text( - '${_fechaCorta(rango.inicioDia)} → ${_fechaCorta(rango.finDia)}', + '${_fechaCorta(l10n, rango.inicioDia)} → ${_fechaCorta(l10n, rango.finDia)}', ), trailing: IconButton( tooltip: l10n.deleteRangeTooltip, @@ -1079,7 +1090,9 @@ class _EditorVacacionesSheet extends StatefulWidget { } class _EditorVacacionesSheetState extends State<_EditorVacacionesSheet> { - late final TextEditingController _nombreController; + // Created lazily: AppLocalizations.of(context) cannot be read in + // initState (inherited-widget lookup assert in debug builds). + TextEditingController? _nombreController; late DateTime _inicio; late DateTime _fin; @@ -1089,14 +1102,19 @@ class _EditorVacacionesSheetState extends State<_EditorVacacionesSheet> { final hoy = DateTime.now(); _inicio = DateTime(hoy.year, hoy.month, hoy.day); _fin = _inicio.add(const Duration(days: 2)); - _nombreController = TextEditingController( + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + _nombreController ??= TextEditingController( text: AppLocalizations.of(context).vacationsDefaultName, ); } @override void dispose() { - _nombreController.dispose(); + _nombreController?.dispose(); super.dispose(); } @@ -1131,7 +1149,7 @@ class _EditorVacacionesSheetState extends State<_EditorVacacionesSheet> { child: _PickerButton( icon: Icons.play_arrow_rounded, label: l10n.startLabel, - value: _fechaCorta(_inicio), + value: _fechaCorta(l10n, _inicio), onTap: () => _elegirFecha(esInicio: true), ), ), @@ -1140,7 +1158,7 @@ class _EditorVacacionesSheetState extends State<_EditorVacacionesSheet> { child: _PickerButton( icon: Icons.stop_rounded, label: l10n.endLabel, - value: _fechaCorta(_fin), + value: _fechaCorta(l10n, _fin), onTap: () => _elegirFecha(esInicio: false), ), ), @@ -1183,7 +1201,7 @@ class _EditorVacacionesSheetState extends State<_EditorVacacionesSheet> { final rango = estado.servicio.crearRangoVacaciones( inicio: _inicio, fin: _fin, - nombre: _nombreController.text.trim(), + nombre: _nombreController?.text.trim() ?? '', ); await estado.crearRangoVacaciones(rango); if (mounted) Navigator.pop(context); @@ -1191,11 +1209,15 @@ class _EditorVacacionesSheetState extends State<_EditorVacacionesSheet> { } class _AssetIcon extends StatelessWidget { - const _AssetIcon(this.asset, {this.size = 44}); + const _AssetIcon(this.asset, {this.size = 44, this.semanticLabel}); final String asset; final double size; + /// S5-R2: meaningful images carry a label; without one the image is + /// treated as decorative and excluded from the semantics tree. + final String? semanticLabel; + @override Widget build(BuildContext context) { return Image.asset( @@ -1203,6 +1225,8 @@ class _AssetIcon extends StatelessWidget { width: size, height: size, fit: BoxFit.contain, + semanticLabel: semanticLabel, + excludeFromSemantics: semanticLabel == null, errorBuilder: (_, __, ___) => Icon(Icons.music_note_rounded, size: size * 0.65), ); @@ -1301,7 +1325,11 @@ class _EmptyAlarmas extends StatelessWidget { return PluriGlassSurface( child: Column( children: [ - const _AssetIcon('assets/icons/alarmas/alarm_music.png', size: 92), + _AssetIcon( + 'assets/icons/alarmas/alarm_music.png', + size: 92, + semanticLabel: l10n.alarmIconLabel, + ), const SizedBox(height: 12), Text(l10n.noAlarmsYetTitle), const SizedBox(height: 4), @@ -1326,7 +1354,7 @@ String _hora(AlarmaMusical alarma) => String _programacion(AppLocalizations l10n, AlarmaMusical alarma) { return switch (alarma.tipoProgramacion) { TipoProgramacionAlarma.unica => l10n.alarmScheduleOnce( - _fechaCorta(alarma.fechaUnica ?? DateTime.now()), + _fechaCorta(l10n, alarma.fechaUnica ?? DateTime.now()), ), TipoProgramacionAlarma.diaria => l10n.dailyOption, TipoProgramacionAlarma.diasSemana => l10n.alarmScheduleWeekdays( @@ -1349,5 +1377,6 @@ String _weekdayShort(AppLocalizations l10n, int day) => switch (day) { _ => '?', }; -String _fechaCorta(DateTime fecha) => - '${fecha.day.toString().padLeft(2, '0')}/${fecha.month.toString().padLeft(2, '0')}/${fecha.year}'; +// S5-R4: short dates follow the active locale (en-US = M/D/Y, ja = Y/M/D). +String _fechaCorta(AppLocalizations l10n, DateTime fecha) => + fechaCortaLocalizada(l10n.localeName, fecha); diff --git a/lib/pantallas/pantalla_buscar.dart b/lib/pantallas/pantalla_buscar.dart index b7cf9f8..320af47 100644 --- a/lib/pantallas/pantalla_buscar.dart +++ b/lib/pantallas/pantalla_buscar.dart @@ -1,8 +1,8 @@ import 'package:flutter/material.dart'; -import 'package:flutter_animate/flutter_animate.dart'; import 'package:provider/provider.dart'; import '../estado/estado_busqueda.dart'; +import '../tema/pluri_animate.dart'; import '../l10n/gen/app_localizations.dart'; import '../widgets/pluri_glass_surface.dart'; import '../widgets/pluri_icon.dart'; @@ -260,9 +260,18 @@ class _PantallaBuscarState extends State { Widget _resultados(EstadoBusqueda estado, ThemeData theme) { final l10n = AppLocalizations.of(context); if (estado.cargando) { - return const SizedBox( - height: 220, - child: Center(child: CircularProgressIndicator()), + // S5-R6: shimmer placeholders instead of a bare spinner, consistent + // with the loading pattern used by the home grid. + return Padding( + padding: const EdgeInsets.all(PluriLayout.horizontal), + child: Column( + children: [ + for (var i = 0; i < 4; i++) ...[ + const TarjetaEmisoraShimmer(esCompacta: true), + if (i < 3) const SizedBox(height: 10), + ], + ], + ), ); } @@ -310,7 +319,11 @@ class _PantallaBuscarState extends State { emisora: resultados[i], esCompacta: true, onTap: () => reproducirMinimizado(context, resultados[i]), - ).animate().fadeIn(delay: (i.clamp(0, 12) * 20).ms).slideY(begin: 0.08); + ).pluriFadeSlideIn( + context, + delay: Duration(milliseconds: i.clamp(0, 12) * 20), + beginY: 0.08, + ); }, ); } diff --git a/lib/pantallas/pantalla_favoritos.dart b/lib/pantallas/pantalla_favoritos.dart index d9765b8..d2e12a9 100644 --- a/lib/pantallas/pantalla_favoritos.dart +++ b/lib/pantallas/pantalla_favoritos.dart @@ -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), diff --git a/lib/pantallas/pantalla_inicio.dart b/lib/pantallas/pantalla_inicio.dart index 7329d67..10ee40b 100644 --- a/lib/pantallas/pantalla_inicio.dart +++ b/lib/pantallas/pantalla_inicio.dart @@ -1,5 +1,4 @@ import 'package:flutter/material.dart'; -import 'package:flutter_animate/flutter_animate.dart'; import 'package:provider/provider.dart'; import 'package:shimmer/shimmer.dart' as shimmer; @@ -7,6 +6,7 @@ import '../estado/estado_busqueda.dart'; import '../estado/estado_radio.dart'; import '../l10n/gen/app_localizations.dart'; import '../modelos/emisora.dart'; +import '../tema/pluri_animate.dart'; import '../widgets/pluri_glass_surface.dart'; import '../widgets/pluri_icon.dart'; import '../widgets/pluri_layout.dart'; @@ -230,7 +230,10 @@ class _PantallaInicioState extends State { ), label: Text(e.nombre, maxLines: 1), onPressed: () => reproducirMinimizado(context, e), - ).animate().fadeIn(delay: (i * 50).ms); + ).pluriFadeIn( + context, + delay: Duration(milliseconds: i * 50), + ); }, ), ), @@ -354,7 +357,11 @@ class _PantallaInicioState extends State { (context, i) => TarjetaEmisora( emisora: emisoras[i], onTap: () => reproducirMinimizado(context, emisoras[i]), - ).animate().fadeIn(delay: (i * 30).ms).slideY(begin: 0.1), + ).pluriFadeSlideIn( + context, + delay: Duration(milliseconds: i * 30), + beginY: 0.1, + ), childCount: emisoras.length, ), gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( diff --git a/lib/pantallas/pantalla_reproductor.dart b/lib/pantallas/pantalla_reproductor.dart index d9aaf54..cc010ca 100644 --- a/lib/pantallas/pantalla_reproductor.dart +++ b/lib/pantallas/pantalla_reproductor.dart @@ -1,6 +1,5 @@ import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; -import 'package:flutter_animate/flutter_animate.dart'; import 'package:provider/provider.dart'; import 'package:shimmer/shimmer.dart'; @@ -11,6 +10,7 @@ import '../l10n/gen/app_localizations.dart'; import '../modelos/emisora.dart'; import '../servicios/servicio_audio.dart'; import '../servicios/servicio_timer.dart'; +import '../tema/pluri_animate.dart'; import '../tema/pluriwave_theme.dart'; import '../widgets/pluri_glass_surface.dart'; import '../widgets/pluri_wave_scaffold.dart'; @@ -129,9 +129,10 @@ class _PantallaReproductorState extends State _WaveHero( emisora: emisoraActiva, estadoStream: estado.estadoStream, - ).animate().scale( - begin: const Offset(0.86, 0.86), - duration: 420.ms, + ).pluriScaleIn( + context, + begin: 0.86, + duration: const Duration(milliseconds: 420), curve: Curves.easeOutBack, ), const SizedBox(height: 18), @@ -143,19 +144,24 @@ class _PantallaReproductorState extends State textAlign: TextAlign.center, maxLines: 2, overflow: TextOverflow.ellipsis, - ).animate().fadeIn(delay: 150.ms), + ).pluriFadeIn(context, delay: const Duration(milliseconds: 150)), const SizedBox(height: 10), - _InfoChips( - emisora: emisoraActiva, - ).animate().fadeIn(delay: 200.ms).slideY(begin: 0.2), + _InfoChips(emisora: emisoraActiva).pluriFadeSlideIn( + context, + delay: const Duration(milliseconds: 200), + beginY: 0.2, + ), const SizedBox(height: 6), if (emisoraActiva.codec != null || emisoraActiva.bitrate != null) Text( _codecInfo(context, emisoraActiva), style: theme.textTheme.bodySmall?.copyWith( - color: Colors.white.withValues(alpha: 0.72), + color: theme.colorScheme.onSurface.withValues(alpha: 0.72), ), - ).animate().fadeIn(delay: 250.ms), + ).pluriFadeIn( + context, + delay: const Duration(milliseconds: 250), + ), const SizedBox(height: 14), PluriGlassSurface( borderRadius: BorderRadius.circular(tokens.radiusLg), @@ -171,16 +177,25 @@ class _PantallaReproductorState extends State color: tokens.warmCoral, altura: 46, ), - ).animate().fadeIn(delay: 280.ms), + ).pluriFadeIn(context, delay: const Duration(milliseconds: 280)), const Spacer(), _Controles( estado: estado, emisora: emisoraActiva, - ).animate().fadeIn(delay: 300.ms).slideY(begin: 0.3), + ).pluriFadeSlideIn( + context, + delay: const Duration(milliseconds: 300), + beginY: 0.3, + ), const SizedBox(height: 14), - const _GrabacionWidget().animate().fadeIn(delay: 360.ms), + const _GrabacionWidget().pluriFadeIn( + context, + delay: const Duration(milliseconds: 360), + ), const SizedBox(height: 14), - _TimerWidget(estado: estado).animate().fadeIn(delay: 400.ms), + _TimerWidget( + estado: estado, + ).pluriFadeIn(context, delay: const Duration(milliseconds: 400)), const SizedBox(height: 16), ], ), @@ -247,9 +262,7 @@ class _WaveHero extends StatelessWidget { height: size + 12, decoration: BoxDecoration( shape: BoxShape.circle, - border: Border.all( - color: Colors.white.withValues(alpha: 0.16), - ), + border: Border.all(color: t.glassBorder), ), ), PluriGlassSurface( @@ -275,9 +288,9 @@ class _WaveHero extends StatelessWidget { if (cargando) Container( color: Colors.black45, - child: const Center( + child: Center( child: CircularProgressIndicator( - color: Colors.white, + color: theme.colorScheme.onSurface, ), ), ), @@ -288,7 +301,9 @@ class _WaveHero extends StatelessWidget { child: Icon( Icons.wifi_off_rounded, size: 56, - color: Colors.white.withValues(alpha: 0.85), + color: theme.colorScheme.onSurface.withValues( + alpha: 0.85, + ), ), ), ), @@ -706,7 +721,7 @@ class _Controles extends StatelessWidget { minWidth: 56, minHeight: 56, ), - color: Colors.white.withValues(alpha: 0.78), + color: theme.colorScheme.onSurface.withValues(alpha: 0.78), tooltip: l10n.stopAction, onPressed: cargando ? null : estado.detenerReproduccion, ), @@ -753,12 +768,12 @@ class _Controles extends StatelessWidget { child: Center( child: cargando - ? const SizedBox( + ? SizedBox( width: 28, height: 28, child: CircularProgressIndicator( strokeWidth: 2.5, - color: Colors.white, + color: theme.colorScheme.onPrimary, ), ) : Icon( diff --git a/lib/tema/pluri_animate.dart b/lib/tema/pluri_animate.dart index c7eebde..759c4b0 100644 --- a/lib/tema/pluri_animate.dart +++ b/lib/tema/pluri_animate.dart @@ -33,6 +33,20 @@ extension PluriAnimate on Widget { .scaleXY(begin: begin, end: 1, duration: duration, curve: curve); } + /// Fade + subtle vertical slide entry animation. + Widget pluriFadeSlideIn( + BuildContext context, { + Duration duration = const Duration(milliseconds: 350), + Duration delay = Duration.zero, + Curve curve = Curves.easeOutCubic, + double beginY = 0.1, + }) { + if (_animacionesDeshabilitadas(context)) return this; + return animate(delay: delay) + .fadeIn(duration: duration, curve: curve) + .slideY(begin: beginY, end: 0, duration: duration, curve: curve); + } + bool _animacionesDeshabilitadas(BuildContext context) => MediaQuery.maybeDisableAnimationsOf(context) ?? false; } diff --git a/lib/tema/pluriwave_tokens.dart b/lib/tema/pluriwave_tokens.dart index 4fed783..4b0210b 100644 --- a/lib/tema/pluriwave_tokens.dart +++ b/lib/tema/pluriwave_tokens.dart @@ -36,9 +36,19 @@ class PluriWaveTokens extends ThemeExtension { final double spacingMd; final double spacingLg; + /// Brand accent (S5-R8). Same hue as [electricMagenta]; exposed as a + /// static const so const contexts (e.g. AudioServiceConfig) can use it. + static const Color brand = Color(0xFF21D4D9); + + /// Secondary palette used by gradients and decorative orbs (S5-R1). + /// These are token DEFINITIONS — the only place raw literals may live. + static const Color brightCyan = Color(0xFF20E6FF); + static const Color auroraTeal = Color(0xFF0E4A4F); + static const Color skyBlue = Color(0xFF60A5FA); + static const dark = PluriWaveTokens( deepViolet: Color(0xFF07121A), - electricMagenta: Color(0xFF21D4D9), + electricMagenta: brand, warmCoral: Color(0xFFF4B860), glassSurface: Color(0x1FFFFFFF), glassBorder: Color(0x33FFFFFF), @@ -86,13 +96,19 @@ class PluriWaveTokens extends ThemeExtension { } @override - PluriWaveTokens lerp(covariant ThemeExtension? other, double t) { + PluriWaveTokens lerp( + covariant ThemeExtension? other, + double t, + ) { if (other is! PluriWaveTokens) return this; return PluriWaveTokens( deepViolet: Color.lerp(deepViolet, other.deepViolet, t) ?? deepViolet, - electricMagenta: Color.lerp(electricMagenta, other.electricMagenta, t) ?? electricMagenta, + electricMagenta: + Color.lerp(electricMagenta, other.electricMagenta, t) ?? + electricMagenta, warmCoral: Color.lerp(warmCoral, other.warmCoral, t) ?? warmCoral, - glassSurface: Color.lerp(glassSurface, other.glassSurface, t) ?? glassSurface, + glassSurface: + Color.lerp(glassSurface, other.glassSurface, t) ?? glassSurface, glassBorder: Color.lerp(glassBorder, other.glassBorder, t) ?? glassBorder, glowColor: Color.lerp(glowColor, other.glowColor, t) ?? glowColor, radiusSm: lerpDouble(radiusSm, other.radiusSm, t) ?? radiusSm, diff --git a/lib/widgets/pluri_premium_widgets.dart b/lib/widgets/pluri_premium_widgets.dart index d5f6f77..bc00507 100644 --- a/lib/widgets/pluri_premium_widgets.dart +++ b/lib/widgets/pluri_premium_widgets.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import '../tema/pluriwave_theme.dart'; +import '../tema/pluriwave_tokens.dart'; import 'pluri_glass_surface.dart'; import 'pluri_icon.dart'; @@ -38,12 +39,14 @@ class PluriScreenHeader extends StatelessWidget { shape: BoxShape.circle, gradient: LinearGradient( colors: [ - const Color(0xFF20E6FF).withValues(alpha: 0.95), + PluriWaveTokens.brightCyan.withValues(alpha: 0.95), t.electricMagenta, t.warmCoral, ], ), - boxShadow: [BoxShadow(color: t.glowColor, blurRadius: 28, spreadRadius: 2)], + boxShadow: [ + BoxShadow(color: t.glowColor, blurRadius: 28, spreadRadius: 2), + ], ), child: Center( child: PluriIcon( @@ -117,14 +120,22 @@ class PluriScreenHeader extends StatelessWidget { Expanded(child: textBlock()), if (trailing != null) ...[ const SizedBox(width: 12), - ConstrainedBox(constraints: const BoxConstraints(maxWidth: 220), child: trailing!), + ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 220), + child: trailing!, + ), ], ], ); } return Padding( - padding: EdgeInsets.fromLTRB(t.spacingMd, t.spacingSm, t.spacingMd, t.spacingSm), + padding: EdgeInsets.fromLTRB( + t.spacingMd, + t.spacingSm, + t.spacingMd, + t.spacingSm, + ), child: PluriGlassSurface( borderRadius: BorderRadius.circular(t.radiusLg + 8), padding: EdgeInsets.symmetric( @@ -164,7 +175,10 @@ class PluriScreenHeader extends StatelessWidget { Positioned( right: -36, top: -42, - child: _Orb(color: t.electricMagenta.withValues(alpha: 0.38), size: 128), + child: _Orb( + color: t.electricMagenta.withValues(alpha: 0.38), + size: 128, + ), ), Positioned( right: 10, @@ -182,7 +196,10 @@ class PluriScreenHeader extends StatelessWidget { Positioned( right: 44, bottom: -54, - child: _Orb(color: const Color(0xFF20E6FF).withValues(alpha: 0.22), size: 116), + child: _Orb( + color: PluriWaveTokens.brightCyan.withValues(alpha: 0.22), + size: 116, + ), ), Padding( padding: EdgeInsets.all(compact ? 2 : 4), @@ -195,7 +212,6 @@ class PluriScreenHeader extends StatelessWidget { } } - class PluriStatusPill extends StatelessWidget { const PluriStatusPill({ super.key, @@ -232,7 +248,9 @@ class PluriStatusPill extends StatelessWidget { label, maxLines: 1, overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.labelMedium?.copyWith(fontWeight: FontWeight.w800), + style: Theme.of( + context, + ).textTheme.labelMedium?.copyWith(fontWeight: FontWeight.w800), ), ), ], @@ -267,14 +285,26 @@ class PluriEmptyState extends StatelessWidget { child: Column( mainAxisSize: MainAxisSize.min, children: [ - PluriIcon(glyph: glyph, variant: PluriIconVariant.activeGlow, size: 58), + PluriIcon( + glyph: glyph, + variant: PluriIconVariant.activeGlow, + size: 58, + ), const SizedBox(height: 18), - Text(title, textAlign: TextAlign.center, style: theme.textTheme.titleLarge?.copyWith(fontWeight: FontWeight.w900)), + Text( + title, + textAlign: TextAlign.center, + style: theme.textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.w900, + ), + ), const SizedBox(height: 8), Text( subtitle, textAlign: TextAlign.center, - style: theme.textTheme.bodyMedium?.copyWith(color: theme.colorScheme.onSurface.withValues(alpha: 0.72)), + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.onSurface.withValues(alpha: 0.72), + ), ), ], ), diff --git a/lib/widgets/pluri_wave_scaffold.dart b/lib/widgets/pluri_wave_scaffold.dart index c2d8fd5..90b576a 100644 --- a/lib/widgets/pluri_wave_scaffold.dart +++ b/lib/widgets/pluri_wave_scaffold.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import '../tema/pluriwave_theme.dart'; +import '../tema/pluriwave_tokens.dart'; class PluriWaveScaffold extends StatelessWidget { const PluriWaveScaffold({ @@ -31,10 +32,10 @@ class PluriWaveScaffold extends StatelessWidget { begin: Alignment.topLeft, end: Alignment.bottomRight, colors: [ - const Color(0xFF07121A), - const Color(0xFF0D1B24), - const Color(0xFF0E4A4F), - const Color(0xFF07121A), + t.deepViolet, + Theme.of(context).colorScheme.surface, + PluriWaveTokens.auroraTeal, + t.deepViolet, ], stops: const [0, 0.34, 0.68, 1], ), @@ -45,17 +46,28 @@ class PluriWaveScaffold extends StatelessWidget { Positioned( left: -120, top: -120, - child: _AuroraOrb(size: 300, color: const Color(0xFF21D4D9).withValues(alpha: 0.18)), + child: _AuroraOrb( + size: 300, + color: t.electricMagenta.withValues(alpha: 0.18), + ), ), Positioned( right: -150, top: 160, - child: _AuroraOrb(size: 340, color: const Color(0xFF7EE4C2).withValues(alpha: 0.12)), + child: _AuroraOrb( + size: 340, + color: Theme.of( + context, + ).colorScheme.secondary.withValues(alpha: 0.12), + ), ), Positioned( left: -90, bottom: 80, - child: _AuroraOrb(size: 260, color: t.warmCoral.withValues(alpha: 0.10)), + child: _AuroraOrb( + size: 260, + color: t.warmCoral.withValues(alpha: 0.10), + ), ), Positioned.fill( child: IgnorePointer( diff --git a/lib/widgets/tarjeta_emisora.dart b/lib/widgets/tarjeta_emisora.dart index bc15dfc..1e3e04f 100644 --- a/lib/widgets/tarjeta_emisora.dart +++ b/lib/widgets/tarjeta_emisora.dart @@ -8,6 +8,7 @@ import '../l10n/display_names.dart'; import '../l10n/gen/app_localizations.dart'; import '../modelos/emisora.dart'; import '../tema/pluriwave_theme.dart'; +import '../tema/pluriwave_tokens.dart'; import 'pluri_glass_surface.dart'; import 'pluri_icon.dart'; @@ -188,9 +189,19 @@ class _TarjetaEmisoraState extends State { decoration: BoxDecoration( shape: BoxShape.circle, gradient: SweepGradient( - colors: [t.electricMagenta, const Color(0xFF20E6FF), t.warmCoral, t.electricMagenta], + colors: [ + t.electricMagenta, + PluriWaveTokens.brightCyan, + t.warmCoral, + t.electricMagenta, + ], ), - boxShadow: [BoxShadow(color: t.glowColor.withValues(alpha: 0.24), blurRadius: 22)], + boxShadow: [ + BoxShadow( + color: t.glowColor.withValues(alpha: 0.24), + blurRadius: 22, + ), + ], ), ), ClipRRect( @@ -268,7 +279,11 @@ class _TarjetaEmisoraState extends State { : l10n.favoritesAddTooltip, ); + // S5-R2: container forces an OWN semantics node — without it the card's + // InkWell absorbs the favorite into one merged node and screen readers + // cannot reach the action independently. return Semantics( + container: true, button: true, toggled: esFavorito, label: @@ -279,11 +294,8 @@ class _TarjetaEmisoraState extends State { child: InkWell( customBorder: const CircleBorder(), onTap: _toggling ? null : _toggle, - child: SizedBox( - width: mini ? 36 : 44, - height: mini ? 36 : 44, - child: Center(child: icono), - ), + // S5-R2: 48dp minimum touch target in both variants. + child: SizedBox(width: 48, height: 48, child: Center(child: icono)), ), ), ); @@ -318,18 +330,21 @@ class _TarjetaEmisoraState extends State { Image.asset( art, fit: BoxFit.cover, - errorBuilder: (_, __, ___) => DecoratedBox( - decoration: BoxDecoration( - gradient: LinearGradient( - begin: Alignment.topLeft, - end: Alignment.bottomRight, - colors: [ - context.pluriTokens.deepViolet, - context.pluriTokens.electricMagenta.withValues(alpha: 0.8), - ], + errorBuilder: + (_, __, ___) => DecoratedBox( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [ + context.pluriTokens.deepViolet, + context.pluriTokens.electricMagenta.withValues( + alpha: 0.8, + ), + ], + ), + ), ), - ), - ), ), Center( child: PluriIcon( @@ -365,7 +380,10 @@ class _LiveBadge extends StatelessWidget { final color = Theme.of(context).colorScheme.secondary; final l10n = AppLocalizations.of(context); return Container( - padding: EdgeInsets.symmetric(horizontal: mini ? 8 : 6, vertical: mini ? 5 : 4), + padding: EdgeInsets.symmetric( + horizontal: mini ? 8 : 6, + vertical: mini ? 5 : 4, + ), decoration: BoxDecoration( borderRadius: BorderRadius.circular(999), color: Colors.black.withValues(alpha: 0.35), @@ -374,10 +392,19 @@ class _LiveBadge extends StatelessWidget { child: Row( mainAxisSize: MainAxisSize.min, children: [ - Icon(Icons.fiber_manual_record_rounded, size: mini ? 10 : 8, color: color), + Icon( + Icons.fiber_manual_record_rounded, + size: mini ? 10 : 8, + color: color, + ), if (mini) ...[ const SizedBox(width: 5), - Text(l10n.liveNow, style: Theme.of(context).textTheme.labelSmall?.copyWith(fontWeight: FontWeight.w900)), + Text( + l10n.liveNow, + style: Theme.of( + context, + ).textTheme.labelSmall?.copyWith(fontWeight: FontWeight.w900), + ), ], ], ), @@ -386,35 +413,74 @@ class _LiveBadge extends StatelessWidget { } /// Placeholder shimmer para listas en carga. +/// +/// S5-R6: corners match the real card radii; [esCompacta] mirrors the +/// compact row layout used in search results. class TarjetaEmisoraShimmer extends StatelessWidget { - const TarjetaEmisoraShimmer({super.key}); + const TarjetaEmisoraShimmer({super.key, this.esCompacta = false}); + + final bool esCompacta; @override Widget build(BuildContext context) { final theme = Theme.of(context); + final t = context.pluriTokens; + final color = theme.colorScheme.surfaceContainerHighest; + + Widget bloque({ + double? width, + double? height, + double? radius, + BoxShape shape = BoxShape.rectangle, + }) { + return Container( + width: width, + height: height, + decoration: BoxDecoration( + color: color, + shape: shape, + borderRadius: + shape == BoxShape.circle || radius == null + ? null + : BorderRadius.circular(radius), + ), + ); + } + + final contenido = + esCompacta + ? Row( + children: [ + bloque(width: 58, height: 58, shape: BoxShape.circle), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + bloque(height: 14, radius: 6), + const SizedBox(height: 6), + bloque(height: 12, width: 90, radius: 6), + ], + ), + ), + ], + ) + : Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + AspectRatio(aspectRatio: 1, child: bloque(radius: t.radiusLg)), + const SizedBox(height: 8), + bloque(height: 14, radius: 6), + const SizedBox(height: 4), + bloque(height: 12, width: 60, radius: 6), + ], + ); + return Shimmer.fromColors( - baseColor: theme.colorScheme.surfaceContainerHighest, + baseColor: color, highlightColor: theme.colorScheme.surface, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - AspectRatio( - aspectRatio: 1, - child: Container(color: theme.colorScheme.surfaceContainerHighest), - ), - const SizedBox(height: 8), - Container( - height: 14, - color: theme.colorScheme.surfaceContainerHighest, - ), - const SizedBox(height: 4), - Container( - height: 12, - width: 60, - color: theme.colorScheme.surfaceContainerHighest, - ), - ], - ), + child: contenido, ); } } diff --git a/lib/widgets/visualizador_audio.dart b/lib/widgets/visualizador_audio.dart index a4a39ca..8f26aca 100644 --- a/lib/widgets/visualizador_audio.dart +++ b/lib/widgets/visualizador_audio.dart @@ -5,6 +5,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import '../servicios/servicio_audio.dart'; +import '../tema/pluriwave_tokens.dart'; /// Visualizador de audio para el reproductor. /// @@ -234,7 +235,9 @@ class _WaveFlowPainter extends CustomPainter { colors: [ color.withValues(alpha: 0.08), color.withValues(alpha: active ? 0.95 : 0.35), - const Color(0xFFF4B860).withValues(alpha: active ? 0.75 : 0.20), + PluriWaveTokens.dark.warmCoral.withValues( + alpha: active ? 0.75 : 0.20, + ), color.withValues(alpha: 0.08), ], ).createShader(Offset.zero & size); diff --git a/openspec/changes/app-quality-and-native-alarms/apply-progress.md b/openspec/changes/app-quality-and-native-alarms/apply-progress.md index 5be5612..164359f 100644 --- a/openspec/changes/app-quality-and-native-alarms/apply-progress.md +++ b/openspec/changes/app-quality-and-native-alarms/apply-progress.md @@ -3,7 +3,7 @@ **Mode**: Strict TDD (test runner: `flutter test`) **Artifact store**: openspec (Engram unavailable this session) **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 @@ -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 | | 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 | +| 7 | S5 — Design system, a11y, i18n, polish | COMPLETE (Dart-only batch) | 2026-06-11 | ## Task status (cumulative) @@ -165,9 +166,32 @@ | 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 | +### 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) -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) @@ -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. +### 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) | 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. 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 - 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) -- 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). -- Next batch: S5 (design system / a11y / i18n — unblocked since S2b) then S6 (quality gates — now unblocked: depends on S4b + S5). +- Current work units: S1, S2a, S2b, S3a, S3b, S7, S4a, S4b (committed, latest 52855e7), S5 (complete, in working tree) +- 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: S6 (quality gates — lint hardening + remaining top-5 tests) then cross-cutting T-CC-01/T-CC-02. S6 is the LAST slice. diff --git a/openspec/changes/app-quality-and-native-alarms/tasks.md b/openspec/changes/app-quality-and-native-alarms/tasks.md index 052becc..514effb 100644 --- a/openspec/changes/app-quality-and-native-alarms/tasks.md +++ b/openspec/changes/app-quality-and-native-alarms/tasks.md @@ -363,30 +363,30 @@ Chain strategy: N/A (local apply) ### 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.** -- [ ] **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.** -- [ ] **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.** -- [ ] **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.** -- [ ] **T-S5-05** [RED] Add widget test: shimmer present during loading state in `PantallaBuscar` (S5-R6). **~10 lines.** -- [ ] **T-S5-06** [RED] Add unit test: `AudioServiceConfig.notificationColor` equals brand color token (S5-R8). **~10 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).** +- [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.** +- [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.** +- [x] **T-S5-04** [RED] `test/pantallas/pantalla_favoritos_plural_test.dart`: `stationCount(1)` differs from `stationCount(5)` in en and es. **DONE.** +- [x] **T-S5-05** [RED] `test/pantallas/pantalla_buscar_shimmer_test.dart`: loading shows `TarjetaEmisoraShimmer`, NO `CircularProgressIndicator` (cargando-true EstadoBusqueda subclass seam). **DONE.** +- [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 -- [ ] **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.** -- [ ] **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.** -- [ ] **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). -- [ ] **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.** -- [ ] **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.** -- [ ] **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.** -- [ ] **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.** -- [ ] **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-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.** +- [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.** +- [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.** +- [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.** +- [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.** +- [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.** +- [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.** +- [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 -- [ ] **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`. -- [ ] **T-S5-16** Run `flutter test` (full suite) — no regressions. -- [ ] **T-S5-17** Run `flutter analyze` — zero errors (no `Color(0x...)` in modified files beyond token definitions). -- [ ] **T-S5-18** Run `dart format` on all edited files. +- [x] **T-S5-15** Targeted run (6 files, 11 tests) — all green (RED captured first: `+0 -6`). +- [x] **T-S5-16** Full suite 121/121 (110 baseline + 11 new), no regressions. +- [x] **T-S5-17** `flutter analyze` — `No issues found!`; `rg 'Color(0x' lib` outside lib/tema + l10n/gen → ZERO matches. +- [x] **T-S5-18** `dart format` on all 20 touched Dart files (7 reflowed); analyze + suite re-run after format. ### S5 Definition of Done - `flutter test` green. diff --git a/test/pantallas/pantalla_alarmas_fecha_test.dart b/test/pantallas/pantalla_alarmas_fecha_test.dart new file mode 100644 index 0000000..906dc4c --- /dev/null +++ b/test/pantallas/pantalla_alarmas_fecha_test.dart @@ -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), + ); + }); +} diff --git a/test/pantallas/pantalla_buscar_shimmer_test.dart b/test/pantallas/pantalla_buscar_shimmer_test.dart new file mode 100644 index 0000000..a1438de --- /dev/null +++ b/test/pantallas/pantalla_buscar_shimmer_test.dart @@ -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.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); + }); +} diff --git a/test/pantallas/pantalla_favoritos_plural_test.dart b/test/pantallas/pantalla_favoritos_plural_test.dart new file mode 100644 index 0000000..b77ecba --- /dev/null +++ b/test/pantallas/pantalla_favoritos_plural_test.dart @@ -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')); + }); +} diff --git a/test/tema/notification_color_test.dart b/test/tema/notification_color_test.dart new file mode 100644 index 0000000..9594c51 --- /dev/null +++ b/test/tema/notification_color_test.dart @@ -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)), + ); + }); +} diff --git a/test/tema/pluri_animate_test.dart b/test/tema/pluri_animate_test.dart new file mode 100644 index 0000000..54aafa2 --- /dev/null +++ b/test/tema/pluri_animate_test.dart @@ -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); + }); +} diff --git a/test/widgets/tarjeta_emisora_a11y_test.dart b/test/widgets/tarjeta_emisora_a11y_test.dart new file mode 100644 index 0000000..4da5b6e --- /dev/null +++ b/test/widgets/tarjeta_emisora_a11y_test.dart @@ -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.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(); + }); +}