Compare commits
20
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
42d35a2541 | ||
|
|
f61b0b9163 | ||
|
|
36d7d5f692 | ||
|
|
dcb415b3f8 | ||
|
|
c7d137c82e | ||
|
|
dcd8488874 | ||
|
|
94b1e901d1 | ||
|
|
533896b9fa | ||
|
|
b5b9829faa | ||
|
|
e036f99a61 | ||
|
|
01615f9751 | ||
|
|
7ff156852f | ||
|
|
038ec7f3b8 | ||
|
|
8fcb734ab0 | ||
|
|
ca2400f0d4 | ||
|
|
163241d4a1 | ||
|
|
56123ea51b | ||
|
|
8ecfc928b3 | ||
|
|
3303bd387c | ||
|
|
955682271c |
+12
-1
@@ -262,8 +262,11 @@
|
||||
"searchCountryFilterLabel": "البلد",
|
||||
"searchLanguageFilterLabel": "اللغة",
|
||||
"searchMinQualityFilterLabel": "الحد الأدنى للجودة",
|
||||
"searchLoadingStationsLabel": "جارٍ البحث عن محطات…",
|
||||
"searchEmptyTitle": "ابحث عن محطة",
|
||||
"searchNoResultsTitle": "لا توجد نتائج",
|
||||
"searchNoResultsForQueryTitle": "لا توجد نتائج لـ «{query}»",
|
||||
"@searchNoResultsForQueryTitle": {"placeholders": {"query": {}}},
|
||||
"searchEmptySubtitle": "استخدم الشريط العلوي أو الشرائح لاكتشاف إشارات من كل العالم.",
|
||||
"searchNoResultsSubtitle": "جرّب إزالة الفلاتر أو كتابة اسم آخر للعثور على إشارة نشطة.",
|
||||
"countrySpain": "إسبانيا",
|
||||
@@ -440,6 +443,7 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"alarmSnoozeUsualLabel": "المعتاد",
|
||||
"saveFavoritesAlarmHint": "احفظ محطات في المفضلة لاستخدامها كمنبه موسيقي.",
|
||||
"useCurrentStationAction": "استخدام المحطة الحالية",
|
||||
"playDuringVacations": "الرنين أثناء الإجازات",
|
||||
@@ -589,6 +593,8 @@
|
||||
"days": {}
|
||||
}
|
||||
},
|
||||
"alarmRepeatSectionLabel": "تكرار",
|
||||
"alarmVolumeLabel": "مستوى الصوت",
|
||||
"androidReliabilityTitle": "مراجعة موثوقية Android",
|
||||
"closeAction": "إغلاق",
|
||||
"customOption": "مخصص",
|
||||
@@ -705,6 +711,7 @@
|
||||
"alarmVolumeRisingStatus": "رفع مستوى الصوت تدريجيًا",
|
||||
"countriesAllTitle": "كل الدول",
|
||||
"countriesScreenTitle": "الدول",
|
||||
"countriesSearchHint": "الدولة أو الرمز...",
|
||||
"countriesYourLanguagesTitle": "لغاتك",
|
||||
"customStationsAddCta": "إضافة محطة مخصصة",
|
||||
"equalizerActiveOutputDefault": "مكبر صوت هذا الجهاز",
|
||||
@@ -746,6 +753,7 @@
|
||||
"recordingRenameEmptyError": "أدخل اسمًا",
|
||||
"recordingRenameLabel": "الاسم",
|
||||
"recordingsLibraryEmptySubtitle": "التسجيلات التي تحفظها ستظهر هنا.",
|
||||
"recordingsLibraryStorageFolderCaption": "Music/PluriWave · يتم حذف الأقدم عند بلوغ الحد",
|
||||
"recordingsLibraryEmptyTitle": "لا توجد تسجيلات بعد",
|
||||
"recordingsLibrarySettingsTooltip": "إعدادات التسجيل",
|
||||
"recordingsLibraryStorageCaption": "تم استخدام {usedMb} ميغابايت من أصل {totalMb} ميغابايت",
|
||||
@@ -780,6 +788,7 @@
|
||||
"times": {}
|
||||
}
|
||||
},
|
||||
"vacationExplainerBanner": "خلال هذه الفترات، لن تُصدر المنبهات المُعلَّمة بـ «إيقاف أثناء الإجازة» صوتًا. أما المُعلَّمة بـ «تشغيل دائمًا» فلن تتأثر.",
|
||||
"vacationNoActiveRangeHint": "لا توجد فترة إجازة نشطة الآن.",
|
||||
"vacationPastSectionTitle": "الفترات السابقة",
|
||||
"vacationRangesCount": "{count} فترة",
|
||||
@@ -816,5 +825,7 @@
|
||||
"welcomeBullet3Title": "منبهات موسيقية",
|
||||
"welcomeCtaLabel": "ابدأ الاستماع",
|
||||
"welcomeHeadline": "عالمك، على الهواء مباشرة",
|
||||
"yourStationsTitle": "محطاتك"
|
||||
"yourStationsTitle": "محطاتك",
|
||||
"nowListeningLabel": "الاستماع الآن",
|
||||
"popularNowTitle": "الأكثر شيوعًا الآن"
|
||||
}
|
||||
|
||||
+12
-1
@@ -262,8 +262,11 @@
|
||||
"searchCountryFilterLabel": "দেশ",
|
||||
"searchLanguageFilterLabel": "ভাষা",
|
||||
"searchMinQualityFilterLabel": "ন্যূনতম গুণমান",
|
||||
"searchLoadingStationsLabel": "স্টেশন খোঁজা হচ্ছে…",
|
||||
"searchEmptyTitle": "একটি স্টেশন খুঁজুন",
|
||||
"searchNoResultsTitle": "কোনো ফলাফল নেই",
|
||||
"searchNoResultsForQueryTitle": "\"{query}\"-এর জন্য কোনো ফলাফল নেই",
|
||||
"@searchNoResultsForQueryTitle": {"placeholders": {"query": {}}},
|
||||
"searchEmptySubtitle": "উপরের বার বা চিপ ব্যবহার করে সারা বিশ্বের সিগন্যাল আবিষ্কার করুন।",
|
||||
"searchNoResultsSubtitle": "সক্রিয় সিগন্যাল পেতে ফিল্টার সরিয়ে বা অন্য নাম লিখে দেখুন।",
|
||||
"countrySpain": "স্পেন",
|
||||
@@ -440,6 +443,7 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"alarmSnoozeUsualLabel": "সাধারণ",
|
||||
"saveFavoritesAlarmHint": "সুরেলা অ্যালার্ম হিসেবে ব্যবহার করতে প্রিয়তে স্টেশন সংরক্ষণ করুন।",
|
||||
"useCurrentStationAction": "বর্তমান স্টেশন ব্যবহার করুন",
|
||||
"playDuringVacations": "ছুটিতে বাজান",
|
||||
@@ -589,6 +593,8 @@
|
||||
"days": {}
|
||||
}
|
||||
},
|
||||
"alarmRepeatSectionLabel": "পুনরাবৃত্তি",
|
||||
"alarmVolumeLabel": "ভলিউম",
|
||||
"androidReliabilityTitle": "Android নির্ভরযোগ্যতা দেখুন",
|
||||
"closeAction": "বন্ধ করুন",
|
||||
"customOption": "কাস্টম",
|
||||
@@ -705,6 +711,7 @@
|
||||
"alarmVolumeRisingStatus": "ভলিউম বাড়ছে",
|
||||
"countriesAllTitle": "সব দেশ",
|
||||
"countriesScreenTitle": "দেশসমূহ",
|
||||
"countriesSearchHint": "দেশ বা কোড...",
|
||||
"countriesYourLanguagesTitle": "আপনার ভাষাসমূহ",
|
||||
"customStationsAddCta": "নিজস্ব স্টেশন যোগ করুন",
|
||||
"equalizerActiveOutputDefault": "এই ডিভাইসের স্পিকার",
|
||||
@@ -746,6 +753,7 @@
|
||||
"recordingRenameEmptyError": "একটি নাম লিখুন",
|
||||
"recordingRenameLabel": "নাম",
|
||||
"recordingsLibraryEmptySubtitle": "আপনি যেসব রেকর্ডিং সংরক্ষণ করবেন তা এখানে দেখা যাবে।",
|
||||
"recordingsLibraryStorageFolderCaption": "Music/PluriWave · সীমা পৌঁছালে সবচেয়ে পুরনোগুলো মুছে যায়",
|
||||
"recordingsLibraryEmptyTitle": "এখনও কোনো রেকর্ডিং নেই",
|
||||
"recordingsLibrarySettingsTooltip": "রেকর্ডিং সেটিংস",
|
||||
"recordingsLibraryStorageCaption": "{totalMb} MB-এর মধ্যে {usedMb} MB ব্যবহৃত",
|
||||
@@ -780,6 +788,7 @@
|
||||
"times": {}
|
||||
}
|
||||
},
|
||||
"vacationExplainerBanner": "এই সময়কালে \"ছুটিতে বিরতি\" হিসেবে চিহ্নিত অ্যালার্মগুলো বাজবে না। \"সবসময় বাজবে\" হিসেবে চিহ্নিত অ্যালার্মগুলো প্রভাবিত হবে না।",
|
||||
"vacationNoActiveRangeHint": "এই মুহূর্তে কোনো সক্রিয় ছুটির সময়সীমা নেই।",
|
||||
"vacationPastSectionTitle": "অতীত সময়সীমা",
|
||||
"vacationRangesCount": "{count}টি সময়সীমা",
|
||||
@@ -816,5 +825,7 @@
|
||||
"welcomeBullet3Title": "সুরেলা অ্যালার্ম",
|
||||
"welcomeCtaLabel": "শোনা শুরু করুন",
|
||||
"welcomeHeadline": "আপনার বিশ্ব, সরাসরি",
|
||||
"yourStationsTitle": "আপনার স্টেশন"
|
||||
"yourStationsTitle": "আপনার স্টেশন",
|
||||
"nowListeningLabel": "এখন শোনা হচ্ছে",
|
||||
"popularNowTitle": "এখন জনপ্রিয়"
|
||||
}
|
||||
|
||||
+12
-1
@@ -262,8 +262,11 @@
|
||||
"searchCountryFilterLabel": "Land",
|
||||
"searchLanguageFilterLabel": "Sprache",
|
||||
"searchMinQualityFilterLabel": "Mindestqualität",
|
||||
"searchLoadingStationsLabel": "SENDER WERDEN GESUCHT…",
|
||||
"searchEmptyTitle": "Suche nach einem Sender",
|
||||
"searchNoResultsTitle": "Keine Ergebnisse",
|
||||
"searchNoResultsForQueryTitle": "Keine Ergebnisse für „{query}“",
|
||||
"@searchNoResultsForQueryTitle": {"placeholders": {"query": {}}},
|
||||
"searchEmptySubtitle": "Nutze die obere Leiste oder die Chips, um Sender aus aller Welt zu entdecken.",
|
||||
"searchNoResultsSubtitle": "Versuche, Filter zu entfernen oder einen anderen Namen einzugeben, um einen aktiven Sender zu finden.",
|
||||
"countrySpain": "Spanien",
|
||||
@@ -440,6 +443,7 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"alarmSnoozeUsualLabel": "üblich",
|
||||
"saveFavoritesAlarmHint": "Speichere Sender in Favoriten, um sie als musikalischen Alarm zu verwenden.",
|
||||
"useCurrentStationAction": "Aktuellen Sender verwenden",
|
||||
"playDuringVacations": "Während der Ferien läuten",
|
||||
@@ -589,6 +593,8 @@
|
||||
"days": {}
|
||||
}
|
||||
},
|
||||
"alarmRepeatSectionLabel": "WIEDERHOLEN",
|
||||
"alarmVolumeLabel": "Lautstärke",
|
||||
"androidReliabilityTitle": "Android-Zuverlässigkeit prüfen",
|
||||
"closeAction": "Schließen",
|
||||
"customOption": "Benutzerdefiniert",
|
||||
@@ -705,6 +711,7 @@
|
||||
"alarmVolumeRisingStatus": "Lautstärke wird erhöht",
|
||||
"countriesAllTitle": "Alle Länder",
|
||||
"countriesScreenTitle": "Länder",
|
||||
"countriesSearchHint": "Land oder Code...",
|
||||
"countriesYourLanguagesTitle": "Deine Sprachen",
|
||||
"customStationsAddCta": "Benutzerdefinierten Sender hinzufügen",
|
||||
"equalizerActiveOutputDefault": "Der Lautsprecher dieses Geräts",
|
||||
@@ -746,6 +753,7 @@
|
||||
"recordingRenameEmptyError": "Gib einen Namen ein",
|
||||
"recordingRenameLabel": "Name",
|
||||
"recordingsLibraryEmptySubtitle": "Aufnahmen, die du speicherst, erscheinen hier.",
|
||||
"recordingsLibraryStorageFolderCaption": "Music/PluriWave · älteste werden bei Erreichen des Limits gelöscht",
|
||||
"recordingsLibraryEmptyTitle": "Noch keine Aufnahmen",
|
||||
"recordingsLibrarySettingsTooltip": "Aufnahmeeinstellungen",
|
||||
"recordingsLibraryStorageCaption": "{usedMb} MB von {totalMb} MB belegt",
|
||||
@@ -780,6 +788,7 @@
|
||||
"times": {}
|
||||
}
|
||||
},
|
||||
"vacationExplainerBanner": "Während dieser Zeiträume klingeln Alarme mit der Markierung \"im Urlaub pausieren\" nicht. Als \"immer klingeln\" markierte Alarme sind davon nicht betroffen.",
|
||||
"vacationNoActiveRangeHint": "Momentan ist kein aktiver Urlaubszeitraum vorhanden.",
|
||||
"vacationPastSectionTitle": "Vergangene Zeiträume",
|
||||
"vacationRangesCount": "{count} Zeiträume",
|
||||
@@ -816,5 +825,7 @@
|
||||
"welcomeBullet3Title": "Musikalarme",
|
||||
"welcomeCtaLabel": "Jetzt hören",
|
||||
"welcomeHeadline": "Deine Welt, live",
|
||||
"yourStationsTitle": "Deine Sender"
|
||||
"yourStationsTitle": "Deine Sender",
|
||||
"nowListeningLabel": "Läuft gerade",
|
||||
"popularNowTitle": "Jetzt beliebt"
|
||||
}
|
||||
|
||||
@@ -233,6 +233,7 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"recordingsLibraryStorageFolderCaption": "Music/PluriWave · purges oldest at limit",
|
||||
"recordingsLibraryEmptyTitle": "No recordings yet",
|
||||
"recordingsLibraryEmptySubtitle": "Recordings you save will appear here.",
|
||||
"recordingActionRename": "Rename",
|
||||
@@ -315,13 +316,17 @@
|
||||
"searchCountryFilterLabel": "Country",
|
||||
"searchLanguageFilterLabel": "Language",
|
||||
"searchMinQualityFilterLabel": "Minimum quality",
|
||||
"searchLoadingStationsLabel": "SEARCHING FOR STATIONS…",
|
||||
"searchEmptyTitle": "Search for a station",
|
||||
"searchNoResultsTitle": "No results",
|
||||
"searchNoResultsForQueryTitle": "No results for \"{query}\"",
|
||||
"@searchNoResultsForQueryTitle": {"placeholders": {"query": {}}},
|
||||
"searchEmptySubtitle": "Use the top bar or chips to discover stations from around the world.",
|
||||
"searchNoResultsSubtitle": "Try removing filters or typing another name to find an active station.",
|
||||
"searchResultsCount": "{count, plural, =1{1 result} other{{count} results}}",
|
||||
"searchClearFiltersAction": "{count, plural, =1{Clear filter} other{Clear {count} filters}}",
|
||||
"countriesScreenTitle": "Countries",
|
||||
"countriesSearchHint": "Country or code...",
|
||||
"countriesYourLanguagesTitle": "Your languages",
|
||||
"countriesAllTitle": "All countries",
|
||||
"radioCountriesError": "We couldn't load the countries.",
|
||||
@@ -358,6 +363,7 @@
|
||||
"yourStationsTitle": "Your stations",
|
||||
"seeAllAction": "See all",
|
||||
"openFullPlayerTooltip": "Open full player",
|
||||
"nowListeningLabel": "Now listening",
|
||||
"nothingPlayingTitle": "Nothing playing yet",
|
||||
"nothingPlayingSubtitle": "Pick a station from Your stations or search to start.",
|
||||
"nearYou": "Near you",
|
||||
@@ -375,6 +381,7 @@
|
||||
"exploreTrendingSubtitle": "Today",
|
||||
"exploreNewTitle": "New",
|
||||
"exploreNewSubtitle": "This week",
|
||||
"popularNowTitle": "Popular now",
|
||||
"offlineBannerTitle": "No connection",
|
||||
"retryAction": "Retry",
|
||||
"noStationsAvailable": "No stations available",
|
||||
@@ -507,6 +514,7 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"alarmSnoozeUsualLabel": "usual",
|
||||
"saveFavoritesAlarmHint": "Save stations in Favorites to use them as a music alarm.",
|
||||
"useCurrentStationAction": "Use current station",
|
||||
"playDuringVacations": "Play during vacations",
|
||||
@@ -568,6 +576,7 @@
|
||||
"vacationUpcomingSectionTitle": "SCHEDULED",
|
||||
"vacationPastSectionTitle": "Past ranges",
|
||||
"addVacationRangeCta": "Add range",
|
||||
"vacationExplainerBanner": "During these ranges, alarms marked \"pause during vacations\" won't ring. Alarms marked \"always ring\" are not affected.",
|
||||
"vacationNoActiveRangeHint": "No active vacation range right now.",
|
||||
"vacationsDefaultName": "Vacation",
|
||||
"newVacationRangeTitle": "New vacation range",
|
||||
@@ -696,6 +705,8 @@
|
||||
"days": {}
|
||||
}
|
||||
},
|
||||
"alarmRepeatSectionLabel": "REPEAT",
|
||||
"alarmVolumeLabel": "Volume",
|
||||
"androidReliabilityTitle": "Review Android reliability",
|
||||
"closeAction": "Close",
|
||||
"customOption": "Custom",
|
||||
|
||||
@@ -233,6 +233,7 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"recordingsLibraryStorageFolderCaption": "Music/PluriWave · se borran las más antiguas al llegar al límite",
|
||||
"recordingsLibraryEmptyTitle": "Todavía no hay grabaciones",
|
||||
"recordingsLibraryEmptySubtitle": "Las grabaciones que guardes van a aparecer acá.",
|
||||
"recordingActionRename": "Renombrar",
|
||||
@@ -315,13 +316,17 @@
|
||||
"searchCountryFilterLabel": "País",
|
||||
"searchLanguageFilterLabel": "Idioma",
|
||||
"searchMinQualityFilterLabel": "Calidad mínima",
|
||||
"searchLoadingStationsLabel": "BUSCANDO EMISORAS…",
|
||||
"searchEmptyTitle": "Buscá una emisora",
|
||||
"searchNoResultsTitle": "Sin resultados",
|
||||
"searchNoResultsForQueryTitle": "Sin resultados para «{query}»",
|
||||
"@searchNoResultsForQueryTitle": {"placeholders": {"query": {}}},
|
||||
"searchEmptySubtitle": "Usá la barra superior o los chips para descubrir señales de todo el mundo.",
|
||||
"searchNoResultsSubtitle": "Probá quitar filtros o escribir otro nombre para encontrar una señal activa.",
|
||||
"searchResultsCount": "{count, plural, =1{1 resultado} other{{count} resultados}}",
|
||||
"searchClearFiltersAction": "{count, plural, =1{Quitar el filtro} other{Quitar los {count} filtros}}",
|
||||
"countriesScreenTitle": "Países",
|
||||
"countriesSearchHint": "País o código...",
|
||||
"countriesYourLanguagesTitle": "Tus idiomas",
|
||||
"countriesAllTitle": "Todos los países",
|
||||
"radioCountriesError": "No pudimos cargar los países.",
|
||||
@@ -358,6 +363,7 @@
|
||||
"yourStationsTitle": "Tus emisoras",
|
||||
"seeAllAction": "Ver todas",
|
||||
"openFullPlayerTooltip": "Abrir reproductor completo",
|
||||
"nowListeningLabel": "Escuchando ahora",
|
||||
"nothingPlayingTitle": "Todavía no estás escuchando nada",
|
||||
"nothingPlayingSubtitle": "Elegí una emisora de Tus emisoras o buscá una para empezar.",
|
||||
"nearYou": "Cerca de vos",
|
||||
@@ -375,6 +381,7 @@
|
||||
"exploreTrendingSubtitle": "Hoy",
|
||||
"exploreNewTitle": "Novedades",
|
||||
"exploreNewSubtitle": "Esta semana",
|
||||
"popularNowTitle": "Populares ahora",
|
||||
"offlineBannerTitle": "Sin conexión",
|
||||
"retryAction": "Reintentar",
|
||||
"noStationsAvailable": "No hay emisoras disponibles",
|
||||
@@ -507,6 +514,7 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"alarmSnoozeUsualLabel": "habitual",
|
||||
"saveFavoritesAlarmHint": "Guardá emisoras en Favoritos para usarlas como alarma musical.",
|
||||
"useCurrentStationAction": "Usar emisora actual",
|
||||
"playDuringVacations": "Sonar durante vacaciones",
|
||||
@@ -568,6 +576,7 @@
|
||||
"vacationUpcomingSectionTitle": "PROGRAMADOS",
|
||||
"vacationPastSectionTitle": "Rangos pasados",
|
||||
"addVacationRangeCta": "Añadir rango",
|
||||
"vacationExplainerBanner": "Durante estos rangos no suenan las alarmas marcadas como \"pausar en vacaciones\". Las marcadas como \"sonar siempre\" no se ven afectadas.",
|
||||
"vacationNoActiveRangeHint": "No hay un rango de vacaciones activo ahora mismo.",
|
||||
"vacationsDefaultName": "Vacaciones",
|
||||
"newVacationRangeTitle": "Nuevo rango de vacaciones",
|
||||
@@ -652,6 +661,8 @@
|
||||
"@alarmScheduleOnce": {"placeholders": {"date": {}}},
|
||||
"alarmScheduleWeekdays": "Días: {days}",
|
||||
"@alarmScheduleWeekdays": {"placeholders": {"days": {}}},
|
||||
"alarmRepeatSectionLabel": "REPETIR",
|
||||
"alarmVolumeLabel": "Volumen",
|
||||
"androidReliabilityTitle": "Revisar fiabilidad Android",
|
||||
"closeAction": "Cerrar",
|
||||
"customOption": "Personalizada",
|
||||
|
||||
+12
-1
@@ -262,8 +262,11 @@
|
||||
"searchCountryFilterLabel": "Pays",
|
||||
"searchLanguageFilterLabel": "Langue",
|
||||
"searchMinQualityFilterLabel": "Qualité minimale",
|
||||
"searchLoadingStationsLabel": "RECHERCHE DE STATIONS…",
|
||||
"searchEmptyTitle": "Recherchez une station",
|
||||
"searchNoResultsTitle": "Aucun résultat",
|
||||
"searchNoResultsForQueryTitle": "Aucun résultat pour « {query} »",
|
||||
"@searchNoResultsForQueryTitle": {"placeholders": {"query": {}}},
|
||||
"searchEmptySubtitle": "Utilisez la barre du haut ou les pastilles pour découvrir des stations du monde entier.",
|
||||
"searchNoResultsSubtitle": "Essayez de retirer des filtres ou de saisir un autre nom pour trouver une station active.",
|
||||
"countrySpain": "Espagne",
|
||||
@@ -440,6 +443,7 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"alarmSnoozeUsualLabel": "habituel",
|
||||
"saveFavoritesAlarmHint": "Enregistrez des stations dans les Favoris pour les utiliser comme alarme musicale.",
|
||||
"useCurrentStationAction": "Utiliser la station actuelle",
|
||||
"playDuringVacations": "Sonner pendant les vacances",
|
||||
@@ -589,6 +593,8 @@
|
||||
"days": {}
|
||||
}
|
||||
},
|
||||
"alarmRepeatSectionLabel": "RÉPÉTER",
|
||||
"alarmVolumeLabel": "Volume",
|
||||
"androidReliabilityTitle": "Vérifier la fiabilité Android",
|
||||
"closeAction": "Fermer",
|
||||
"customOption": "Personnalisée",
|
||||
@@ -705,6 +711,7 @@
|
||||
"alarmVolumeRisingStatus": "Augmentation du volume",
|
||||
"countriesAllTitle": "Tous les pays",
|
||||
"countriesScreenTitle": "Pays",
|
||||
"countriesSearchHint": "Pays ou code...",
|
||||
"countriesYourLanguagesTitle": "Vos langues",
|
||||
"customStationsAddCta": "Ajouter une station personnalisée",
|
||||
"equalizerActiveOutputDefault": "Le haut-parleur de cet appareil",
|
||||
@@ -746,6 +753,7 @@
|
||||
"recordingRenameEmptyError": "Saisissez un nom",
|
||||
"recordingRenameLabel": "Nom",
|
||||
"recordingsLibraryEmptySubtitle": "Les enregistrements que vous sauvegardez apparaîtront ici.",
|
||||
"recordingsLibraryStorageFolderCaption": "Music/PluriWave · supprime les plus anciens à la limite",
|
||||
"recordingsLibraryEmptyTitle": "Aucun enregistrement pour l'instant",
|
||||
"recordingsLibrarySettingsTooltip": "Paramètres d'enregistrement",
|
||||
"recordingsLibraryStorageCaption": "{usedMb} Mo sur {totalMb} Mo utilisés",
|
||||
@@ -780,6 +788,7 @@
|
||||
"times": {}
|
||||
}
|
||||
},
|
||||
"vacationExplainerBanner": "Pendant ces periodes, les alarmes marquees \"pause pendant les vacances\" ne sonnent pas. Celles marquees \"toujours sonner\" ne sont pas concernees.",
|
||||
"vacationNoActiveRangeHint": "Aucune période de vacances active pour le moment.",
|
||||
"vacationPastSectionTitle": "Périodes passées",
|
||||
"vacationRangesCount": "{count} périodes",
|
||||
@@ -816,5 +825,7 @@
|
||||
"welcomeBullet3Title": "Alarmes musicales",
|
||||
"welcomeCtaLabel": "Commencer à écouter",
|
||||
"welcomeHeadline": "Votre monde, en direct",
|
||||
"yourStationsTitle": "Vos stations"
|
||||
"yourStationsTitle": "Vos stations",
|
||||
"nowListeningLabel": "En cours d'écoute",
|
||||
"popularNowTitle": "Populaire maintenant"
|
||||
}
|
||||
|
||||
+12
-1
@@ -262,8 +262,11 @@
|
||||
"searchCountryFilterLabel": "देश",
|
||||
"searchLanguageFilterLabel": "भाषा",
|
||||
"searchMinQualityFilterLabel": "न्यूनतम गुणवत्ता",
|
||||
"searchLoadingStationsLabel": "स्टेशन खोजे जा रहे हैं…",
|
||||
"searchEmptyTitle": "एक स्टेशन खोजें",
|
||||
"searchNoResultsTitle": "कोई परिणाम नहीं",
|
||||
"searchNoResultsForQueryTitle": "\"{query}\" के लिए कोई परिणाम नहीं",
|
||||
"@searchNoResultsForQueryTitle": {"placeholders": {"query": {}}},
|
||||
"searchEmptySubtitle": "दुनिया भर के सिग्नल खोजने के लिए ऊपर की बार या चिप्स इस्तेमाल करें।",
|
||||
"searchNoResultsSubtitle": "सक्रिय सिग्नल पाने के लिए फ़िल्टर हटाएँ या कोई दूसरा नाम लिखें।",
|
||||
"countrySpain": "स्पेन",
|
||||
@@ -440,6 +443,7 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"alarmSnoozeUsualLabel": "सामान्य",
|
||||
"saveFavoritesAlarmHint": "उन्हें संगीतमय अलार्म के रूप में इस्तेमाल करने के लिए स्टेशन पसंदीदा में सहेजें।",
|
||||
"useCurrentStationAction": "वर्तमान स्टेशन इस्तेमाल करें",
|
||||
"playDuringVacations": "छुट्टियों में बजाएँ",
|
||||
@@ -589,6 +593,8 @@
|
||||
"days": {}
|
||||
}
|
||||
},
|
||||
"alarmRepeatSectionLabel": "दोहराएं",
|
||||
"alarmVolumeLabel": "आवाज़",
|
||||
"androidReliabilityTitle": "Android विश्वसनीयता जाँचें",
|
||||
"closeAction": "बंद करें",
|
||||
"customOption": "कस्टम",
|
||||
@@ -705,6 +711,7 @@
|
||||
"alarmVolumeRisingStatus": "आवाज़ बढ़ाई जा रही है",
|
||||
"countriesAllTitle": "सभी देश",
|
||||
"countriesScreenTitle": "देश",
|
||||
"countriesSearchHint": "देश या कोड...",
|
||||
"countriesYourLanguagesTitle": "आपकी भाषाएं",
|
||||
"customStationsAddCta": "मनचाहा स्टेशन जोड़ें",
|
||||
"equalizerActiveOutputDefault": "इस डिवाइस का स्पीकर",
|
||||
@@ -746,6 +753,7 @@
|
||||
"recordingRenameEmptyError": "एक नाम डालें",
|
||||
"recordingRenameLabel": "नाम",
|
||||
"recordingsLibraryEmptySubtitle": "आप जो रिकॉर्डिंग सहेजेंगे, वे यहां दिखाई देंगी।",
|
||||
"recordingsLibraryStorageFolderCaption": "Music/PluriWave · सीमा पूरी होने पर सबसे पुरानी रिकॉर्डिंग हटा दी जाती हैं",
|
||||
"recordingsLibraryEmptyTitle": "अभी तक कोई रिकॉर्डिंग नहीं",
|
||||
"recordingsLibrarySettingsTooltip": "रिकॉर्डिंग सेटिंग्स",
|
||||
"recordingsLibraryStorageCaption": "{totalMb} MB में से {usedMb} MB इस्तेमाल हुआ",
|
||||
@@ -780,6 +788,7 @@
|
||||
"times": {}
|
||||
}
|
||||
},
|
||||
"vacationExplainerBanner": "इन अवधियों के दौरान \"छुट्टी में रोकें\" के रूप में चिह्नित अलार्म नहीं बजेंगे। \"हमेशा बजाएं\" के रूप में चिह्नित अलार्म पर कोई असर नहीं पड़ता।",
|
||||
"vacationNoActiveRangeHint": "अभी कोई सक्रिय छुट्टी अवधि नहीं है।",
|
||||
"vacationPastSectionTitle": "पिछली अवधियां",
|
||||
"vacationRangesCount": "{count} अवधियां",
|
||||
@@ -816,5 +825,7 @@
|
||||
"welcomeBullet3Title": "संगीत अलार्म",
|
||||
"welcomeCtaLabel": "सुनना शुरू करें",
|
||||
"welcomeHeadline": "आपकी दुनिया, लाइव",
|
||||
"yourStationsTitle": "आपके स्टेशन"
|
||||
"yourStationsTitle": "आपके स्टेशन",
|
||||
"nowListeningLabel": "अभी सुन रहे हैं",
|
||||
"popularNowTitle": "अभी लोकप्रिय"
|
||||
}
|
||||
|
||||
+12
-1
@@ -262,8 +262,11 @@
|
||||
"searchCountryFilterLabel": "Negara",
|
||||
"searchLanguageFilterLabel": "Bahasa",
|
||||
"searchMinQualityFilterLabel": "Kualitas minimum",
|
||||
"searchLoadingStationsLabel": "MENCARI STASIUN…",
|
||||
"searchEmptyTitle": "Cari stasiun",
|
||||
"searchNoResultsTitle": "Tidak ada hasil",
|
||||
"searchNoResultsForQueryTitle": "Tidak ada hasil untuk \"{query}\"",
|
||||
"@searchNoResultsForQueryTitle": {"placeholders": {"query": {}}},
|
||||
"searchEmptySubtitle": "Gunakan bilah atas atau chip untuk menemukan sinyal dari seluruh dunia.",
|
||||
"searchNoResultsSubtitle": "Coba hapus filter atau tulis nama lain untuk menemukan sinyal aktif.",
|
||||
"countrySpain": "Spanyol",
|
||||
@@ -440,6 +443,7 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"alarmSnoozeUsualLabel": "biasa",
|
||||
"saveFavoritesAlarmHint": "Simpan stasiun ke Favorit untuk digunakan sebagai alarm musik.",
|
||||
"useCurrentStationAction": "Gunakan stasiun saat ini",
|
||||
"playDuringVacations": "Bunyi saat liburan",
|
||||
@@ -589,6 +593,8 @@
|
||||
"days": {}
|
||||
}
|
||||
},
|
||||
"alarmRepeatSectionLabel": "ULANGI",
|
||||
"alarmVolumeLabel": "Volume",
|
||||
"androidReliabilityTitle": "Tinjau keandalan Android",
|
||||
"closeAction": "Tutup",
|
||||
"customOption": "Kustom",
|
||||
@@ -705,6 +711,7 @@
|
||||
"alarmVolumeRisingStatus": "Menaikkan volume",
|
||||
"countriesAllTitle": "Semua negara",
|
||||
"countriesScreenTitle": "Negara",
|
||||
"countriesSearchHint": "Negara atau kode...",
|
||||
"countriesYourLanguagesTitle": "Bahasa Anda",
|
||||
"customStationsAddCta": "Tambahkan stasiun khusus",
|
||||
"equalizerActiveOutputDefault": "Speaker perangkat ini",
|
||||
@@ -746,6 +753,7 @@
|
||||
"recordingRenameEmptyError": "Masukkan nama",
|
||||
"recordingRenameLabel": "Nama",
|
||||
"recordingsLibraryEmptySubtitle": "Rekaman yang Anda simpan akan muncul di sini.",
|
||||
"recordingsLibraryStorageFolderCaption": "Music/PluriWave · yang terlama dihapus saat mencapai batas",
|
||||
"recordingsLibraryEmptyTitle": "Belum ada rekaman",
|
||||
"recordingsLibrarySettingsTooltip": "Pengaturan rekaman",
|
||||
"recordingsLibraryStorageCaption": "{usedMb} MB dari {totalMb} MB terpakai",
|
||||
@@ -780,6 +788,7 @@
|
||||
"times": {}
|
||||
}
|
||||
},
|
||||
"vacationExplainerBanner": "Selama periode ini, alarm yang ditandai \"jeda saat liburan\" tidak akan berbunyi. Alarm yang ditandai \"selalu berbunyi\" tidak terpengaruh.",
|
||||
"vacationNoActiveRangeHint": "Tidak ada periode liburan aktif saat ini.",
|
||||
"vacationPastSectionTitle": "Periode lampau",
|
||||
"vacationRangesCount": "{count} periode",
|
||||
@@ -816,5 +825,7 @@
|
||||
"welcomeBullet3Title": "Alarm musik",
|
||||
"welcomeCtaLabel": "Mulai mendengarkan",
|
||||
"welcomeHeadline": "Duniamu, secara langsung",
|
||||
"yourStationsTitle": "Stasiun Anda"
|
||||
"yourStationsTitle": "Stasiun Anda",
|
||||
"nowListeningLabel": "Sedang mendengarkan",
|
||||
"popularNowTitle": "Populer sekarang"
|
||||
}
|
||||
|
||||
+12
-1
@@ -262,8 +262,11 @@
|
||||
"searchCountryFilterLabel": "Paese",
|
||||
"searchLanguageFilterLabel": "Lingua",
|
||||
"searchMinQualityFilterLabel": "Qualità minima",
|
||||
"searchLoadingStationsLabel": "RICERCA STAZIONI IN CORSO…",
|
||||
"searchEmptyTitle": "Cerca un'emittente",
|
||||
"searchNoResultsTitle": "Nessun risultato",
|
||||
"searchNoResultsForQueryTitle": "Nessun risultato per \"{query}\"",
|
||||
"@searchNoResultsForQueryTitle": {"placeholders": {"query": {}}},
|
||||
"searchEmptySubtitle": "Usa la barra in alto o i chip per scoprire emittenti da tutto il mondo.",
|
||||
"searchNoResultsSubtitle": "Prova a rimuovere i filtri o a digitare un altro nome per trovare un'emittente attiva.",
|
||||
"countrySpain": "Spagna",
|
||||
@@ -440,6 +443,7 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"alarmSnoozeUsualLabel": "abituale",
|
||||
"saveFavoritesAlarmHint": "Salva emittenti nei Preferiti per usarle come sveglia musicale.",
|
||||
"useCurrentStationAction": "Usa emittente attuale",
|
||||
"playDuringVacations": "Suona durante le vacanze",
|
||||
@@ -589,6 +593,8 @@
|
||||
"days": {}
|
||||
}
|
||||
},
|
||||
"alarmRepeatSectionLabel": "RIPETI",
|
||||
"alarmVolumeLabel": "Volume",
|
||||
"androidReliabilityTitle": "Controlla affidabilità Android",
|
||||
"closeAction": "Chiudi",
|
||||
"customOption": "Personalizzata",
|
||||
@@ -705,6 +711,7 @@
|
||||
"alarmVolumeRisingStatus": "Aumento del volume",
|
||||
"countriesAllTitle": "Tutti i paesi",
|
||||
"countriesScreenTitle": "Paesi",
|
||||
"countriesSearchHint": "Paese o codice...",
|
||||
"countriesYourLanguagesTitle": "Le tue lingue",
|
||||
"customStationsAddCta": "Aggiungi emittente personalizzata",
|
||||
"equalizerActiveOutputDefault": "L'altoparlante di questo dispositivo",
|
||||
@@ -746,6 +753,7 @@
|
||||
"recordingRenameEmptyError": "Inserisci un nome",
|
||||
"recordingRenameLabel": "Nome",
|
||||
"recordingsLibraryEmptySubtitle": "Le registrazioni che salvi appariranno qui.",
|
||||
"recordingsLibraryStorageFolderCaption": "Music/PluriWave · elimina le più vecchie al raggiungimento del limite",
|
||||
"recordingsLibraryEmptyTitle": "Ancora nessuna registrazione",
|
||||
"recordingsLibrarySettingsTooltip": "Impostazioni di registrazione",
|
||||
"recordingsLibraryStorageCaption": "{usedMb} MB di {totalMb} MB usati",
|
||||
@@ -780,6 +788,7 @@
|
||||
"times": {}
|
||||
}
|
||||
},
|
||||
"vacationExplainerBanner": "Durante questi periodi le sveglie contrassegnate come \"pausa in vacanza\" non suonano. Quelle contrassegnate come \"suona sempre\" non sono interessate.",
|
||||
"vacationNoActiveRangeHint": "Nessun periodo di vacanza attivo al momento.",
|
||||
"vacationPastSectionTitle": "Periodi passati",
|
||||
"vacationRangesCount": "{count} periodi",
|
||||
@@ -816,5 +825,7 @@
|
||||
"welcomeBullet3Title": "Sveglie musicali",
|
||||
"welcomeCtaLabel": "Inizia ad ascoltare",
|
||||
"welcomeHeadline": "Il tuo mondo, in diretta",
|
||||
"yourStationsTitle": "Le tue emittenti"
|
||||
"yourStationsTitle": "Le tue emittenti",
|
||||
"nowListeningLabel": "In ascolto ora",
|
||||
"popularNowTitle": "Popolari ora"
|
||||
}
|
||||
|
||||
+12
-1
@@ -262,8 +262,11 @@
|
||||
"searchCountryFilterLabel": "国",
|
||||
"searchLanguageFilterLabel": "言語",
|
||||
"searchMinQualityFilterLabel": "最低品質",
|
||||
"searchLoadingStationsLabel": "局を検索中…",
|
||||
"searchEmptyTitle": "局を検索",
|
||||
"searchNoResultsTitle": "結果がありません",
|
||||
"searchNoResultsForQueryTitle": "「{query}」の結果はありません",
|
||||
"@searchNoResultsForQueryTitle": {"placeholders": {"query": {}}},
|
||||
"searchEmptySubtitle": "上部バーやチップを使って、世界中の電波を見つけましょう。",
|
||||
"searchNoResultsSubtitle": "有効な電波を見つけるには、フィルターを外すか別の名前を入力してください。",
|
||||
"countrySpain": "スペイン",
|
||||
@@ -440,6 +443,7 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"alarmSnoozeUsualLabel": "通常",
|
||||
"saveFavoritesAlarmHint": "音楽アラームとして使うには、局をお気に入りに保存してください。",
|
||||
"useCurrentStationAction": "現在の局を使用",
|
||||
"playDuringVacations": "休暇中も鳴らす",
|
||||
@@ -589,6 +593,8 @@
|
||||
"days": {}
|
||||
}
|
||||
},
|
||||
"alarmRepeatSectionLabel": "繰り返し",
|
||||
"alarmVolumeLabel": "音量",
|
||||
"androidReliabilityTitle": "Androidの信頼性を確認",
|
||||
"closeAction": "閉じる",
|
||||
"customOption": "カスタム",
|
||||
@@ -705,6 +711,7 @@
|
||||
"alarmVolumeRisingStatus": "音量を上げています",
|
||||
"countriesAllTitle": "すべての国",
|
||||
"countriesScreenTitle": "国",
|
||||
"countriesSearchHint": "国名またはコード...",
|
||||
"countriesYourLanguagesTitle": "あなたの言語",
|
||||
"customStationsAddCta": "カスタム局を追加",
|
||||
"equalizerActiveOutputDefault": "この端末のスピーカー",
|
||||
@@ -746,6 +753,7 @@
|
||||
"recordingRenameEmptyError": "名前を入力してください",
|
||||
"recordingRenameLabel": "名前",
|
||||
"recordingsLibraryEmptySubtitle": "保存した録音はここに表示されます。",
|
||||
"recordingsLibraryStorageFolderCaption": "Music/PluriWave・上限に達すると古い順に削除されます",
|
||||
"recordingsLibraryEmptyTitle": "まだ録音がありません",
|
||||
"recordingsLibrarySettingsTooltip": "録音設定",
|
||||
"recordingsLibraryStorageCaption": "{totalMb} MB 中 {usedMb} MB 使用済み",
|
||||
@@ -780,6 +788,7 @@
|
||||
"times": {}
|
||||
}
|
||||
},
|
||||
"vacationExplainerBanner": "この期間中は「休暇中は一時停止」に設定したアラームは鳴りません。「常に鳴らす」に設定したアラームには影響しません。",
|
||||
"vacationNoActiveRangeHint": "現在アクティブな休暇期間はありません。",
|
||||
"vacationPastSectionTitle": "過去の期間",
|
||||
"vacationRangesCount": "{count} 件の期間",
|
||||
@@ -816,5 +825,7 @@
|
||||
"welcomeBullet3Title": "音楽アラーム",
|
||||
"welcomeCtaLabel": "聴き始める",
|
||||
"welcomeHeadline": "あなたの世界を、ライブで",
|
||||
"yourStationsTitle": "あなたの局"
|
||||
"yourStationsTitle": "あなたの局",
|
||||
"nowListeningLabel": "再生中",
|
||||
"popularNowTitle": "今人気"
|
||||
}
|
||||
|
||||
+12
-1
@@ -262,8 +262,11 @@
|
||||
"searchCountryFilterLabel": "País",
|
||||
"searchLanguageFilterLabel": "Idioma",
|
||||
"searchMinQualityFilterLabel": "Qualidade mínima",
|
||||
"searchLoadingStationsLabel": "PROCURANDO EMISSORAS…",
|
||||
"searchEmptyTitle": "Busque uma estação",
|
||||
"searchNoResultsTitle": "Sem resultados",
|
||||
"searchNoResultsForQueryTitle": "Nenhum resultado para \"{query}\"",
|
||||
"@searchNoResultsForQueryTitle": {"placeholders": {"query": {}}},
|
||||
"searchEmptySubtitle": "Use a barra superior ou os chips para descobrir estações do mundo todo.",
|
||||
"searchNoResultsSubtitle": "Tente remover filtros ou digitar outro nome para encontrar uma estação ativa.",
|
||||
"countrySpain": "Espanha",
|
||||
@@ -440,6 +443,7 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"alarmSnoozeUsualLabel": "usual",
|
||||
"saveFavoritesAlarmHint": "Salve estações nos Favoritos para usá-las como alarme musical.",
|
||||
"useCurrentStationAction": "Usar estação atual",
|
||||
"playDuringVacations": "Tocar durante as férias",
|
||||
@@ -589,6 +593,8 @@
|
||||
"days": {}
|
||||
}
|
||||
},
|
||||
"alarmRepeatSectionLabel": "REPETIÇÃO",
|
||||
"alarmVolumeLabel": "Volume",
|
||||
"androidReliabilityTitle": "Revisar confiabilidade Android",
|
||||
"closeAction": "Fechar",
|
||||
"customOption": "Personalizada",
|
||||
@@ -705,6 +711,7 @@
|
||||
"alarmVolumeRisingStatus": "Aumentando o volume",
|
||||
"countriesAllTitle": "Todos os países",
|
||||
"countriesScreenTitle": "Países",
|
||||
"countriesSearchHint": "País ou código...",
|
||||
"countriesYourLanguagesTitle": "Seus idiomas",
|
||||
"customStationsAddCta": "Adicionar estação personalizada",
|
||||
"equalizerActiveOutputDefault": "O alto-falante deste dispositivo",
|
||||
@@ -746,6 +753,7 @@
|
||||
"recordingRenameEmptyError": "Digite um nome",
|
||||
"recordingRenameLabel": "Nome",
|
||||
"recordingsLibraryEmptySubtitle": "As gravações que você salvar vão aparecer aqui.",
|
||||
"recordingsLibraryStorageFolderCaption": "Music/PluriWave · apaga as mais antigas ao atingir o limite",
|
||||
"recordingsLibraryEmptyTitle": "Ainda não há gravações",
|
||||
"recordingsLibrarySettingsTooltip": "Configurações de gravação",
|
||||
"recordingsLibraryStorageCaption": "{usedMb} MB de {totalMb} MB usados",
|
||||
@@ -780,6 +788,7 @@
|
||||
"times": {}
|
||||
}
|
||||
},
|
||||
"vacationExplainerBanner": "Durante esses periodos, os alarmes marcados como \"pausar nas ferias\" nao tocam. Os marcados como \"tocar sempre\" nao sao afetados.",
|
||||
"vacationNoActiveRangeHint": "Não há período de férias ativo no momento.",
|
||||
"vacationPastSectionTitle": "Períodos passados",
|
||||
"vacationRangesCount": "{count} períodos",
|
||||
@@ -816,5 +825,7 @@
|
||||
"welcomeBullet3Title": "Alarmes musicais",
|
||||
"welcomeCtaLabel": "Começar a ouvir",
|
||||
"welcomeHeadline": "Seu mundo, ao vivo",
|
||||
"yourStationsTitle": "Suas estações"
|
||||
"yourStationsTitle": "Suas estações",
|
||||
"nowListeningLabel": "Ouvindo agora",
|
||||
"popularNowTitle": "Populares agora"
|
||||
}
|
||||
|
||||
+12
-1
@@ -262,8 +262,11 @@
|
||||
"searchCountryFilterLabel": "Страна",
|
||||
"searchLanguageFilterLabel": "Язык",
|
||||
"searchMinQualityFilterLabel": "Минимальное качество",
|
||||
"searchLoadingStationsLabel": "ПОИСК СТАНЦИЙ…",
|
||||
"searchEmptyTitle": "Найдите станцию",
|
||||
"searchNoResultsTitle": "Нет результатов",
|
||||
"searchNoResultsForQueryTitle": "Нет результатов по запросу «{query}»",
|
||||
"@searchNoResultsForQueryTitle": {"placeholders": {"query": {}}},
|
||||
"searchEmptySubtitle": "Используйте верхнюю строку или чипы, чтобы находить сигналы со всего мира.",
|
||||
"searchNoResultsSubtitle": "Попробуйте убрать фильтры или ввести другое название, чтобы найти активный сигнал.",
|
||||
"countrySpain": "Испания",
|
||||
@@ -440,6 +443,7 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"alarmSnoozeUsualLabel": "обычно",
|
||||
"saveFavoritesAlarmHint": "Сохраните станции в избранное, чтобы использовать их как музыкальный будильник.",
|
||||
"useCurrentStationAction": "Использовать текущую станцию",
|
||||
"playDuringVacations": "Звонить во время отпусков",
|
||||
@@ -589,6 +593,8 @@
|
||||
"days": {}
|
||||
}
|
||||
},
|
||||
"alarmRepeatSectionLabel": "ПОВТОР",
|
||||
"alarmVolumeLabel": "Громкость",
|
||||
"androidReliabilityTitle": "Проверить надёжность Android",
|
||||
"closeAction": "Закрыть",
|
||||
"customOption": "Своя",
|
||||
@@ -705,6 +711,7 @@
|
||||
"alarmVolumeRisingStatus": "Громкость нарастает",
|
||||
"countriesAllTitle": "Все страны",
|
||||
"countriesScreenTitle": "Страны",
|
||||
"countriesSearchHint": "Страна или код...",
|
||||
"countriesYourLanguagesTitle": "Ваши языки",
|
||||
"customStationsAddCta": "Добавить пользовательскую станцию",
|
||||
"equalizerActiveOutputDefault": "Динамик этого устройства",
|
||||
@@ -746,6 +753,7 @@
|
||||
"recordingRenameEmptyError": "Введите название",
|
||||
"recordingRenameLabel": "Название",
|
||||
"recordingsLibraryEmptySubtitle": "Записи, которые вы сохраните, появятся здесь.",
|
||||
"recordingsLibraryStorageFolderCaption": "Music/PluriWave · при достижении лимита удаляются старые записи",
|
||||
"recordingsLibraryEmptyTitle": "Записей пока нет",
|
||||
"recordingsLibrarySettingsTooltip": "Настройки записи",
|
||||
"recordingsLibraryStorageCaption": "Использовано {usedMb} МБ из {totalMb} МБ",
|
||||
@@ -780,6 +788,7 @@
|
||||
"times": {}
|
||||
}
|
||||
},
|
||||
"vacationExplainerBanner": "В эти периоды будильники с пометкой «пауза на время отпуска» не звонят. Будильники с пометкой «звонить всегда» это не затрагивает.",
|
||||
"vacationNoActiveRangeHint": "Сейчас нет активного периода отпуска.",
|
||||
"vacationPastSectionTitle": "Прошедшие периоды",
|
||||
"vacationRangesCount": "{count} периодов",
|
||||
@@ -816,5 +825,7 @@
|
||||
"welcomeBullet3Title": "Музыкальные будильники",
|
||||
"welcomeCtaLabel": "Начать слушать",
|
||||
"welcomeHeadline": "Ваш мир, в прямом эфире",
|
||||
"yourStationsTitle": "Ваши станции"
|
||||
"yourStationsTitle": "Ваши станции",
|
||||
"nowListeningLabel": "Сейчас слушаете",
|
||||
"popularNowTitle": "Популярно сейчас"
|
||||
}
|
||||
|
||||
+12
-1
@@ -262,8 +262,11 @@
|
||||
"searchCountryFilterLabel": "国家/地区",
|
||||
"searchLanguageFilterLabel": "语言",
|
||||
"searchMinQualityFilterLabel": "最低质量",
|
||||
"searchLoadingStationsLabel": "正在搜索电台…",
|
||||
"searchEmptyTitle": "搜索电台",
|
||||
"searchNoResultsTitle": "没有结果",
|
||||
"searchNoResultsForQueryTitle": "未找到与\"{query}\"相关的结果",
|
||||
"@searchNoResultsForQueryTitle": {"placeholders": {"query": {}}},
|
||||
"searchEmptySubtitle": "使用顶部搜索栏或筛选标签,发现世界各地的电台信号。",
|
||||
"searchNoResultsSubtitle": "尝试减少筛选条件,或换个名称搜索,找到正在播出的电台。",
|
||||
"countrySpain": "西班牙",
|
||||
@@ -440,6 +443,7 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"alarmSnoozeUsualLabel": "常用",
|
||||
"saveFavoritesAlarmHint": "将电台保存到收藏,即可把它们用作音乐闹钟。",
|
||||
"useCurrentStationAction": "使用当前电台",
|
||||
"playDuringVacations": "假期期间响铃",
|
||||
@@ -589,6 +593,8 @@
|
||||
"days": {}
|
||||
}
|
||||
},
|
||||
"alarmRepeatSectionLabel": "重复",
|
||||
"alarmVolumeLabel": "音量",
|
||||
"androidReliabilityTitle": "检查 Android 可靠性",
|
||||
"closeAction": "关闭",
|
||||
"customOption": "自定义",
|
||||
@@ -705,6 +711,7 @@
|
||||
"alarmVolumeRisingStatus": "音量渐强中",
|
||||
"countriesAllTitle": "所有国家",
|
||||
"countriesScreenTitle": "国家",
|
||||
"countriesSearchHint": "国家或代码...",
|
||||
"countriesYourLanguagesTitle": "你的语言",
|
||||
"customStationsAddCta": "添加自定义电台",
|
||||
"equalizerActiveOutputDefault": "此设备的扬声器",
|
||||
@@ -746,6 +753,7 @@
|
||||
"recordingRenameEmptyError": "请输入名称",
|
||||
"recordingRenameLabel": "名称",
|
||||
"recordingsLibraryEmptySubtitle": "你保存的录音会显示在这里。",
|
||||
"recordingsLibraryStorageFolderCaption": "Music/PluriWave · 达到上限时会删除最旧的文件",
|
||||
"recordingsLibraryEmptyTitle": "暂无录音",
|
||||
"recordingsLibrarySettingsTooltip": "录音设置",
|
||||
"recordingsLibraryStorageCaption": "已使用 {usedMb} MB / 共 {totalMb} MB",
|
||||
@@ -780,6 +788,7 @@
|
||||
"times": {}
|
||||
}
|
||||
},
|
||||
"vacationExplainerBanner": "在这些时间段内,标记为“假期暂停”的闹钟不会响铃;标记为“始终响铃”的闹钟不受影响。",
|
||||
"vacationNoActiveRangeHint": "目前没有正在生效的假期时间段。",
|
||||
"vacationPastSectionTitle": "过往时间段",
|
||||
"vacationRangesCount": "{count} 个时间段",
|
||||
@@ -816,5 +825,7 @@
|
||||
"welcomeBullet3Title": "音乐闹钟",
|
||||
"welcomeCtaLabel": "开始收听",
|
||||
"welcomeHeadline": "你的世界,直播中",
|
||||
"yourStationsTitle": "你的电台"
|
||||
"yourStationsTitle": "你的电台",
|
||||
"nowListeningLabel": "正在收听",
|
||||
"popularNowTitle": "当前热门"
|
||||
}
|
||||
|
||||
@@ -22,3 +22,21 @@ String diaMesLocalizado(String localeTag, DateTime fecha) =>
|
||||
/// audit 9b.4 (t4:459), the small caption under the day-month.
|
||||
String nombreDiaSemanaLocalizado(String localeTag, DateTime fecha) =>
|
||||
DateFormat.EEEE(localeTag).format(fecha);
|
||||
|
||||
/// Full weekday + month + day, locale-aware (e.g. "lunes, 3 de agosto" for
|
||||
/// `es`, "Monday, August 3" for `en`) — audit 9.4 (t4:419), the ringing
|
||||
/// screen's date line between the schedule pill and the hero time.
|
||||
String fechaLargaConDiaSemana(String localeTag, DateTime fecha) =>
|
||||
DateFormat.MMMMEEEEd(localeTag).format(fecha);
|
||||
|
||||
/// Short "d–d MON" range pill, locale-aware month abbreviation, uppercased
|
||||
/// (e.g. "4–18 AGO" for `es`, "4–18 AUG" for `en`) — audit 7.3 (t4:332),
|
||||
/// the vacation-row date-range pill on the Alarmas root. Always labels the
|
||||
/// range with the END date's month: vacation ranges are short (days to a
|
||||
/// couple of weeks), so a cross-month span is the rare case, and the
|
||||
/// prototype itself only ever shows a single abbreviation.
|
||||
String rangoFechasCorto(String localeTag, DateTime inicio, DateTime fin) {
|
||||
final dia = DateFormat.d(localeTag);
|
||||
final mes = DateFormat.MMM(localeTag).format(fin).toUpperCase();
|
||||
return '${dia.format(inicio)}–${dia.format(fin)} $mes';
|
||||
}
|
||||
|
||||
@@ -880,6 +880,12 @@ abstract class AppLocalizations {
|
||||
/// **'{usedMb} MB de {totalMb} MB usados'**
|
||||
String recordingsLibraryStorageCaption(int usedMb, int totalMb);
|
||||
|
||||
/// No description provided for @recordingsLibraryStorageFolderCaption.
|
||||
///
|
||||
/// In es, this message translates to:
|
||||
/// **'Music/PluriWave · se borran las más antiguas al llegar al límite'**
|
||||
String get recordingsLibraryStorageFolderCaption;
|
||||
|
||||
/// No description provided for @recordingsLibraryEmptyTitle.
|
||||
///
|
||||
/// In es, this message translates to:
|
||||
@@ -1186,6 +1192,12 @@ abstract class AppLocalizations {
|
||||
/// **'Calidad mínima'**
|
||||
String get searchMinQualityFilterLabel;
|
||||
|
||||
/// No description provided for @searchLoadingStationsLabel.
|
||||
///
|
||||
/// In es, this message translates to:
|
||||
/// **'BUSCANDO EMISORAS…'**
|
||||
String get searchLoadingStationsLabel;
|
||||
|
||||
/// No description provided for @searchEmptyTitle.
|
||||
///
|
||||
/// In es, this message translates to:
|
||||
@@ -1198,6 +1210,12 @@ abstract class AppLocalizations {
|
||||
/// **'Sin resultados'**
|
||||
String get searchNoResultsTitle;
|
||||
|
||||
/// No description provided for @searchNoResultsForQueryTitle.
|
||||
///
|
||||
/// In es, this message translates to:
|
||||
/// **'Sin resultados para «{query}»'**
|
||||
String searchNoResultsForQueryTitle(Object query);
|
||||
|
||||
/// No description provided for @searchEmptySubtitle.
|
||||
///
|
||||
/// In es, this message translates to:
|
||||
@@ -1228,6 +1246,12 @@ abstract class AppLocalizations {
|
||||
/// **'Países'**
|
||||
String get countriesScreenTitle;
|
||||
|
||||
/// No description provided for @countriesSearchHint.
|
||||
///
|
||||
/// In es, this message translates to:
|
||||
/// **'País o código...'**
|
||||
String get countriesSearchHint;
|
||||
|
||||
/// No description provided for @countriesYourLanguagesTitle.
|
||||
///
|
||||
/// In es, this message translates to:
|
||||
@@ -1402,6 +1426,12 @@ abstract class AppLocalizations {
|
||||
/// **'Abrir reproductor completo'**
|
||||
String get openFullPlayerTooltip;
|
||||
|
||||
/// No description provided for @nowListeningLabel.
|
||||
///
|
||||
/// In es, this message translates to:
|
||||
/// **'Escuchando ahora'**
|
||||
String get nowListeningLabel;
|
||||
|
||||
/// No description provided for @nothingPlayingTitle.
|
||||
///
|
||||
/// In es, this message translates to:
|
||||
@@ -1474,6 +1504,12 @@ abstract class AppLocalizations {
|
||||
/// **'Esta semana'**
|
||||
String get exploreNewSubtitle;
|
||||
|
||||
/// No description provided for @popularNowTitle.
|
||||
///
|
||||
/// In es, this message translates to:
|
||||
/// **'Populares ahora'**
|
||||
String get popularNowTitle;
|
||||
|
||||
/// No description provided for @offlineBannerTitle.
|
||||
///
|
||||
/// In es, this message translates to:
|
||||
@@ -1870,6 +1906,12 @@ abstract class AppLocalizations {
|
||||
/// **'{minutes} min'**
|
||||
String alarmSnoozeOptionLabel(int minutes);
|
||||
|
||||
/// No description provided for @alarmSnoozeUsualLabel.
|
||||
///
|
||||
/// In es, this message translates to:
|
||||
/// **'habitual'**
|
||||
String get alarmSnoozeUsualLabel;
|
||||
|
||||
/// No description provided for @saveFavoritesAlarmHint.
|
||||
///
|
||||
/// In es, this message translates to:
|
||||
@@ -2012,6 +2054,12 @@ abstract class AppLocalizations {
|
||||
/// **'Añadir rango'**
|
||||
String get addVacationRangeCta;
|
||||
|
||||
/// No description provided for @vacationExplainerBanner.
|
||||
///
|
||||
/// In es, this message translates to:
|
||||
/// **'Durante estos rangos no suenan las alarmas marcadas como \"pausar en vacaciones\". Las marcadas como \"sonar siempre\" no se ven afectadas.'**
|
||||
String get vacationExplainerBanner;
|
||||
|
||||
/// No description provided for @vacationNoActiveRangeHint.
|
||||
///
|
||||
/// In es, this message translates to:
|
||||
@@ -2450,6 +2498,18 @@ abstract class AppLocalizations {
|
||||
/// **'Días: {days}'**
|
||||
String alarmScheduleWeekdays(Object days);
|
||||
|
||||
/// No description provided for @alarmRepeatSectionLabel.
|
||||
///
|
||||
/// In es, this message translates to:
|
||||
/// **'REPETIR'**
|
||||
String get alarmRepeatSectionLabel;
|
||||
|
||||
/// No description provided for @alarmVolumeLabel.
|
||||
///
|
||||
/// In es, this message translates to:
|
||||
/// **'Volumen'**
|
||||
String get alarmVolumeLabel;
|
||||
|
||||
/// No description provided for @androidReliabilityTitle.
|
||||
///
|
||||
/// In es, this message translates to:
|
||||
|
||||
@@ -441,6 +441,10 @@ class AppLocalizationsAr extends AppLocalizations {
|
||||
return 'تم استخدام $usedMb ميغابايت من أصل $totalMb ميغابايت';
|
||||
}
|
||||
|
||||
@override
|
||||
String get recordingsLibraryStorageFolderCaption =>
|
||||
'Music/PluriWave · يتم حذف الأقدم عند بلوغ الحد';
|
||||
|
||||
@override
|
||||
String get recordingsLibraryEmptyTitle => 'لا توجد تسجيلات بعد';
|
||||
|
||||
@@ -612,12 +616,20 @@ class AppLocalizationsAr extends AppLocalizations {
|
||||
@override
|
||||
String get searchMinQualityFilterLabel => 'الحد الأدنى للجودة';
|
||||
|
||||
@override
|
||||
String get searchLoadingStationsLabel => 'جارٍ البحث عن محطات…';
|
||||
|
||||
@override
|
||||
String get searchEmptyTitle => 'ابحث عن محطة';
|
||||
|
||||
@override
|
||||
String get searchNoResultsTitle => 'لا توجد نتائج';
|
||||
|
||||
@override
|
||||
String searchNoResultsForQueryTitle(Object query) {
|
||||
return 'لا توجد نتائج لـ «$query»';
|
||||
}
|
||||
|
||||
@override
|
||||
String get searchEmptySubtitle =>
|
||||
'استخدم الشريط العلوي أو الشرائح لاكتشاف إشارات من كل العالم.';
|
||||
@@ -655,6 +667,9 @@ class AppLocalizationsAr extends AppLocalizations {
|
||||
@override
|
||||
String get countriesScreenTitle => 'الدول';
|
||||
|
||||
@override
|
||||
String get countriesSearchHint => 'الدولة أو الرمز...';
|
||||
|
||||
@override
|
||||
String get countriesYourLanguagesTitle => 'لغاتك';
|
||||
|
||||
@@ -745,6 +760,9 @@ class AppLocalizationsAr extends AppLocalizations {
|
||||
@override
|
||||
String get openFullPlayerTooltip => 'فتح المشغل الكامل';
|
||||
|
||||
@override
|
||||
String get nowListeningLabel => 'الاستماع الآن';
|
||||
|
||||
@override
|
||||
String get nothingPlayingTitle => 'لا تستمع إلى شيء بعد';
|
||||
|
||||
@@ -784,6 +802,9 @@ class AppLocalizationsAr extends AppLocalizations {
|
||||
@override
|
||||
String get exploreNewSubtitle => 'هذا الأسبوع';
|
||||
|
||||
@override
|
||||
String get popularNowTitle => 'الأكثر شيوعًا الآن';
|
||||
|
||||
@override
|
||||
String get offlineBannerTitle => 'لا يوجد اتصال';
|
||||
|
||||
@@ -1008,6 +1029,9 @@ class AppLocalizationsAr extends AppLocalizations {
|
||||
return '$minutes د';
|
||||
}
|
||||
|
||||
@override
|
||||
String get alarmSnoozeUsualLabel => 'المعتاد';
|
||||
|
||||
@override
|
||||
String get saveFavoritesAlarmHint =>
|
||||
'احفظ محطات في المفضلة لاستخدامها كمنبه موسيقي.';
|
||||
@@ -1096,6 +1120,10 @@ class AppLocalizationsAr extends AppLocalizations {
|
||||
@override
|
||||
String get addVacationRangeCta => 'إضافة فترة';
|
||||
|
||||
@override
|
||||
String get vacationExplainerBanner =>
|
||||
'خلال هذه الفترات، لن تُصدر المنبهات المُعلَّمة بـ «إيقاف أثناء الإجازة» صوتًا. أما المُعلَّمة بـ «تشغيل دائمًا» فلن تتأثر.';
|
||||
|
||||
@override
|
||||
String get vacationNoActiveRangeHint => 'لا توجد فترة إجازة نشطة الآن.';
|
||||
|
||||
@@ -1345,6 +1373,12 @@ class AppLocalizationsAr extends AppLocalizations {
|
||||
return 'الأيام: $days';
|
||||
}
|
||||
|
||||
@override
|
||||
String get alarmRepeatSectionLabel => 'تكرار';
|
||||
|
||||
@override
|
||||
String get alarmVolumeLabel => 'مستوى الصوت';
|
||||
|
||||
@override
|
||||
String get androidReliabilityTitle => 'مراجعة موثوقية Android';
|
||||
|
||||
|
||||
@@ -446,6 +446,10 @@ class AppLocalizationsBn extends AppLocalizations {
|
||||
return '$totalMb MB-এর মধ্যে $usedMb MB ব্যবহৃত';
|
||||
}
|
||||
|
||||
@override
|
||||
String get recordingsLibraryStorageFolderCaption =>
|
||||
'Music/PluriWave · সীমা পৌঁছালে সবচেয়ে পুরনোগুলো মুছে যায়';
|
||||
|
||||
@override
|
||||
String get recordingsLibraryEmptyTitle => 'এখনও কোনো রেকর্ডিং নেই';
|
||||
|
||||
@@ -619,12 +623,20 @@ class AppLocalizationsBn extends AppLocalizations {
|
||||
@override
|
||||
String get searchMinQualityFilterLabel => 'ন্যূনতম গুণমান';
|
||||
|
||||
@override
|
||||
String get searchLoadingStationsLabel => 'স্টেশন খোঁজা হচ্ছে…';
|
||||
|
||||
@override
|
||||
String get searchEmptyTitle => 'একটি স্টেশন খুঁজুন';
|
||||
|
||||
@override
|
||||
String get searchNoResultsTitle => 'কোনো ফলাফল নেই';
|
||||
|
||||
@override
|
||||
String searchNoResultsForQueryTitle(Object query) {
|
||||
return '\"$query\"-এর জন্য কোনো ফলাফল নেই';
|
||||
}
|
||||
|
||||
@override
|
||||
String get searchEmptySubtitle =>
|
||||
'উপরের বার বা চিপ ব্যবহার করে সারা বিশ্বের সিগন্যাল আবিষ্কার করুন।';
|
||||
@@ -658,6 +670,9 @@ class AppLocalizationsBn extends AppLocalizations {
|
||||
@override
|
||||
String get countriesScreenTitle => 'দেশসমূহ';
|
||||
|
||||
@override
|
||||
String get countriesSearchHint => 'দেশ বা কোড...';
|
||||
|
||||
@override
|
||||
String get countriesYourLanguagesTitle => 'আপনার ভাষাসমূহ';
|
||||
|
||||
@@ -748,6 +763,9 @@ class AppLocalizationsBn extends AppLocalizations {
|
||||
@override
|
||||
String get openFullPlayerTooltip => 'সম্পূর্ণ প্লেয়ার খুলুন';
|
||||
|
||||
@override
|
||||
String get nowListeningLabel => 'এখন শোনা হচ্ছে';
|
||||
|
||||
@override
|
||||
String get nothingPlayingTitle => 'এখনও কিছু চলছে না';
|
||||
|
||||
@@ -787,6 +805,9 @@ class AppLocalizationsBn extends AppLocalizations {
|
||||
@override
|
||||
String get exploreNewSubtitle => 'এই সপ্তাহে';
|
||||
|
||||
@override
|
||||
String get popularNowTitle => 'এখন জনপ্রিয়';
|
||||
|
||||
@override
|
||||
String get offlineBannerTitle => 'সংযোগ নেই';
|
||||
|
||||
@@ -1013,6 +1034,9 @@ class AppLocalizationsBn extends AppLocalizations {
|
||||
return '$minutes মিনিট';
|
||||
}
|
||||
|
||||
@override
|
||||
String get alarmSnoozeUsualLabel => 'সাধারণ';
|
||||
|
||||
@override
|
||||
String get saveFavoritesAlarmHint =>
|
||||
'সুরেলা অ্যালার্ম হিসেবে ব্যবহার করতে প্রিয়তে স্টেশন সংরক্ষণ করুন।';
|
||||
@@ -1102,6 +1126,10 @@ class AppLocalizationsBn extends AppLocalizations {
|
||||
@override
|
||||
String get addVacationRangeCta => 'সময়সীমা যোগ করুন';
|
||||
|
||||
@override
|
||||
String get vacationExplainerBanner =>
|
||||
'এই সময়কালে \"ছুটিতে বিরতি\" হিসেবে চিহ্নিত অ্যালার্মগুলো বাজবে না। \"সবসময় বাজবে\" হিসেবে চিহ্নিত অ্যালার্মগুলো প্রভাবিত হবে না।';
|
||||
|
||||
@override
|
||||
String get vacationNoActiveRangeHint =>
|
||||
'এই মুহূর্তে কোনো সক্রিয় ছুটির সময়সীমা নেই।';
|
||||
@@ -1354,6 +1382,12 @@ class AppLocalizationsBn extends AppLocalizations {
|
||||
return 'দিন: $days';
|
||||
}
|
||||
|
||||
@override
|
||||
String get alarmRepeatSectionLabel => 'পুনরাবৃত্তি';
|
||||
|
||||
@override
|
||||
String get alarmVolumeLabel => 'ভলিউম';
|
||||
|
||||
@override
|
||||
String get androidReliabilityTitle => 'Android নির্ভরযোগ্যতা দেখুন';
|
||||
|
||||
|
||||
@@ -449,6 +449,10 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
return '$usedMb MB von $totalMb MB belegt';
|
||||
}
|
||||
|
||||
@override
|
||||
String get recordingsLibraryStorageFolderCaption =>
|
||||
'Music/PluriWave · älteste werden bei Erreichen des Limits gelöscht';
|
||||
|
||||
@override
|
||||
String get recordingsLibraryEmptyTitle => 'Noch keine Aufnahmen';
|
||||
|
||||
@@ -622,12 +626,20 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
@override
|
||||
String get searchMinQualityFilterLabel => 'Mindestqualität';
|
||||
|
||||
@override
|
||||
String get searchLoadingStationsLabel => 'SENDER WERDEN GESUCHT…';
|
||||
|
||||
@override
|
||||
String get searchEmptyTitle => 'Suche nach einem Sender';
|
||||
|
||||
@override
|
||||
String get searchNoResultsTitle => 'Keine Ergebnisse';
|
||||
|
||||
@override
|
||||
String searchNoResultsForQueryTitle(Object query) {
|
||||
return 'Keine Ergebnisse für „$query“';
|
||||
}
|
||||
|
||||
@override
|
||||
String get searchEmptySubtitle =>
|
||||
'Nutze die obere Leiste oder die Chips, um Sender aus aller Welt zu entdecken.';
|
||||
@@ -661,6 +673,9 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
@override
|
||||
String get countriesScreenTitle => 'Länder';
|
||||
|
||||
@override
|
||||
String get countriesSearchHint => 'Land oder Code...';
|
||||
|
||||
@override
|
||||
String get countriesYourLanguagesTitle => 'Deine Sprachen';
|
||||
|
||||
@@ -751,6 +766,9 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
@override
|
||||
String get openFullPlayerTooltip => 'Vollständigen Player öffnen';
|
||||
|
||||
@override
|
||||
String get nowListeningLabel => 'Läuft gerade';
|
||||
|
||||
@override
|
||||
String get nothingPlayingTitle => 'Es läuft noch nichts';
|
||||
|
||||
@@ -790,6 +808,9 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
@override
|
||||
String get exploreNewSubtitle => 'Diese Woche';
|
||||
|
||||
@override
|
||||
String get popularNowTitle => 'Jetzt beliebt';
|
||||
|
||||
@override
|
||||
String get offlineBannerTitle => 'Keine Verbindung';
|
||||
|
||||
@@ -1016,6 +1037,9 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
return '$minutes Min.';
|
||||
}
|
||||
|
||||
@override
|
||||
String get alarmSnoozeUsualLabel => 'üblich';
|
||||
|
||||
@override
|
||||
String get saveFavoritesAlarmHint =>
|
||||
'Speichere Sender in Favoriten, um sie als musikalischen Alarm zu verwenden.';
|
||||
@@ -1104,6 +1128,10 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
@override
|
||||
String get addVacationRangeCta => 'Zeitraum hinzufügen';
|
||||
|
||||
@override
|
||||
String get vacationExplainerBanner =>
|
||||
'Während dieser Zeiträume klingeln Alarme mit der Markierung \"im Urlaub pausieren\" nicht. Als \"immer klingeln\" markierte Alarme sind davon nicht betroffen.';
|
||||
|
||||
@override
|
||||
String get vacationNoActiveRangeHint =>
|
||||
'Momentan ist kein aktiver Urlaubszeitraum vorhanden.';
|
||||
@@ -1362,6 +1390,12 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
return 'Tage: $days';
|
||||
}
|
||||
|
||||
@override
|
||||
String get alarmRepeatSectionLabel => 'WIEDERHOLEN';
|
||||
|
||||
@override
|
||||
String get alarmVolumeLabel => 'Lautstärke';
|
||||
|
||||
@override
|
||||
String get androidReliabilityTitle => 'Android-Zuverlässigkeit prüfen';
|
||||
|
||||
|
||||
@@ -443,6 +443,10 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
return '$usedMb MB of $totalMb MB used';
|
||||
}
|
||||
|
||||
@override
|
||||
String get recordingsLibraryStorageFolderCaption =>
|
||||
'Music/PluriWave · purges oldest at limit';
|
||||
|
||||
@override
|
||||
String get recordingsLibraryEmptyTitle => 'No recordings yet';
|
||||
|
||||
@@ -615,12 +619,20 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
@override
|
||||
String get searchMinQualityFilterLabel => 'Minimum quality';
|
||||
|
||||
@override
|
||||
String get searchLoadingStationsLabel => 'SEARCHING FOR STATIONS…';
|
||||
|
||||
@override
|
||||
String get searchEmptyTitle => 'Search for a station';
|
||||
|
||||
@override
|
||||
String get searchNoResultsTitle => 'No results';
|
||||
|
||||
@override
|
||||
String searchNoResultsForQueryTitle(Object query) {
|
||||
return 'No results for \"$query\"';
|
||||
}
|
||||
|
||||
@override
|
||||
String get searchEmptySubtitle =>
|
||||
'Use the top bar or chips to discover stations from around the world.';
|
||||
@@ -654,6 +666,9 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
@override
|
||||
String get countriesScreenTitle => 'Countries';
|
||||
|
||||
@override
|
||||
String get countriesSearchHint => 'Country or code...';
|
||||
|
||||
@override
|
||||
String get countriesYourLanguagesTitle => 'Your languages';
|
||||
|
||||
@@ -744,6 +759,9 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
@override
|
||||
String get openFullPlayerTooltip => 'Open full player';
|
||||
|
||||
@override
|
||||
String get nowListeningLabel => 'Now listening';
|
||||
|
||||
@override
|
||||
String get nothingPlayingTitle => 'Nothing playing yet';
|
||||
|
||||
@@ -783,6 +801,9 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
@override
|
||||
String get exploreNewSubtitle => 'This week';
|
||||
|
||||
@override
|
||||
String get popularNowTitle => 'Popular now';
|
||||
|
||||
@override
|
||||
String get offlineBannerTitle => 'No connection';
|
||||
|
||||
@@ -1008,6 +1029,9 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
return '$minutes min';
|
||||
}
|
||||
|
||||
@override
|
||||
String get alarmSnoozeUsualLabel => 'usual';
|
||||
|
||||
@override
|
||||
String get saveFavoritesAlarmHint =>
|
||||
'Save stations in Favorites to use them as a music alarm.';
|
||||
@@ -1096,6 +1120,10 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
@override
|
||||
String get addVacationRangeCta => 'Add range';
|
||||
|
||||
@override
|
||||
String get vacationExplainerBanner =>
|
||||
'During these ranges, alarms marked \"pause during vacations\" won\'t ring. Alarms marked \"always ring\" are not affected.';
|
||||
|
||||
@override
|
||||
String get vacationNoActiveRangeHint => 'No active vacation range right now.';
|
||||
|
||||
@@ -1346,6 +1374,12 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
return 'Days: $days';
|
||||
}
|
||||
|
||||
@override
|
||||
String get alarmRepeatSectionLabel => 'REPEAT';
|
||||
|
||||
@override
|
||||
String get alarmVolumeLabel => 'Volume';
|
||||
|
||||
@override
|
||||
String get androidReliabilityTitle => 'Review Android reliability';
|
||||
|
||||
|
||||
@@ -447,6 +447,10 @@ class AppLocalizationsEs extends AppLocalizations {
|
||||
return '$usedMb MB de $totalMb MB usados';
|
||||
}
|
||||
|
||||
@override
|
||||
String get recordingsLibraryStorageFolderCaption =>
|
||||
'Music/PluriWave · se borran las más antiguas al llegar al límite';
|
||||
|
||||
@override
|
||||
String get recordingsLibraryEmptyTitle => 'Todavía no hay grabaciones';
|
||||
|
||||
@@ -620,12 +624,20 @@ class AppLocalizationsEs extends AppLocalizations {
|
||||
@override
|
||||
String get searchMinQualityFilterLabel => 'Calidad mínima';
|
||||
|
||||
@override
|
||||
String get searchLoadingStationsLabel => 'BUSCANDO EMISORAS…';
|
||||
|
||||
@override
|
||||
String get searchEmptyTitle => 'Buscá una emisora';
|
||||
|
||||
@override
|
||||
String get searchNoResultsTitle => 'Sin resultados';
|
||||
|
||||
@override
|
||||
String searchNoResultsForQueryTitle(Object query) {
|
||||
return 'Sin resultados para «$query»';
|
||||
}
|
||||
|
||||
@override
|
||||
String get searchEmptySubtitle =>
|
||||
'Usá la barra superior o los chips para descubrir señales de todo el mundo.';
|
||||
@@ -659,6 +671,9 @@ class AppLocalizationsEs extends AppLocalizations {
|
||||
@override
|
||||
String get countriesScreenTitle => 'Países';
|
||||
|
||||
@override
|
||||
String get countriesSearchHint => 'País o código...';
|
||||
|
||||
@override
|
||||
String get countriesYourLanguagesTitle => 'Tus idiomas';
|
||||
|
||||
@@ -749,6 +764,9 @@ class AppLocalizationsEs extends AppLocalizations {
|
||||
@override
|
||||
String get openFullPlayerTooltip => 'Abrir reproductor completo';
|
||||
|
||||
@override
|
||||
String get nowListeningLabel => 'Escuchando ahora';
|
||||
|
||||
@override
|
||||
String get nothingPlayingTitle => 'Todavía no estás escuchando nada';
|
||||
|
||||
@@ -788,6 +806,9 @@ class AppLocalizationsEs extends AppLocalizations {
|
||||
@override
|
||||
String get exploreNewSubtitle => 'Esta semana';
|
||||
|
||||
@override
|
||||
String get popularNowTitle => 'Populares ahora';
|
||||
|
||||
@override
|
||||
String get offlineBannerTitle => 'Sin conexión';
|
||||
|
||||
@@ -1013,6 +1034,9 @@ class AppLocalizationsEs extends AppLocalizations {
|
||||
return '$minutes min';
|
||||
}
|
||||
|
||||
@override
|
||||
String get alarmSnoozeUsualLabel => 'habitual';
|
||||
|
||||
@override
|
||||
String get saveFavoritesAlarmHint =>
|
||||
'Guardá emisoras en Favoritos para usarlas como alarma musical.';
|
||||
@@ -1101,6 +1125,10 @@ class AppLocalizationsEs extends AppLocalizations {
|
||||
@override
|
||||
String get addVacationRangeCta => 'Añadir rango';
|
||||
|
||||
@override
|
||||
String get vacationExplainerBanner =>
|
||||
'Durante estos rangos no suenan las alarmas marcadas como \"pausar en vacaciones\". Las marcadas como \"sonar siempre\" no se ven afectadas.';
|
||||
|
||||
@override
|
||||
String get vacationNoActiveRangeHint =>
|
||||
'No hay un rango de vacaciones activo ahora mismo.';
|
||||
@@ -1356,6 +1384,12 @@ class AppLocalizationsEs extends AppLocalizations {
|
||||
return 'Días: $days';
|
||||
}
|
||||
|
||||
@override
|
||||
String get alarmRepeatSectionLabel => 'REPETIR';
|
||||
|
||||
@override
|
||||
String get alarmVolumeLabel => 'Volumen';
|
||||
|
||||
@override
|
||||
String get androidReliabilityTitle => 'Revisar fiabilidad Android';
|
||||
|
||||
|
||||
@@ -451,6 +451,10 @@ class AppLocalizationsFr extends AppLocalizations {
|
||||
return '$usedMb Mo sur $totalMb Mo utilisés';
|
||||
}
|
||||
|
||||
@override
|
||||
String get recordingsLibraryStorageFolderCaption =>
|
||||
'Music/PluriWave · supprime les plus anciens à la limite';
|
||||
|
||||
@override
|
||||
String get recordingsLibraryEmptyTitle =>
|
||||
'Aucun enregistrement pour l\'instant';
|
||||
@@ -624,12 +628,20 @@ class AppLocalizationsFr extends AppLocalizations {
|
||||
@override
|
||||
String get searchMinQualityFilterLabel => 'Qualité minimale';
|
||||
|
||||
@override
|
||||
String get searchLoadingStationsLabel => 'RECHERCHE DE STATIONS…';
|
||||
|
||||
@override
|
||||
String get searchEmptyTitle => 'Recherchez une station';
|
||||
|
||||
@override
|
||||
String get searchNoResultsTitle => 'Aucun résultat';
|
||||
|
||||
@override
|
||||
String searchNoResultsForQueryTitle(Object query) {
|
||||
return 'Aucun résultat pour « $query »';
|
||||
}
|
||||
|
||||
@override
|
||||
String get searchEmptySubtitle =>
|
||||
'Utilisez la barre du haut ou les pastilles pour découvrir des stations du monde entier.';
|
||||
@@ -663,6 +675,9 @@ class AppLocalizationsFr extends AppLocalizations {
|
||||
@override
|
||||
String get countriesScreenTitle => 'Pays';
|
||||
|
||||
@override
|
||||
String get countriesSearchHint => 'Pays ou code...';
|
||||
|
||||
@override
|
||||
String get countriesYourLanguagesTitle => 'Vos langues';
|
||||
|
||||
@@ -753,6 +768,9 @@ class AppLocalizationsFr extends AppLocalizations {
|
||||
@override
|
||||
String get openFullPlayerTooltip => 'Ouvrir le lecteur complet';
|
||||
|
||||
@override
|
||||
String get nowListeningLabel => 'En cours d\'écoute';
|
||||
|
||||
@override
|
||||
String get nothingPlayingTitle => 'Rien n\'est en cours de lecture';
|
||||
|
||||
@@ -792,6 +810,9 @@ class AppLocalizationsFr extends AppLocalizations {
|
||||
@override
|
||||
String get exploreNewSubtitle => 'Cette semaine';
|
||||
|
||||
@override
|
||||
String get popularNowTitle => 'Populaire maintenant';
|
||||
|
||||
@override
|
||||
String get offlineBannerTitle => 'Aucune connexion';
|
||||
|
||||
@@ -1018,6 +1039,9 @@ class AppLocalizationsFr extends AppLocalizations {
|
||||
return '$minutes min';
|
||||
}
|
||||
|
||||
@override
|
||||
String get alarmSnoozeUsualLabel => 'habituel';
|
||||
|
||||
@override
|
||||
String get saveFavoritesAlarmHint =>
|
||||
'Enregistrez des stations dans les Favoris pour les utiliser comme alarme musicale.';
|
||||
@@ -1107,6 +1131,10 @@ class AppLocalizationsFr extends AppLocalizations {
|
||||
@override
|
||||
String get addVacationRangeCta => 'Ajouter une période';
|
||||
|
||||
@override
|
||||
String get vacationExplainerBanner =>
|
||||
'Pendant ces periodes, les alarmes marquees \"pause pendant les vacances\" ne sonnent pas. Celles marquees \"toujours sonner\" ne sont pas concernees.';
|
||||
|
||||
@override
|
||||
String get vacationNoActiveRangeHint =>
|
||||
'Aucune période de vacances active pour le moment.';
|
||||
@@ -1366,6 +1394,12 @@ class AppLocalizationsFr extends AppLocalizations {
|
||||
return 'Jours : $days';
|
||||
}
|
||||
|
||||
@override
|
||||
String get alarmRepeatSectionLabel => 'RÉPÉTER';
|
||||
|
||||
@override
|
||||
String get alarmVolumeLabel => 'Volume';
|
||||
|
||||
@override
|
||||
String get androidReliabilityTitle => 'Vérifier la fiabilité Android';
|
||||
|
||||
|
||||
@@ -444,6 +444,10 @@ class AppLocalizationsHi extends AppLocalizations {
|
||||
return '$totalMb MB में से $usedMb MB इस्तेमाल हुआ';
|
||||
}
|
||||
|
||||
@override
|
||||
String get recordingsLibraryStorageFolderCaption =>
|
||||
'Music/PluriWave · सीमा पूरी होने पर सबसे पुरानी रिकॉर्डिंग हटा दी जाती हैं';
|
||||
|
||||
@override
|
||||
String get recordingsLibraryEmptyTitle => 'अभी तक कोई रिकॉर्डिंग नहीं';
|
||||
|
||||
@@ -616,12 +620,20 @@ class AppLocalizationsHi extends AppLocalizations {
|
||||
@override
|
||||
String get searchMinQualityFilterLabel => 'न्यूनतम गुणवत्ता';
|
||||
|
||||
@override
|
||||
String get searchLoadingStationsLabel => 'स्टेशन खोजे जा रहे हैं…';
|
||||
|
||||
@override
|
||||
String get searchEmptyTitle => 'एक स्टेशन खोजें';
|
||||
|
||||
@override
|
||||
String get searchNoResultsTitle => 'कोई परिणाम नहीं';
|
||||
|
||||
@override
|
||||
String searchNoResultsForQueryTitle(Object query) {
|
||||
return '\"$query\" के लिए कोई परिणाम नहीं';
|
||||
}
|
||||
|
||||
@override
|
||||
String get searchEmptySubtitle =>
|
||||
'दुनिया भर के सिग्नल खोजने के लिए ऊपर की बार या चिप्स इस्तेमाल करें।';
|
||||
@@ -655,6 +667,9 @@ class AppLocalizationsHi extends AppLocalizations {
|
||||
@override
|
||||
String get countriesScreenTitle => 'देश';
|
||||
|
||||
@override
|
||||
String get countriesSearchHint => 'देश या कोड...';
|
||||
|
||||
@override
|
||||
String get countriesYourLanguagesTitle => 'आपकी भाषाएं';
|
||||
|
||||
@@ -745,6 +760,9 @@ class AppLocalizationsHi extends AppLocalizations {
|
||||
@override
|
||||
String get openFullPlayerTooltip => 'पूरा प्लेयर खोलें';
|
||||
|
||||
@override
|
||||
String get nowListeningLabel => 'अभी सुन रहे हैं';
|
||||
|
||||
@override
|
||||
String get nothingPlayingTitle => 'अभी तक कुछ नहीं चल रहा';
|
||||
|
||||
@@ -784,6 +802,9 @@ class AppLocalizationsHi extends AppLocalizations {
|
||||
@override
|
||||
String get exploreNewSubtitle => 'इस सप्ताह';
|
||||
|
||||
@override
|
||||
String get popularNowTitle => 'अभी लोकप्रिय';
|
||||
|
||||
@override
|
||||
String get offlineBannerTitle => 'कोई कनेक्शन नहीं';
|
||||
|
||||
@@ -1009,6 +1030,9 @@ class AppLocalizationsHi extends AppLocalizations {
|
||||
return '$minutes मिनट';
|
||||
}
|
||||
|
||||
@override
|
||||
String get alarmSnoozeUsualLabel => 'सामान्य';
|
||||
|
||||
@override
|
||||
String get saveFavoritesAlarmHint =>
|
||||
'उन्हें संगीतमय अलार्म के रूप में इस्तेमाल करने के लिए स्टेशन पसंदीदा में सहेजें।';
|
||||
@@ -1097,6 +1121,10 @@ class AppLocalizationsHi extends AppLocalizations {
|
||||
@override
|
||||
String get addVacationRangeCta => 'अवधि जोड़ें';
|
||||
|
||||
@override
|
||||
String get vacationExplainerBanner =>
|
||||
'इन अवधियों के दौरान \"छुट्टी में रोकें\" के रूप में चिह्नित अलार्म नहीं बजेंगे। \"हमेशा बजाएं\" के रूप में चिह्नित अलार्म पर कोई असर नहीं पड़ता।';
|
||||
|
||||
@override
|
||||
String get vacationNoActiveRangeHint => 'अभी कोई सक्रिय छुट्टी अवधि नहीं है।';
|
||||
|
||||
@@ -1349,6 +1377,12 @@ class AppLocalizationsHi extends AppLocalizations {
|
||||
return 'दिन: $days';
|
||||
}
|
||||
|
||||
@override
|
||||
String get alarmRepeatSectionLabel => 'दोहराएं';
|
||||
|
||||
@override
|
||||
String get alarmVolumeLabel => 'आवाज़';
|
||||
|
||||
@override
|
||||
String get androidReliabilityTitle => 'Android विश्वसनीयता जाँचें';
|
||||
|
||||
|
||||
@@ -444,6 +444,10 @@ class AppLocalizationsId extends AppLocalizations {
|
||||
return '$usedMb MB dari $totalMb MB terpakai';
|
||||
}
|
||||
|
||||
@override
|
||||
String get recordingsLibraryStorageFolderCaption =>
|
||||
'Music/PluriWave · yang terlama dihapus saat mencapai batas';
|
||||
|
||||
@override
|
||||
String get recordingsLibraryEmptyTitle => 'Belum ada rekaman';
|
||||
|
||||
@@ -617,12 +621,20 @@ class AppLocalizationsId extends AppLocalizations {
|
||||
@override
|
||||
String get searchMinQualityFilterLabel => 'Kualitas minimum';
|
||||
|
||||
@override
|
||||
String get searchLoadingStationsLabel => 'MENCARI STASIUN…';
|
||||
|
||||
@override
|
||||
String get searchEmptyTitle => 'Cari stasiun';
|
||||
|
||||
@override
|
||||
String get searchNoResultsTitle => 'Tidak ada hasil';
|
||||
|
||||
@override
|
||||
String searchNoResultsForQueryTitle(Object query) {
|
||||
return 'Tidak ada hasil untuk \"$query\"';
|
||||
}
|
||||
|
||||
@override
|
||||
String get searchEmptySubtitle =>
|
||||
'Gunakan bilah atas atau chip untuk menemukan sinyal dari seluruh dunia.';
|
||||
@@ -655,6 +667,9 @@ class AppLocalizationsId extends AppLocalizations {
|
||||
@override
|
||||
String get countriesScreenTitle => 'Negara';
|
||||
|
||||
@override
|
||||
String get countriesSearchHint => 'Negara atau kode...';
|
||||
|
||||
@override
|
||||
String get countriesYourLanguagesTitle => 'Bahasa Anda';
|
||||
|
||||
@@ -745,6 +760,9 @@ class AppLocalizationsId extends AppLocalizations {
|
||||
@override
|
||||
String get openFullPlayerTooltip => 'Buka pemutar lengkap';
|
||||
|
||||
@override
|
||||
String get nowListeningLabel => 'Sedang mendengarkan';
|
||||
|
||||
@override
|
||||
String get nothingPlayingTitle => 'Belum ada yang diputar';
|
||||
|
||||
@@ -784,6 +802,9 @@ class AppLocalizationsId extends AppLocalizations {
|
||||
@override
|
||||
String get exploreNewSubtitle => 'Minggu ini';
|
||||
|
||||
@override
|
||||
String get popularNowTitle => 'Populer sekarang';
|
||||
|
||||
@override
|
||||
String get offlineBannerTitle => 'Tidak ada koneksi';
|
||||
|
||||
@@ -1012,6 +1033,9 @@ class AppLocalizationsId extends AppLocalizations {
|
||||
return '$minutes mnt';
|
||||
}
|
||||
|
||||
@override
|
||||
String get alarmSnoozeUsualLabel => 'biasa';
|
||||
|
||||
@override
|
||||
String get saveFavoritesAlarmHint =>
|
||||
'Simpan stasiun ke Favorit untuk digunakan sebagai alarm musik.';
|
||||
@@ -1101,6 +1125,10 @@ class AppLocalizationsId extends AppLocalizations {
|
||||
@override
|
||||
String get addVacationRangeCta => 'Tambahkan periode';
|
||||
|
||||
@override
|
||||
String get vacationExplainerBanner =>
|
||||
'Selama periode ini, alarm yang ditandai \"jeda saat liburan\" tidak akan berbunyi. Alarm yang ditandai \"selalu berbunyi\" tidak terpengaruh.';
|
||||
|
||||
@override
|
||||
String get vacationNoActiveRangeHint =>
|
||||
'Tidak ada periode liburan aktif saat ini.';
|
||||
@@ -1357,6 +1385,12 @@ class AppLocalizationsId extends AppLocalizations {
|
||||
return 'Hari: $days';
|
||||
}
|
||||
|
||||
@override
|
||||
String get alarmRepeatSectionLabel => 'ULANGI';
|
||||
|
||||
@override
|
||||
String get alarmVolumeLabel => 'Volume';
|
||||
|
||||
@override
|
||||
String get androidReliabilityTitle => 'Tinjau keandalan Android';
|
||||
|
||||
|
||||
@@ -448,6 +448,10 @@ class AppLocalizationsIt extends AppLocalizations {
|
||||
return '$usedMb MB di $totalMb MB usati';
|
||||
}
|
||||
|
||||
@override
|
||||
String get recordingsLibraryStorageFolderCaption =>
|
||||
'Music/PluriWave · elimina le più vecchie al raggiungimento del limite';
|
||||
|
||||
@override
|
||||
String get recordingsLibraryEmptyTitle => 'Ancora nessuna registrazione';
|
||||
|
||||
@@ -622,12 +626,20 @@ class AppLocalizationsIt extends AppLocalizations {
|
||||
@override
|
||||
String get searchMinQualityFilterLabel => 'Qualità minima';
|
||||
|
||||
@override
|
||||
String get searchLoadingStationsLabel => 'RICERCA STAZIONI IN CORSO…';
|
||||
|
||||
@override
|
||||
String get searchEmptyTitle => 'Cerca un\'emittente';
|
||||
|
||||
@override
|
||||
String get searchNoResultsTitle => 'Nessun risultato';
|
||||
|
||||
@override
|
||||
String searchNoResultsForQueryTitle(Object query) {
|
||||
return 'Nessun risultato per \"$query\"';
|
||||
}
|
||||
|
||||
@override
|
||||
String get searchEmptySubtitle =>
|
||||
'Usa la barra in alto o i chip per scoprire emittenti da tutto il mondo.';
|
||||
@@ -661,6 +673,9 @@ class AppLocalizationsIt extends AppLocalizations {
|
||||
@override
|
||||
String get countriesScreenTitle => 'Paesi';
|
||||
|
||||
@override
|
||||
String get countriesSearchHint => 'Paese o codice...';
|
||||
|
||||
@override
|
||||
String get countriesYourLanguagesTitle => 'Le tue lingue';
|
||||
|
||||
@@ -751,6 +766,9 @@ class AppLocalizationsIt extends AppLocalizations {
|
||||
@override
|
||||
String get openFullPlayerTooltip => 'Apri il player completo';
|
||||
|
||||
@override
|
||||
String get nowListeningLabel => 'In ascolto ora';
|
||||
|
||||
@override
|
||||
String get nothingPlayingTitle => 'Non stai ancora ascoltando nulla';
|
||||
|
||||
@@ -790,6 +808,9 @@ class AppLocalizationsIt extends AppLocalizations {
|
||||
@override
|
||||
String get exploreNewSubtitle => 'Questa settimana';
|
||||
|
||||
@override
|
||||
String get popularNowTitle => 'Popolari ora';
|
||||
|
||||
@override
|
||||
String get offlineBannerTitle => 'Nessuna connessione';
|
||||
|
||||
@@ -1017,6 +1038,9 @@ class AppLocalizationsIt extends AppLocalizations {
|
||||
return '$minutes min';
|
||||
}
|
||||
|
||||
@override
|
||||
String get alarmSnoozeUsualLabel => 'abituale';
|
||||
|
||||
@override
|
||||
String get saveFavoritesAlarmHint =>
|
||||
'Salva emittenti nei Preferiti per usarle come sveglia musicale.';
|
||||
@@ -1106,6 +1130,10 @@ class AppLocalizationsIt extends AppLocalizations {
|
||||
@override
|
||||
String get addVacationRangeCta => 'Aggiungi periodo';
|
||||
|
||||
@override
|
||||
String get vacationExplainerBanner =>
|
||||
'Durante questi periodi le sveglie contrassegnate come \"pausa in vacanza\" non suonano. Quelle contrassegnate come \"suona sempre\" non sono interessate.';
|
||||
|
||||
@override
|
||||
String get vacationNoActiveRangeHint =>
|
||||
'Nessun periodo di vacanza attivo al momento.';
|
||||
@@ -1365,6 +1393,12 @@ class AppLocalizationsIt extends AppLocalizations {
|
||||
return 'Giorni: $days';
|
||||
}
|
||||
|
||||
@override
|
||||
String get alarmRepeatSectionLabel => 'RIPETI';
|
||||
|
||||
@override
|
||||
String get alarmVolumeLabel => 'Volume';
|
||||
|
||||
@override
|
||||
String get androidReliabilityTitle => 'Controlla affidabilità Android';
|
||||
|
||||
|
||||
@@ -431,6 +431,10 @@ class AppLocalizationsJa extends AppLocalizations {
|
||||
return '$totalMb MB 中 $usedMb MB 使用済み';
|
||||
}
|
||||
|
||||
@override
|
||||
String get recordingsLibraryStorageFolderCaption =>
|
||||
'Music/PluriWave・上限に達すると古い順に削除されます';
|
||||
|
||||
@override
|
||||
String get recordingsLibraryEmptyTitle => 'まだ録音がありません';
|
||||
|
||||
@@ -594,12 +598,20 @@ class AppLocalizationsJa extends AppLocalizations {
|
||||
@override
|
||||
String get searchMinQualityFilterLabel => '最低品質';
|
||||
|
||||
@override
|
||||
String get searchLoadingStationsLabel => '局を検索中…';
|
||||
|
||||
@override
|
||||
String get searchEmptyTitle => '局を検索';
|
||||
|
||||
@override
|
||||
String get searchNoResultsTitle => '結果がありません';
|
||||
|
||||
@override
|
||||
String searchNoResultsForQueryTitle(Object query) {
|
||||
return '「$query」の結果はありません';
|
||||
}
|
||||
|
||||
@override
|
||||
String get searchEmptySubtitle => '上部バーやチップを使って、世界中の電波を見つけましょう。';
|
||||
|
||||
@@ -630,6 +642,9 @@ class AppLocalizationsJa extends AppLocalizations {
|
||||
@override
|
||||
String get countriesScreenTitle => '国';
|
||||
|
||||
@override
|
||||
String get countriesSearchHint => '国名またはコード...';
|
||||
|
||||
@override
|
||||
String get countriesYourLanguagesTitle => 'あなたの言語';
|
||||
|
||||
@@ -720,6 +735,9 @@ class AppLocalizationsJa extends AppLocalizations {
|
||||
@override
|
||||
String get openFullPlayerTooltip => 'フルプレーヤーを開く';
|
||||
|
||||
@override
|
||||
String get nowListeningLabel => '再生中';
|
||||
|
||||
@override
|
||||
String get nothingPlayingTitle => 'まだ何も再生されていません';
|
||||
|
||||
@@ -758,6 +776,9 @@ class AppLocalizationsJa extends AppLocalizations {
|
||||
@override
|
||||
String get exploreNewSubtitle => '今週';
|
||||
|
||||
@override
|
||||
String get popularNowTitle => '今人気';
|
||||
|
||||
@override
|
||||
String get offlineBannerTitle => '接続なし';
|
||||
|
||||
@@ -979,6 +1000,9 @@ class AppLocalizationsJa extends AppLocalizations {
|
||||
return '$minutes分';
|
||||
}
|
||||
|
||||
@override
|
||||
String get alarmSnoozeUsualLabel => '通常';
|
||||
|
||||
@override
|
||||
String get saveFavoritesAlarmHint => '音楽アラームとして使うには、局をお気に入りに保存してください。';
|
||||
|
||||
@@ -1064,6 +1088,10 @@ class AppLocalizationsJa extends AppLocalizations {
|
||||
@override
|
||||
String get addVacationRangeCta => '期間を追加';
|
||||
|
||||
@override
|
||||
String get vacationExplainerBanner =>
|
||||
'この期間中は「休暇中は一時停止」に設定したアラームは鳴りません。「常に鳴らす」に設定したアラームには影響しません。';
|
||||
|
||||
@override
|
||||
String get vacationNoActiveRangeHint => '現在アクティブな休暇期間はありません。';
|
||||
|
||||
@@ -1306,6 +1334,12 @@ class AppLocalizationsJa extends AppLocalizations {
|
||||
return '曜日: $days';
|
||||
}
|
||||
|
||||
@override
|
||||
String get alarmRepeatSectionLabel => '繰り返し';
|
||||
|
||||
@override
|
||||
String get alarmVolumeLabel => '音量';
|
||||
|
||||
@override
|
||||
String get androidReliabilityTitle => 'Androidの信頼性を確認';
|
||||
|
||||
|
||||
@@ -446,6 +446,10 @@ class AppLocalizationsPt extends AppLocalizations {
|
||||
return '$usedMb MB de $totalMb MB usados';
|
||||
}
|
||||
|
||||
@override
|
||||
String get recordingsLibraryStorageFolderCaption =>
|
||||
'Music/PluriWave · apaga as mais antigas ao atingir o limite';
|
||||
|
||||
@override
|
||||
String get recordingsLibraryEmptyTitle => 'Ainda não há gravações';
|
||||
|
||||
@@ -619,12 +623,20 @@ class AppLocalizationsPt extends AppLocalizations {
|
||||
@override
|
||||
String get searchMinQualityFilterLabel => 'Qualidade mínima';
|
||||
|
||||
@override
|
||||
String get searchLoadingStationsLabel => 'PROCURANDO EMISSORAS…';
|
||||
|
||||
@override
|
||||
String get searchEmptyTitle => 'Busque uma estação';
|
||||
|
||||
@override
|
||||
String get searchNoResultsTitle => 'Sem resultados';
|
||||
|
||||
@override
|
||||
String searchNoResultsForQueryTitle(Object query) {
|
||||
return 'Nenhum resultado para \"$query\"';
|
||||
}
|
||||
|
||||
@override
|
||||
String get searchEmptySubtitle =>
|
||||
'Use a barra superior ou os chips para descobrir estações do mundo todo.';
|
||||
@@ -658,6 +670,9 @@ class AppLocalizationsPt extends AppLocalizations {
|
||||
@override
|
||||
String get countriesScreenTitle => 'Países';
|
||||
|
||||
@override
|
||||
String get countriesSearchHint => 'País ou código...';
|
||||
|
||||
@override
|
||||
String get countriesYourLanguagesTitle => 'Seus idiomas';
|
||||
|
||||
@@ -748,6 +763,9 @@ class AppLocalizationsPt extends AppLocalizations {
|
||||
@override
|
||||
String get openFullPlayerTooltip => 'Abrir player completo';
|
||||
|
||||
@override
|
||||
String get nowListeningLabel => 'Ouvindo agora';
|
||||
|
||||
@override
|
||||
String get nothingPlayingTitle => 'Ainda não há nada tocando';
|
||||
|
||||
@@ -787,6 +805,9 @@ class AppLocalizationsPt extends AppLocalizations {
|
||||
@override
|
||||
String get exploreNewSubtitle => 'Nesta semana';
|
||||
|
||||
@override
|
||||
String get popularNowTitle => 'Populares agora';
|
||||
|
||||
@override
|
||||
String get offlineBannerTitle => 'Sem conexão';
|
||||
|
||||
@@ -1012,6 +1033,9 @@ class AppLocalizationsPt extends AppLocalizations {
|
||||
return '$minutes min';
|
||||
}
|
||||
|
||||
@override
|
||||
String get alarmSnoozeUsualLabel => 'usual';
|
||||
|
||||
@override
|
||||
String get saveFavoritesAlarmHint =>
|
||||
'Salve estações nos Favoritos para usá-las como alarme musical.';
|
||||
@@ -1100,6 +1124,10 @@ class AppLocalizationsPt extends AppLocalizations {
|
||||
@override
|
||||
String get addVacationRangeCta => 'Adicionar período';
|
||||
|
||||
@override
|
||||
String get vacationExplainerBanner =>
|
||||
'Durante esses periodos, os alarmes marcados como \"pausar nas ferias\" nao tocam. Os marcados como \"tocar sempre\" nao sao afetados.';
|
||||
|
||||
@override
|
||||
String get vacationNoActiveRangeHint =>
|
||||
'Não há período de férias ativo no momento.';
|
||||
@@ -1354,6 +1382,12 @@ class AppLocalizationsPt extends AppLocalizations {
|
||||
return 'Dias: $days';
|
||||
}
|
||||
|
||||
@override
|
||||
String get alarmRepeatSectionLabel => 'REPETIÇÃO';
|
||||
|
||||
@override
|
||||
String get alarmVolumeLabel => 'Volume';
|
||||
|
||||
@override
|
||||
String get androidReliabilityTitle => 'Revisar confiabilidade Android';
|
||||
|
||||
|
||||
@@ -446,6 +446,10 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
return 'Использовано $usedMb МБ из $totalMb МБ';
|
||||
}
|
||||
|
||||
@override
|
||||
String get recordingsLibraryStorageFolderCaption =>
|
||||
'Music/PluriWave · при достижении лимита удаляются старые записи';
|
||||
|
||||
@override
|
||||
String get recordingsLibraryEmptyTitle => 'Записей пока нет';
|
||||
|
||||
@@ -618,12 +622,20 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
@override
|
||||
String get searchMinQualityFilterLabel => 'Минимальное качество';
|
||||
|
||||
@override
|
||||
String get searchLoadingStationsLabel => 'ПОИСК СТАНЦИЙ…';
|
||||
|
||||
@override
|
||||
String get searchEmptyTitle => 'Найдите станцию';
|
||||
|
||||
@override
|
||||
String get searchNoResultsTitle => 'Нет результатов';
|
||||
|
||||
@override
|
||||
String searchNoResultsForQueryTitle(Object query) {
|
||||
return 'Нет результатов по запросу «$query»';
|
||||
}
|
||||
|
||||
@override
|
||||
String get searchEmptySubtitle =>
|
||||
'Используйте верхнюю строку или чипы, чтобы находить сигналы со всего мира.';
|
||||
@@ -659,6 +671,9 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
@override
|
||||
String get countriesScreenTitle => 'Страны';
|
||||
|
||||
@override
|
||||
String get countriesSearchHint => 'Страна или код...';
|
||||
|
||||
@override
|
||||
String get countriesYourLanguagesTitle => 'Ваши языки';
|
||||
|
||||
@@ -749,6 +764,9 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
@override
|
||||
String get openFullPlayerTooltip => 'Открыть полный плеер';
|
||||
|
||||
@override
|
||||
String get nowListeningLabel => 'Сейчас слушаете';
|
||||
|
||||
@override
|
||||
String get nothingPlayingTitle => 'Пока ничего не воспроизводится';
|
||||
|
||||
@@ -788,6 +806,9 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
@override
|
||||
String get exploreNewSubtitle => 'На этой неделе';
|
||||
|
||||
@override
|
||||
String get popularNowTitle => 'Популярно сейчас';
|
||||
|
||||
@override
|
||||
String get offlineBannerTitle => 'Нет подключения';
|
||||
|
||||
@@ -1014,6 +1035,9 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
return '$minutes мин';
|
||||
}
|
||||
|
||||
@override
|
||||
String get alarmSnoozeUsualLabel => 'обычно';
|
||||
|
||||
@override
|
||||
String get saveFavoritesAlarmHint =>
|
||||
'Сохраните станции в избранное, чтобы использовать их как музыкальный будильник.';
|
||||
@@ -1102,6 +1126,10 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
@override
|
||||
String get addVacationRangeCta => 'Добавить период';
|
||||
|
||||
@override
|
||||
String get vacationExplainerBanner =>
|
||||
'В эти периоды будильники с пометкой «пауза на время отпуска» не звонят. Будильники с пометкой «звонить всегда» это не затрагивает.';
|
||||
|
||||
@override
|
||||
String get vacationNoActiveRangeHint =>
|
||||
'Сейчас нет активного периода отпуска.';
|
||||
@@ -1358,6 +1386,12 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
return 'Дни: $days';
|
||||
}
|
||||
|
||||
@override
|
||||
String get alarmRepeatSectionLabel => 'ПОВТОР';
|
||||
|
||||
@override
|
||||
String get alarmVolumeLabel => 'Громкость';
|
||||
|
||||
@override
|
||||
String get androidReliabilityTitle => 'Проверить надёжность Android';
|
||||
|
||||
|
||||
@@ -429,6 +429,10 @@ class AppLocalizationsZh extends AppLocalizations {
|
||||
return '已使用 $usedMb MB / 共 $totalMb MB';
|
||||
}
|
||||
|
||||
@override
|
||||
String get recordingsLibraryStorageFolderCaption =>
|
||||
'Music/PluriWave · 达到上限时会删除最旧的文件';
|
||||
|
||||
@override
|
||||
String get recordingsLibraryEmptyTitle => '暂无录音';
|
||||
|
||||
@@ -592,12 +596,20 @@ class AppLocalizationsZh extends AppLocalizations {
|
||||
@override
|
||||
String get searchMinQualityFilterLabel => '最低质量';
|
||||
|
||||
@override
|
||||
String get searchLoadingStationsLabel => '正在搜索电台…';
|
||||
|
||||
@override
|
||||
String get searchEmptyTitle => '搜索电台';
|
||||
|
||||
@override
|
||||
String get searchNoResultsTitle => '没有结果';
|
||||
|
||||
@override
|
||||
String searchNoResultsForQueryTitle(Object query) {
|
||||
return '未找到与\"$query\"相关的结果';
|
||||
}
|
||||
|
||||
@override
|
||||
String get searchEmptySubtitle => '使用顶部搜索栏或筛选标签,发现世界各地的电台信号。';
|
||||
|
||||
@@ -628,6 +640,9 @@ class AppLocalizationsZh extends AppLocalizations {
|
||||
@override
|
||||
String get countriesScreenTitle => '国家';
|
||||
|
||||
@override
|
||||
String get countriesSearchHint => '国家或代码...';
|
||||
|
||||
@override
|
||||
String get countriesYourLanguagesTitle => '你的语言';
|
||||
|
||||
@@ -717,6 +732,9 @@ class AppLocalizationsZh extends AppLocalizations {
|
||||
@override
|
||||
String get openFullPlayerTooltip => '打开完整播放器';
|
||||
|
||||
@override
|
||||
String get nowListeningLabel => '正在收听';
|
||||
|
||||
@override
|
||||
String get nothingPlayingTitle => '目前没有正在播放的内容';
|
||||
|
||||
@@ -755,6 +773,9 @@ class AppLocalizationsZh extends AppLocalizations {
|
||||
@override
|
||||
String get exploreNewSubtitle => '本周';
|
||||
|
||||
@override
|
||||
String get popularNowTitle => '当前热门';
|
||||
|
||||
@override
|
||||
String get offlineBannerTitle => '无连接';
|
||||
|
||||
@@ -975,6 +996,9 @@ class AppLocalizationsZh extends AppLocalizations {
|
||||
return '$minutes 分钟';
|
||||
}
|
||||
|
||||
@override
|
||||
String get alarmSnoozeUsualLabel => '常用';
|
||||
|
||||
@override
|
||||
String get saveFavoritesAlarmHint => '将电台保存到收藏,即可把它们用作音乐闹钟。';
|
||||
|
||||
@@ -1060,6 +1084,10 @@ class AppLocalizationsZh extends AppLocalizations {
|
||||
@override
|
||||
String get addVacationRangeCta => '添加时间段';
|
||||
|
||||
@override
|
||||
String get vacationExplainerBanner =>
|
||||
'在这些时间段内,标记为“假期暂停”的闹钟不会响铃;标记为“始终响铃”的闹钟不受影响。';
|
||||
|
||||
@override
|
||||
String get vacationNoActiveRangeHint => '目前没有正在生效的假期时间段。';
|
||||
|
||||
@@ -1301,6 +1329,12 @@ class AppLocalizationsZh extends AppLocalizations {
|
||||
return '星期:$days';
|
||||
}
|
||||
|
||||
@override
|
||||
String get alarmRepeatSectionLabel => '重复';
|
||||
|
||||
@override
|
||||
String get alarmVolumeLabel => '音量';
|
||||
|
||||
@override
|
||||
String get androidReliabilityTitle => '检查 Android 可靠性';
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import '../../estado/estado_radio.dart';
|
||||
import '../../l10n/gen/app_localizations.dart';
|
||||
import '../../modelos/preset_ecualizador.dart';
|
||||
import '../../tema/pluriwave_theme.dart';
|
||||
import '../../tema/pluriwave_tokens.dart';
|
||||
import '../../widgets/ecualizador_widget.dart';
|
||||
import '../../widgets/pluri_glass_surface.dart';
|
||||
import '../../widgets/pluri_layout.dart';
|
||||
@@ -36,13 +37,33 @@ class PantallaAjustesEcualizador extends StatelessWidget {
|
||||
const PantallaAjustesEcualizador({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => PluriPushScaffold(
|
||||
title: AppLocalizations.of(context).equalizerTitle,
|
||||
body: ListView(
|
||||
padding: PluriLayout.pageContentPadding,
|
||||
children: const [_CuerpoEcualizador()],
|
||||
),
|
||||
);
|
||||
Widget build(BuildContext context) {
|
||||
// Audit 11.1 (t4 line 566): the master enable switch lives in the
|
||||
// HEADER, not as the body's first row -- read here (a second,
|
||||
// cheap watch alongside _CuerpoEcualizadorState's own Consumer2)
|
||||
// purely to feed PluriPushScaffold.actions.
|
||||
final eq = context.watch<EstadoEcualizador>();
|
||||
return PluriPushScaffold(
|
||||
title: AppLocalizations.of(context).equalizerTitle,
|
||||
actions: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: 8),
|
||||
child: Switch(
|
||||
key: const ValueKey('eq-master-switch'),
|
||||
value: eq.activo,
|
||||
onChanged: eq.cambiarActivo,
|
||||
// t4 line 569: brand-teal track, white thumb -- the default
|
||||
// Material thumb colour already renders white when "on".
|
||||
activeTrackColor: PluriWaveTokens.brand,
|
||||
),
|
||||
),
|
||||
],
|
||||
body: ListView(
|
||||
padding: PluriLayout.pageContentPadding,
|
||||
children: const [_CuerpoEcualizador()],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _CuerpoEcualizador extends StatefulWidget {
|
||||
@@ -83,17 +104,18 @@ class _CuerpoEcualizadorState extends State<_CuerpoEcualizador> {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
SwitchListTile.adaptive(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
title: Text(l10n.equalizerEnable),
|
||||
subtitle: Text(
|
||||
disponible
|
||||
? l10n.equalizerRealtimeSubtitle
|
||||
: l10n.equalizerPendingSubtitle,
|
||||
),
|
||||
value: eq.activo,
|
||||
onChanged: eq.cambiarActivo,
|
||||
// Audit 11.1 (t4 line 566): the enable switch itself now lives
|
||||
// in PluriPushScaffold's header (see PantallaAjustesEcualizador
|
||||
// above) -- this stays behind only as the explanatory caption
|
||||
// the old SwitchListTile's subtitle carried, so that
|
||||
// information is not lost.
|
||||
Text(
|
||||
disponible
|
||||
? l10n.equalizerRealtimeSubtitle
|
||||
: l10n.equalizerPendingSubtitle,
|
||||
style: Theme.of(ctx).textTheme.bodySmall,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
if (mostrarModoPorEmisora) ...[
|
||||
const SizedBox(height: 8),
|
||||
SwitchListTile.adaptive(
|
||||
@@ -164,6 +186,12 @@ class _BannerExplicacionBase extends StatelessWidget {
|
||||
|
||||
final AppLocalizations l10n;
|
||||
|
||||
/// Audit 11.2 (t4 line 571): 16 -- doesn't match any of
|
||||
/// [PluriWaveTokens]'s three named radii (14/18/30), so this stays a
|
||||
/// local one-off constant (same precedent as `_stopButtonRadius` in
|
||||
/// `pantalla_alarma_sonando.dart`).
|
||||
static const _bannerRadius = 16.0;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tokens = context.pluriTokens;
|
||||
@@ -172,7 +200,7 @@ class _BannerExplicacionBase extends StatelessWidget {
|
||||
padding: const EdgeInsets.all(14),
|
||||
decoration: BoxDecoration(
|
||||
color: tokens.liveGreen.withValues(alpha: 0.09),
|
||||
borderRadius: BorderRadius.circular(tokens.radiusSm),
|
||||
borderRadius: BorderRadius.circular(_bannerRadius),
|
||||
border: Border.all(color: tokens.liveGreen.withValues(alpha: 0.26)),
|
||||
),
|
||||
child: Row(
|
||||
|
||||
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../tema/pluriwave_theme.dart';
|
||||
import '../../../widgets/pluri_glass_surface.dart';
|
||||
import '../../../widgets/pluri_layout.dart';
|
||||
|
||||
/// Design ADR-3: the two nav-row primitives every Settings detail screen is
|
||||
/// reached through. [GrupoAjustes] is a single [PluriGlassSurface] card
|
||||
@@ -27,18 +28,36 @@ class GrupoAjustes extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final type = context.pluriType;
|
||||
return PluriGlassSurface(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(titulo, style: type.eyebrowLabel),
|
||||
const SizedBox(height: 4),
|
||||
for (var i = 0; i < filas.length; i++) ...[
|
||||
if (i > 0) const Divider(height: 1),
|
||||
filas[i],
|
||||
],
|
||||
],
|
||||
),
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Audit 10.2 (t4 line 511): the group eyebrow sits OUTSIDE the
|
||||
// card entirely, at title-tier (20px) padding -- it used to live
|
||||
// INSIDE the PluriGlassSurface, sharing the card's own 16px
|
||||
// padding.
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(
|
||||
PluriLayout.titleHorizontal,
|
||||
0,
|
||||
PluriLayout.titleHorizontal,
|
||||
6,
|
||||
),
|
||||
child: Text(titulo, style: type.eyebrowLabel),
|
||||
),
|
||||
PluriGlassSurface(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
for (var i = 0; i < filas.length; i++) ...[
|
||||
// S10 (Tier 4 visual fidelity): the prototype insets its
|
||||
// row divider by 47px (t4 line 516), not full-bleed.
|
||||
if (i > 0) const Divider(height: 1, indent: 47),
|
||||
filas[i],
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -54,6 +73,7 @@ class FilaAjuste extends StatelessWidget {
|
||||
required this.titulo,
|
||||
required this.onTap,
|
||||
this.valor,
|
||||
this.iconColor,
|
||||
});
|
||||
|
||||
final IconData icon;
|
||||
@@ -66,14 +86,27 @@ class FilaAjuste extends StatelessWidget {
|
||||
/// "no current value to show" — the row renders exactly as before.
|
||||
final String? valor;
|
||||
|
||||
/// Audit 10.5 (t4 lines 514/516/522 — equalizer `#21D4D9`, hd
|
||||
/// `#7EE4C2`, folder `#F4B860`): only the first row or two of a group
|
||||
/// carries an accent colour in the prototype; every other row's icon
|
||||
/// stays the ambient default. Null (the vast majority of rows) means
|
||||
/// "no accent" — the icon renders exactly as before.
|
||||
final Color? iconColor;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final type = context.pluriType;
|
||||
final valorActual = valor;
|
||||
return ListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
leading: Icon(icon),
|
||||
title: Text(titulo, style: type.cardTitle),
|
||||
// 10.6 (Tier 4 visual fidelity): the prototype's row icon is 21px (t4
|
||||
// line 514), not Material's 24px default.
|
||||
leading: Icon(icon, size: 21, color: iconColor),
|
||||
// 10.8 (Tier 4 visual fidelity): the prototype's row title is
|
||||
// 14px/w700 (t4 line 514); cardTitle is 14.5/w700 — a one-off
|
||||
// override, not a new PluriWaveTypography style (mirrors the
|
||||
// precedent set for the ringing screen's station name, audit 9.7).
|
||||
title: Text(titulo, style: type.cardTitle.copyWith(fontSize: 14)),
|
||||
trailing: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
@@ -88,7 +121,14 @@ class FilaAjuste extends StatelessWidget {
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
],
|
||||
const Icon(Icons.chevron_right_rounded),
|
||||
// 10.9 (Tier 4 visual fidelity): the prototype's chevron is 19px
|
||||
// at 40% opacity (t4 lines 515-539), not Material's 24px
|
||||
// full-opacity default.
|
||||
Icon(
|
||||
Icons.chevron_right_rounded,
|
||||
size: 19,
|
||||
color: const Color(0xFFF2F7FA).withValues(alpha: 0.4),
|
||||
),
|
||||
],
|
||||
),
|
||||
onTap: onTap,
|
||||
|
||||
@@ -9,6 +9,7 @@ import '../l10n/display_names.dart';
|
||||
import '../l10n/gen/app_localizations.dart';
|
||||
import '../modelos/archivo_grabacion.dart';
|
||||
import '../modelos/emisora.dart';
|
||||
import '../tema/pluriwave_tokens.dart';
|
||||
import '../widgets/pluri_layout.dart';
|
||||
import '../widgets/pluri_push_scaffold.dart';
|
||||
import '../widgets/pluri_root_header.dart';
|
||||
@@ -106,6 +107,9 @@ class _AjustesContent extends StatelessWidget {
|
||||
filas: [
|
||||
FilaAjuste(
|
||||
icon: Icons.equalizer_rounded,
|
||||
// Audit 10.5 (t4 line 514): the first AUDIO row's icon is
|
||||
// brand cyan.
|
||||
iconColor: PluriWaveTokens.brand,
|
||||
titulo: l10n.equalizerTitle,
|
||||
valor:
|
||||
ecualizadorActivo
|
||||
@@ -149,6 +153,9 @@ class _AjustesContent extends StatelessWidget {
|
||||
filas: [
|
||||
FilaAjuste(
|
||||
icon: Icons.playlist_add_check_circle_rounded,
|
||||
// Audit 10.5 (t4 line 526): the first STATIONS row's icon
|
||||
// is warmCoral (the prototype's own `folder` row accent).
|
||||
iconColor: PluriWaveTokens.dark.warmCoral,
|
||||
titulo: l10n.favoriteGroupsTitle,
|
||||
valor: '$gruposCount',
|
||||
onTap:
|
||||
|
||||
@@ -3,6 +3,7 @@ import 'dart:ui';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_animate/flutter_animate.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../estado/estado_alarmas.dart';
|
||||
@@ -199,6 +200,17 @@ class _PantallaAlarmaSonandoState extends State<PantallaAlarmaSonando> {
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
_FondoArteDifuminado(tokens: tokens),
|
||||
// Audit 9.1 (t4 line 411): the pulsing amber halo behind the
|
||||
// hero content — see _HaloPulsante for why it is a BOUNDED pulse,
|
||||
// not the prototype's literal `infinite` animation.
|
||||
Positioned(
|
||||
top: 150,
|
||||
left: 0,
|
||||
right: 0,
|
||||
child: IgnorePointer(
|
||||
child: Center(child: _HaloPulsante(tokens: tokens)),
|
||||
),
|
||||
),
|
||||
SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(24, 20, 24, 24),
|
||||
@@ -213,12 +225,39 @@ class _PantallaAlarmaSonandoState extends State<PantallaAlarmaSonando> {
|
||||
tokens: tokens,
|
||||
),
|
||||
const SizedBox(height: 22),
|
||||
// Audit 9.4 (t4 line 419): "Lunes, 3 de agosto" between
|
||||
// the pill and the hero time -- never rendered before.
|
||||
// Purely additive: a new sibling Text, touching neither
|
||||
// the pill above nor the hero time below.
|
||||
Text(
|
||||
fechaLargaConDiaSemana(
|
||||
Localizations.localeOf(context).toString(),
|
||||
DateTime.now(),
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.onSurface.withValues(alpha: 0.6),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
FittedBox(
|
||||
fit: BoxFit.scaleDown,
|
||||
child: Text(
|
||||
_hora(alarma),
|
||||
key: const ValueKey('ringing-hero-time'),
|
||||
style: type.heroTime,
|
||||
// Audit 9.5 (t4 line 417): 88px/w800/ls-4/lh.95 on
|
||||
// THIS screen only — a local override, not a change
|
||||
// to the shared heroTime token (EditorHoraInline, the
|
||||
// alarm editor's hour block, is the other consumer
|
||||
// and wants height:1, t4 line 379).
|
||||
style: type.heroTime.copyWith(
|
||||
letterSpacing: -4,
|
||||
height: 0.95,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
@@ -229,11 +268,11 @@ class _PantallaAlarmaSonandoState extends State<PantallaAlarmaSonando> {
|
||||
),
|
||||
const SizedBox(height: 22),
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(tokens.radiusLg),
|
||||
borderRadius: BorderRadius.circular(_artworkRadio),
|
||||
child: Image.asset(
|
||||
'assets/icons/alarmas/alarm_music.png',
|
||||
width: 168,
|
||||
height: 168,
|
||||
width: _artworkLado,
|
||||
height: _artworkLado,
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder:
|
||||
(_, __, ___) => Icon(
|
||||
@@ -280,11 +319,24 @@ class _PantallaAlarmaSonandoState extends State<PantallaAlarmaSonando> {
|
||||
const Spacer(flex: 3),
|
||||
Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Text(
|
||||
l10n.snoozeAction,
|
||||
style: type.eyebrowLabel.copyWith(
|
||||
color: tokens.warmCoral,
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// Audit 9.8 (t4 line 428): the POSPONER eyebrow
|
||||
// lost its snooze icon.
|
||||
Icon(
|
||||
Icons.snooze_rounded,
|
||||
size: 19,
|
||||
color: tokens.warmCoral,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
l10n.snoozeAction,
|
||||
style: type.eyebrowLabel.copyWith(
|
||||
color: tokens.warmCoral,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
@@ -377,6 +429,13 @@ String _hora(AlarmaMusical alarma) =>
|
||||
/// call site (mirrors `_ArteEscuchar._radio` in `pantalla_inicio.dart`).
|
||||
const _stopButtonRadius = 24.0;
|
||||
|
||||
/// Audit 9.6 (t4 line 421): the ringing screen's art is 180x180 with a 36
|
||||
/// corner radius — neither matches [PluriWaveTokens]'s three named radii
|
||||
/// (14/18/30), so this stays a local constant (same precedent as
|
||||
/// [_stopButtonRadius] above).
|
||||
const _artworkLado = 180.0;
|
||||
const _artworkRadio = 36.0;
|
||||
|
||||
/// Audit 9.3: the schedule pill's text, built only from
|
||||
/// [AlarmaMusical.tipoProgramacion] and the fields it already carries per
|
||||
/// case (`diasSemana`, `fechaUnica`) — no new domain plumbing. Reuses the
|
||||
@@ -458,6 +517,66 @@ class _PildoraProgramacion extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
/// Pulsing amber halo (audit 9.1, t4 line 411): a 420x420 radial gradient
|
||||
/// centered behind the hero content, echoing the alarm's warmCoral accent.
|
||||
///
|
||||
/// BOUNDED, not infinite: the prototype's CSS is `animation: pw-pulse 2.4s
|
||||
/// ease-in-out infinite`, but this screen's dismiss guard
|
||||
/// (`pantalla_alarma_sonando_dismiss_guard_test.dart`, protected — must stay
|
||||
/// byte-identical to `main`) calls `pumpAndSettle()` after every mount and
|
||||
/// every interaction. A genuinely infinite `AnimationController.repeat()`
|
||||
/// anywhere in this widget's subtree would hang every one of those calls
|
||||
/// forever, with no way to fix it since that file cannot be edited (see
|
||||
/// `_EstadoSubidaVolumen` above for the same reasoning applied earlier on
|
||||
/// this exact screen). One grow-and-settle cycle, timed to the prototype's
|
||||
/// own 2.4s cadence, delivers the same "draws the eye" motion without ever
|
||||
/// leaving a frame scheduled forever. Respects reduced motion exactly like
|
||||
/// every other entry animation in this app (`PluriAnimate`).
|
||||
class _HaloPulsante extends StatelessWidget {
|
||||
const _HaloPulsante({required this.tokens});
|
||||
|
||||
final PluriWaveTokens tokens;
|
||||
|
||||
static const _lado = 420.0;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final halo = Container(
|
||||
key: const ValueKey('ringing-pulse-halo'),
|
||||
width: _lado,
|
||||
height: _lado,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
gradient: RadialGradient(
|
||||
colors: [
|
||||
tokens.warmCoral.withValues(alpha: 0.2),
|
||||
tokens.warmCoral.withValues(alpha: 0),
|
||||
],
|
||||
stops: const [0, 0.62],
|
||||
),
|
||||
),
|
||||
);
|
||||
if (MediaQuery.maybeDisableAnimationsOf(context) ?? false) {
|
||||
return halo;
|
||||
}
|
||||
return halo
|
||||
.animate()
|
||||
.scaleXY(
|
||||
begin: 1,
|
||||
end: 1.08,
|
||||
duration: 1200.ms,
|
||||
curve: Curves.easeInOut,
|
||||
)
|
||||
.then()
|
||||
.scaleXY(
|
||||
begin: 1.08,
|
||||
end: 1,
|
||||
duration: 1200.ms,
|
||||
curve: Curves.easeInOut,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Full-bleed blurred backdrop (WU11, replaces the `PluriGlassSurface` card
|
||||
/// container per task 11.3). This app has no per-station artwork/favicon
|
||||
/// safe to render here: `Emisora.favicon` is a network URL, and rendering
|
||||
@@ -589,6 +708,20 @@ class _FilaSnoozeFija extends StatelessWidget {
|
||||
final forma = RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(tokens.radiusMd),
|
||||
);
|
||||
// Audit 9.10 (t4 line 433): the prototype stacks a big NUMBER over a
|
||||
// small "min · habitual" unit — the SAME text-splitting conflict as
|
||||
// 9.9 (permanently rejected, Engram id 2525): this flat string is
|
||||
// exactly what the protected dismiss-guard test locates via
|
||||
// `find.text(l10n.alarmSnoozeOptionLabel(N))` in four places, and
|
||||
// splitting it into two differently-styled Text nodes would make
|
||||
// that flat value vanish from the render tree.
|
||||
//
|
||||
// Resolved differently here than 9.9: rather than splitting THIS
|
||||
// string, an entirely SEPARATE small qualifier Text is added
|
||||
// alongside it (only on the destacado tile) — the original flat
|
||||
// label stays a single, untouched, unstyled-differently Text node,
|
||||
// still the exact widget the guard finds and taps. This delivers
|
||||
// the "habitual" qualifier without the conflict 9.9 hit.
|
||||
final etiqueta = Text(l10n.alarmSnoozeOptionLabel(minutos));
|
||||
return Expanded(
|
||||
flex: esDestacado ? 3 : 2,
|
||||
@@ -603,7 +736,21 @@ class _FilaSnoozeFija extends StatelessWidget {
|
||||
foregroundColor: tokens.deepViolet,
|
||||
shape: forma,
|
||||
),
|
||||
child: etiqueta,
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
etiqueta,
|
||||
Text(
|
||||
l10n.alarmSnoozeUsualLabel,
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w800,
|
||||
color: tokens.deepViolet.withValues(alpha: 0.75),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
: OutlinedButton(
|
||||
onPressed: () => onPosponer(minutos),
|
||||
|
||||
+484
-171
@@ -45,9 +45,26 @@ class PantallaAlarmas extends StatelessWidget {
|
||||
title: l10n.alarmScreenTitle,
|
||||
onSleepTimer: () => showPluriSleepTimerSheet(context),
|
||||
actions: [
|
||||
FilledButton.tonalIcon(
|
||||
// Audit 7.1 (t4:325): a solid brand-teal pill with a plain
|
||||
// `add` glyph -- was a tonal button with `auto_awesome`.
|
||||
FilledButton.icon(
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: PluriWaveTokens.brand,
|
||||
foregroundColor: const Color(0xFF062126),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 14,
|
||||
vertical: 9,
|
||||
),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
textStyle: const TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w800,
|
||||
),
|
||||
),
|
||||
onPressed: () => _abrirEditor(context),
|
||||
icon: const Icon(Icons.auto_awesome_rounded, size: 18),
|
||||
icon: const Icon(Icons.add_rounded, size: 18),
|
||||
label: Text(l10n.createAlarmAction),
|
||||
),
|
||||
],
|
||||
@@ -63,7 +80,14 @@ class PantallaAlarmas extends StatelessWidget {
|
||||
else
|
||||
for (final alarma in estado.alarmas) ...[
|
||||
_TarjetaAlarma(alarma: alarma),
|
||||
const SizedBox(height: 12),
|
||||
// 7.5 (Tier 4 visual fidelity): the prototype's gap
|
||||
// between stacked alarm cards is 10px (t4 line 334),
|
||||
// not 12. Keyed per alarm id so the guard test can
|
||||
// target the exact gap between two known cards.
|
||||
SizedBox(
|
||||
height: 10,
|
||||
key: ValueKey('alarm-card-gap-${alarma.id}'),
|
||||
),
|
||||
],
|
||||
_PanelVacaciones(estado: estado),
|
||||
const SizedBox(height: 12),
|
||||
@@ -98,6 +122,7 @@ class _PanelProximaAlarma extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final tokens = context.pluriTokens;
|
||||
final proxima = estado.proximaAlarma;
|
||||
final activasSinProxima =
|
||||
estado.alarmas
|
||||
@@ -105,56 +130,87 @@ class _PanelProximaAlarma extends StatelessWidget {
|
||||
.length;
|
||||
final proximaProgramable = proxima?.proximaProgramable;
|
||||
|
||||
return PluriGlassSurface(
|
||||
glowColor: context.pluriTokens.warmCoral.withValues(alpha: 0.28),
|
||||
child: Row(
|
||||
children: [
|
||||
_AssetIcon(
|
||||
'assets/icons/alarmas/alarm_music.png',
|
||||
size: 72,
|
||||
semanticLabel: l10n.alarmIconLabel,
|
||||
),
|
||||
const SizedBox(width: 14),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
proxima == null
|
||||
? activasSinProxima > 0
|
||||
? l10n.activeAlarmsWithoutNextTitle
|
||||
: l10n.noActiveAlarms
|
||||
: l10n.nextAlarmTitle,
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w900,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
proxima == null
|
||||
? activasSinProxima > 0
|
||||
? l10n.activeAlarmsWithoutNextSubtitle(
|
||||
activasSinProxima,
|
||||
)
|
||||
: l10n.createAlarmHint
|
||||
: '${_nombreVisibleAlarma(l10n, proxima)} · ${_fechaHora(l10n, proximaProgramable!)}',
|
||||
),
|
||||
if (proxima != null) ...[
|
||||
const SizedBox(height: 10),
|
||||
Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: OutlinedButton.icon(
|
||||
key: const ValueKey('hero-skip-next'),
|
||||
onPressed: () => _saltarDesdeHero(context, proxima),
|
||||
icon: const Icon(Icons.skip_next_rounded, size: 18),
|
||||
label: Text(l10n.alarmHeroSkipAction),
|
||||
// Audit 7.2 (t4:326-330): warmCoral-tinted card, `alarm_on` icon at
|
||||
// 26px, and the "Saltar" chip BESIDE the text on the same row -- was
|
||||
// an opaque default card with a 72px PNG and the skip action stacked
|
||||
// BELOW the text as an OutlinedButton.
|
||||
return DecoratedBox(
|
||||
key: const ValueKey('next-alarm-banner'),
|
||||
decoration: BoxDecoration(
|
||||
color: tokens.warmCoral.withValues(alpha: 0.13),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
border: Border.all(color: tokens.warmCoral.withValues(alpha: 0.34)),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Icon(Icons.alarm_on, size: 26, color: tokens.warmCoral),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
proxima == null
|
||||
? activasSinProxima > 0
|
||||
? l10n.activeAlarmsWithoutNextTitle
|
||||
: l10n.noActiveAlarms
|
||||
: l10n.nextAlarmTitle,
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w900,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
proxima == null
|
||||
? activasSinProxima > 0
|
||||
? l10n.activeAlarmsWithoutNextSubtitle(
|
||||
activasSinProxima,
|
||||
)
|
||||
: l10n.createAlarmHint
|
||||
: '${_nombreVisibleAlarma(l10n, proxima)} · ${_fechaHora(l10n, proximaProgramable!)}',
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
if (proxima != null) ...[
|
||||
const SizedBox(width: 8),
|
||||
_ChipSaltar(onTap: () => _saltarDesdeHero(context, proxima)),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Audit 7.2 (t4:329): `padding:8px 12px;radius:10;rgba(255,255,255,.08)` --
|
||||
/// plain text, no icon, unlike the previous `OutlinedButton.icon`.
|
||||
class _ChipSaltar extends StatelessWidget {
|
||||
const _ChipSaltar({required this.onTap});
|
||||
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
return Material(
|
||||
color: Colors.white.withValues(alpha: 0.08),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
child: InkWell(
|
||||
key: const ValueKey('hero-skip-next'),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
onTap: onTap,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
child: Text(
|
||||
l10n.alarmHeroSkipAction,
|
||||
style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w800),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -236,17 +292,83 @@ class _TarjetaAlarma extends StatelessWidget {
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
_hora(alarma),
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.displaySmall?.copyWith(
|
||||
fontWeight: FontWeight.w900,
|
||||
letterSpacing: -1.5,
|
||||
),
|
||||
// Audit 7.4 (t4:339): the recurrence label sits on
|
||||
// the SAME baseline as the giant time -- it used to
|
||||
// be missing entirely.
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.baseline,
|
||||
textBaseline: TextBaseline.alphabetic,
|
||||
children: [
|
||||
Text(
|
||||
_hora(alarma),
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.displaySmall?.copyWith(
|
||||
fontWeight: FontWeight.w900,
|
||||
letterSpacing: -1.5,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
_recurrenciaCorta(l10n, alarma),
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.onSurface.withValues(alpha: 0.5),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 7),
|
||||
// Audit 7.4 (t4:341): a small station-art slot
|
||||
// inline with the name -- was the name alone.
|
||||
// `Emisora.favicon` is a network URL (the same
|
||||
// hazard documented for the ringing screen's audit
|
||||
// 9.2 -- `Image.network` here would hang widget
|
||||
// tests without a mocked HttpClient), so this is a
|
||||
// themed fallback icon, not the real per-station
|
||||
// artwork, mirroring the recordings-row precedent
|
||||
// (`pantalla_grabaciones.dart`'s `_FilaGrabacion`).
|
||||
Row(
|
||||
children: [
|
||||
if (alarma.emisora != null) ...[
|
||||
DecoratedBox(
|
||||
key: const ValueKey('tarjeta-alarma-arte'),
|
||||
decoration: BoxDecoration(
|
||||
color: tokens.listSurface,
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
child: SizedBox(
|
||||
width: 22,
|
||||
height: 22,
|
||||
child: Icon(
|
||||
Icons.radio_rounded,
|
||||
size: 14,
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.onSurface
|
||||
.withValues(alpha: 0.75),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 7),
|
||||
],
|
||||
Flexible(
|
||||
child: Text(
|
||||
estacion,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontSize: 12.5,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Theme.of(context).colorScheme.onSurface
|
||||
.withValues(alpha: 0.75),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(estacion, overflow: TextOverflow.ellipsis),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -525,6 +647,15 @@ class _EditorAlarmaSheetState extends State<_EditorAlarmaSheet> {
|
||||
(value) => setState(() => _tipo = value.first),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
// Audit 8.5 (t4 line 381): the "REPETIR" eyebrow above the
|
||||
// weekday circles -- never rendered anywhere before.
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(4, 0, 4, 8),
|
||||
child: Text(
|
||||
l10n.alarmRepeatSectionLabel,
|
||||
style: context.pluriType.eyebrowLabel,
|
||||
),
|
||||
),
|
||||
// WU10: weekday circles are now ALWAYS visible (previously
|
||||
// only inserted into the tree in diasSemana mode) — matching
|
||||
// the mockup, which shows them unconditionally under the
|
||||
@@ -566,80 +697,117 @@ class _EditorAlarmaSheetState extends State<_EditorAlarmaSheet> {
|
||||
const SizedBox(height: 12),
|
||||
_vistaProximaEjecucion(l10n),
|
||||
const SizedBox(height: 14),
|
||||
_SectionLabel(
|
||||
icon: 'assets/icons/alarmas/fallback_sound.png',
|
||||
text: l10n.soundAndVolumeSection,
|
||||
),
|
||||
Slider(
|
||||
value: _volumen,
|
||||
// S2-R11: floor lowered from 0.25 to 0.0.
|
||||
min: 0,
|
||||
max: 1,
|
||||
divisions: 20,
|
||||
label: '${(_volumen * 100).round()}%',
|
||||
onChanged: (value) => setState(() => _volumen = value),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
ListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
title: Text(
|
||||
l10n.alarmFadeInTitle,
|
||||
style: context.pluriType.cardTitle,
|
||||
// Audit 8.6 (t4 lines 392-401): the station picker, volume,
|
||||
// fade-in and vacation toggle now share ONE bordered card
|
||||
// with sangred divider lines between rows -- matching the
|
||||
// prototype's own single grouped block -- instead of
|
||||
// sitting bare on the sheet, each with its own gaps. The
|
||||
// extra fields the prototype does NOT show (name, type
|
||||
// selector, snooze selector, "use current station",
|
||||
// Advanced) stay OUTSIDE this card, exactly where they
|
||||
// were (documented as deliberate in audit 8.8 / WU10).
|
||||
DecoratedBox(
|
||||
key: const ValueKey('alarm-editor-grouped-card'),
|
||||
decoration: BoxDecoration(
|
||||
color: context.pluriTokens.listSurface,
|
||||
borderRadius: BorderRadius.circular(18),
|
||||
border: Border.all(
|
||||
color: Colors.white.withValues(alpha: 0.08),
|
||||
),
|
||||
),
|
||||
subtitle: Text(
|
||||
_fadeInSegundos == 0
|
||||
? l10n.alarmFadeInOff
|
||||
: l10n.alarmFadeInSummary(_fadeInSegundos),
|
||||
// A transparent Material sits directly inside the coloured
|
||||
// box, closer to the fade-in ListTile and the vacation
|
||||
// SwitchListTile than the DecoratedBox's own opaque fill
|
||||
// -- both paint their ink/background on the NEAREST
|
||||
// Material ancestor, and without this the box's colour
|
||||
// would hide those effects (Flutter's own debug-mode
|
||||
// check for exactly this).
|
||||
child: Material(
|
||||
type: MaterialType.transparency,
|
||||
child: Column(
|
||||
children: [
|
||||
// S2-R9: searchable bottom-sheet picker instead of a
|
||||
// dropdown, for the primary station. The backup
|
||||
// (fallback) picker moves into the Advanced section
|
||||
// below (WU10) — the primary choice stays a
|
||||
// top-level field, only its secondary/backup sibling
|
||||
// is now one tap further away.
|
||||
_CampoSelectorEmisora(
|
||||
key: const ValueKey('alarm-station-field'),
|
||||
label: l10n.favoriteStationLabel,
|
||||
icon: Icons.radio_rounded,
|
||||
value:
|
||||
_emisora == null
|
||||
? l10n.noStationUseInternalSound
|
||||
: localizedStationName(
|
||||
l10n,
|
||||
_emisora!.nombre,
|
||||
),
|
||||
onTap:
|
||||
() => _elegirEmisora(
|
||||
favoritas,
|
||||
seleccionar:
|
||||
(emisora) =>
|
||||
setState(() => _emisora = emisora),
|
||||
),
|
||||
),
|
||||
const Divider(height: 1, indent: 66),
|
||||
// Audit 8.7 (t4 line 396): a compact 112px track with
|
||||
// a trailing percentage label -- was a bare
|
||||
// full-width Slider with no visible value.
|
||||
_FilaVolumen(
|
||||
volumen: _volumen,
|
||||
onChanged:
|
||||
(value) => setState(() => _volumen = value),
|
||||
),
|
||||
const Divider(height: 1, indent: 46),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(14, 4, 14, 4),
|
||||
child: ListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
title: Text(
|
||||
l10n.alarmFadeInTitle,
|
||||
style: context.pluriType.cardTitle,
|
||||
),
|
||||
subtitle: Text(
|
||||
_fadeInSegundos == 0
|
||||
? l10n.alarmFadeInOff
|
||||
: l10n.alarmFadeInSummary(_fadeInSegundos),
|
||||
),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10),
|
||||
child: Slider(
|
||||
value: _fadeInSegundos.toDouble(),
|
||||
min: 0,
|
||||
max: 60,
|
||||
divisions: 60,
|
||||
label: '${_fadeInSegundos}s',
|
||||
onChanged:
|
||||
(value) => setState(
|
||||
() => _fadeInSegundos = value.round(),
|
||||
),
|
||||
),
|
||||
),
|
||||
const Divider(height: 1, indent: 46),
|
||||
SwitchListTile.adaptive(
|
||||
value: _sonarEnVacaciones,
|
||||
onChanged:
|
||||
(value) =>
|
||||
setState(() => _sonarEnVacaciones = value),
|
||||
secondary: _AssetIcon(
|
||||
'assets/icons/alarmas/vacation_wave.png',
|
||||
size: 42,
|
||||
semanticLabel: l10n.vacationIconLabel,
|
||||
),
|
||||
title: Text(l10n.playDuringVacations),
|
||||
subtitle: Text(l10n.playDuringVacationsHint),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
Slider(
|
||||
value: _fadeInSegundos.toDouble(),
|
||||
min: 0,
|
||||
max: 60,
|
||||
divisions: 60,
|
||||
label: '${_fadeInSegundos}s',
|
||||
onChanged:
|
||||
(value) =>
|
||||
setState(() => _fadeInSegundos = value.round()),
|
||||
),
|
||||
ListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
title: Text(l10n.alarmSnoozeDurationTitle),
|
||||
subtitle: Text(l10n.alarmSnoozeOptionLabel(_snoozeMinutos)),
|
||||
),
|
||||
SegmentedButton<int>(
|
||||
segments: [
|
||||
for (final minutos in _opcionesSnooze())
|
||||
ButtonSegment(
|
||||
value: minutos,
|
||||
label: Text(l10n.alarmSnoozeOptionLabel(minutos)),
|
||||
),
|
||||
],
|
||||
selected: {_snoozeMinutos},
|
||||
onSelectionChanged:
|
||||
(value) => setState(() => _snoozeMinutos = value.first),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
// S2-R9: searchable bottom-sheet picker instead of a dropdown,
|
||||
// for the primary station. The backup (fallback) picker moves
|
||||
// into the Advanced section below (WU10) — the primary choice
|
||||
// stays a top-level field, only its secondary/backup sibling
|
||||
// is now one tap further away.
|
||||
_CampoSelectorEmisora(
|
||||
key: const ValueKey('alarm-station-field'),
|
||||
label: l10n.favoriteStationLabel,
|
||||
icon: Icons.radio_rounded,
|
||||
value:
|
||||
_emisora == null
|
||||
? l10n.noStationUseInternalSound
|
||||
: localizedStationName(l10n, _emisora!.nombre),
|
||||
onTap:
|
||||
() => _elegirEmisora(
|
||||
favoritas,
|
||||
seleccionar:
|
||||
(emisora) => setState(() => _emisora = emisora),
|
||||
),
|
||||
),
|
||||
if (favoritas.isEmpty) ...[
|
||||
const SizedBox(height: 6),
|
||||
Text(l10n.saveFavoritesAlarmHint),
|
||||
@@ -656,19 +824,23 @@ class _EditorAlarmaSheetState extends State<_EditorAlarmaSheet> {
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 8),
|
||||
SwitchListTile.adaptive(
|
||||
const SizedBox(height: 12),
|
||||
ListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
value: _sonarEnVacaciones,
|
||||
onChanged:
|
||||
(value) => setState(() => _sonarEnVacaciones = value),
|
||||
secondary: _AssetIcon(
|
||||
'assets/icons/alarmas/vacation_wave.png',
|
||||
size: 42,
|
||||
semanticLabel: l10n.vacationIconLabel,
|
||||
),
|
||||
title: Text(l10n.playDuringVacations),
|
||||
subtitle: Text(l10n.playDuringVacationsHint),
|
||||
title: Text(l10n.alarmSnoozeDurationTitle),
|
||||
subtitle: Text(l10n.alarmSnoozeOptionLabel(_snoozeMinutos)),
|
||||
),
|
||||
SegmentedButton<int>(
|
||||
segments: [
|
||||
for (final minutos in _opcionesSnooze())
|
||||
ButtonSegment(
|
||||
value: minutos,
|
||||
label: Text(l10n.alarmSnoozeOptionLabel(minutos)),
|
||||
),
|
||||
],
|
||||
selected: {_snoozeMinutos},
|
||||
onSelectionChanged:
|
||||
(value) => setState(() => _snoozeMinutos = value.first),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
// WU10 (native-alarms delta — Alarm Editor Preserves Date,
|
||||
@@ -911,18 +1083,122 @@ class _CampoSelectorEmisora extends StatelessWidget {
|
||||
final String value;
|
||||
final VoidCallback onTap;
|
||||
|
||||
/// Audit 8.6 (t4:394): a flat nav row -- icon, bold label, muted value,
|
||||
/// chevron -- matching the same convention `FilaAjuste` uses everywhere
|
||||
/// else in Settings, so this fits cleanly inside the grouped card below
|
||||
/// instead of drawing its own outlined `InputDecoration` chrome.
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return InkWell(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
onTap: onTap,
|
||||
child: InputDecorator(
|
||||
decoration: InputDecoration(
|
||||
labelText: label,
|
||||
prefixIcon: Icon(icon),
|
||||
suffixIcon: const Icon(Icons.arrow_drop_down_rounded),
|
||||
final type = context.pluriType;
|
||||
return Material(
|
||||
type: MaterialType.transparency,
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 11),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(icon, size: 20),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(label, style: type.cardTitle),
|
||||
Text(
|
||||
value,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: type.bodyStrong.copyWith(
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.onSurface.withValues(alpha: 0.55),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Icon(
|
||||
Icons.chevron_right_rounded,
|
||||
size: 19,
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.onSurface.withValues(alpha: 0.4),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
child: Text(value, overflow: TextOverflow.ellipsis),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Audit 8.7 (t4 line 396): a compact volume row -- icon, label, a 112px
|
||||
/// track, and a trailing "80%" -- was a bare full-width `Slider` with no
|
||||
/// visible current value. Constrains a real, still-draggable `Slider`
|
||||
/// (not a static bar) to the prototype's 112px track width via a
|
||||
/// `SliderTheme` + fixed-width `SizedBox`, preserving drag interactivity.
|
||||
class _FilaVolumen extends StatelessWidget {
|
||||
const _FilaVolumen({required this.volumen, required this.onChanged});
|
||||
|
||||
final double volumen;
|
||||
final ValueChanged<double> onChanged;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final onSurface = Theme.of(context).colorScheme.onSurface;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 4),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.volume_up_rounded,
|
||||
size: 20,
|
||||
color: onSurface.withValues(alpha: 0.7),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
l10n.alarmVolumeLabel,
|
||||
style: context.pluriType.cardTitle,
|
||||
),
|
||||
),
|
||||
SizedBox(
|
||||
width: 112,
|
||||
height: 24,
|
||||
child: SliderTheme(
|
||||
data: SliderTheme.of(context).copyWith(
|
||||
trackHeight: 4,
|
||||
activeTrackColor: PluriWaveTokens.brand,
|
||||
inactiveTrackColor: Colors.white.withValues(alpha: 0.14),
|
||||
thumbShape: const RoundSliderThumbShape(enabledThumbRadius: 7),
|
||||
overlayShape: const RoundSliderOverlayShape(overlayRadius: 14),
|
||||
),
|
||||
child: Slider(
|
||||
value: volumen,
|
||||
// S2-R11: floor lowered from 0.25 to 0.0.
|
||||
min: 0,
|
||||
max: 1,
|
||||
divisions: 20,
|
||||
label: '${(volumen * 100).round()}%',
|
||||
onChanged: onChanged,
|
||||
),
|
||||
),
|
||||
),
|
||||
SizedBox(
|
||||
width: 34,
|
||||
child: Text(
|
||||
'${(volumen * 100).round()}%',
|
||||
textAlign: TextAlign.right,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w800,
|
||||
color: onSurface.withValues(alpha: 0.7),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -1082,6 +1358,12 @@ class _PanelVacaciones extends StatelessWidget {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final tokens = context.pluriTokens;
|
||||
final resumen = _resumenVacaciones(l10n, estado.vacaciones);
|
||||
// Audit 7.3 (t4:332): a trailing date-range pill ("4-18 AGO") for the
|
||||
// active-or-next range -- never rendered anywhere before.
|
||||
final proximas = estado.vacacionesProximas();
|
||||
final rangoRelevante =
|
||||
estado.rangoVacacionesActivo() ??
|
||||
(proximas.isEmpty ? null : proximas.first);
|
||||
return PluriGlassSurface(
|
||||
glowColor: PluriWaveTokens.skyBlue.withValues(alpha: 0.22),
|
||||
padding: EdgeInsets.zero,
|
||||
@@ -1115,6 +1397,10 @@ class _PanelVacaciones extends StatelessWidget {
|
||||
],
|
||||
),
|
||||
),
|
||||
if (rangoRelevante != null) ...[
|
||||
const SizedBox(width: 8),
|
||||
_PildoraFechasVacaciones(rango: rangoRelevante),
|
||||
],
|
||||
const Icon(Icons.chevron_right_rounded),
|
||||
],
|
||||
),
|
||||
@@ -1161,6 +1447,40 @@ class _PanelVacaciones extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
/// Audit 7.3 (t4:332): "4-18 AGO" -- `radius:999`, `liveGreen@.16` fill,
|
||||
/// `liveGreen@.38` border, 10.5px/w800 in `liveGreen` (matches the
|
||||
/// prototype's own `rgba(126,228,194,...)` teal, not the alarm banner's
|
||||
/// warmCoral).
|
||||
class _PildoraFechasVacaciones extends StatelessWidget {
|
||||
const _PildoraFechasVacaciones({required this.rango});
|
||||
|
||||
final RangoVacaciones rango;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tokens = context.pluriTokens;
|
||||
final locale = AppLocalizations.of(context).localeName;
|
||||
return DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
color: tokens.liveGreen.withValues(alpha: 0.16),
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
border: Border.all(color: tokens.liveGreen.withValues(alpha: 0.38)),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 9, vertical: 4),
|
||||
child: Text(
|
||||
rangoFechasCorto(locale, rango.inicioDia, rango.finDia),
|
||||
style: TextStyle(
|
||||
fontSize: 10.5,
|
||||
fontWeight: FontWeight.w800,
|
||||
color: tokens.liveGreen,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Audit 8.4 (t4 lines 383-390): one circular weekday button in the alarm
|
||||
/// editor's REPETIR row — was a `FilterChip`. `aspect-ratio:1` in the
|
||||
/// prototype is achieved here by the caller wrapping each instance in an
|
||||
@@ -1280,27 +1600,6 @@ class _PickerButton extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
class _SectionLabel extends StatelessWidget {
|
||||
const _SectionLabel({required this.icon, required this.text});
|
||||
|
||||
final String icon;
|
||||
final String text;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Row(
|
||||
children: [
|
||||
_AssetIcon(icon, size: 34),
|
||||
const SizedBox(width: 8),
|
||||
// WU10: swapped the raw TextTheme lookup for the named type-scale
|
||||
// token (cosmetic only — same weight class, now shared with every
|
||||
// other card/section title in the redesign).
|
||||
Text(text, style: context.pluriType.cardTitle),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _NoticeLine extends StatelessWidget {
|
||||
const _NoticeLine({super.key, required this.icon, required this.text});
|
||||
|
||||
@@ -1368,3 +1667,17 @@ String _weekdayShort(AppLocalizations l10n, int day) => switch (day) {
|
||||
// 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);
|
||||
|
||||
/// Audit 7.4 (t4:339): a compact recurrence label next to the alarm card's
|
||||
/// giant time. Reuses the SAME generic labels the editor's own
|
||||
/// `TipoProgramacionAlarma` `SegmentedButton` already shows (`oneTimeOption`
|
||||
/// / `dailyOption` / `weekdaysOption`) rather than inventing a new, more
|
||||
/// specific ARB string -- honest given the space (12px, next to a 34px
|
||||
/// time) genuinely only fits a short word, not a full weekday list.
|
||||
String _recurrenciaCorta(AppLocalizations l10n, AlarmaMusical alarma) {
|
||||
return switch (alarma.tipoProgramacion) {
|
||||
TipoProgramacionAlarma.diaria => l10n.dailyOption,
|
||||
TipoProgramacionAlarma.diasSemana => l10n.weekdaysOption,
|
||||
TipoProgramacionAlarma.unica => l10n.oneTimeOption,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -71,31 +71,46 @@ class PantallaBienvenida extends StatelessWidget {
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Image.asset(
|
||||
'assets/icons/pluriwave_app_mark.png',
|
||||
key: const ValueKey('welcome-logo'),
|
||||
width: 76,
|
||||
height: 76,
|
||||
errorBuilder:
|
||||
(_, __, ___) => Icon(
|
||||
Icons.graphic_eq_rounded,
|
||||
size: 76,
|
||||
color: t.electricMagenta,
|
||||
),
|
||||
// Audit 14.8 (t4 line 693): the prototype clips
|
||||
// its logo mark to a 20px rounded rect; the build
|
||||
// used to paint it square.
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
child: Image.asset(
|
||||
'assets/icons/pluriwave_app_mark.png',
|
||||
key: const ValueKey('welcome-logo'),
|
||||
width: 76,
|
||||
height: 76,
|
||||
errorBuilder:
|
||||
(_, __, ___) => Icon(
|
||||
Icons.graphic_eq_rounded,
|
||||
size: 76,
|
||||
color: t.electricMagenta,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 22),
|
||||
Text(
|
||||
l10n.welcomeHeadline,
|
||||
// Audit 14.3 (t4 line 696): 34px/ls-1.2, not
|
||||
// headlineMedium's 28/ls-1.0 — a local override,
|
||||
// matching the precedent already set by every
|
||||
// other one-off size correction in this screen
|
||||
// (14.4-14.6 below).
|
||||
style: theme.textTheme.headlineMedium?.copyWith(
|
||||
fontSize: 34,
|
||||
fontWeight: FontWeight.w800,
|
||||
height: 1.05,
|
||||
letterSpacing: -1.0,
|
||||
letterSpacing: -1.2,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
l10n.welcomeBody,
|
||||
// Audit 14.4 (t4 line 697): 14.5px, not
|
||||
// bodyMedium's 14.
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
fontSize: 14.5,
|
||||
color: theme.colorScheme.onSurface.withValues(
|
||||
alpha: 0.72,
|
||||
),
|
||||
@@ -109,14 +124,16 @@ class PantallaBienvenida extends StatelessWidget {
|
||||
titulo: l10n.welcomeBullet1Title,
|
||||
subtitulo: l10n.welcomeBullet1Subtitle,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
// Audit 14.6 (t4 line 701): 12px between bullets,
|
||||
// not 16.
|
||||
const SizedBox(height: 12),
|
||||
FilaCaracteristicaBienvenida(
|
||||
icon: Icons.directions_car_rounded,
|
||||
iconColor: t.liveGreen,
|
||||
titulo: l10n.welcomeBullet2Title,
|
||||
subtitulo: l10n.welcomeBullet2Subtitle,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
const SizedBox(height: 12),
|
||||
FilaCaracteristicaBienvenida(
|
||||
icon: Icons.alarm_rounded,
|
||||
iconColor: t.warmCoral,
|
||||
@@ -132,6 +149,14 @@ class PantallaBienvenida extends StatelessWidget {
|
||||
height: 58,
|
||||
child: FilledButton(
|
||||
onPressed: () => _empezar(context),
|
||||
// Audit 14.7 (t4 line 715): radius 18 — Material 3's
|
||||
// default FilledButton shape is a fully-round
|
||||
// StadiumBorder, which the prototype does not draw.
|
||||
style: FilledButton.styleFrom(
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(18),
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
l10n.welcomeCtaLabel,
|
||||
style: const TextStyle(
|
||||
@@ -196,13 +221,18 @@ class FilaCaracteristicaBienvenida extends StatelessWidget {
|
||||
children: [
|
||||
Text(
|
||||
titulo,
|
||||
// Audit 14.5 (t4 line 700): 13.5px/w800, not bodyMedium's
|
||||
// 14/w800.
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
fontSize: 13.5,
|
||||
fontWeight: FontWeight.w800,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
subtitulo,
|
||||
// Audit 14.5 (t4 line 700): 11.5px, not bodySmall's 12.
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
fontSize: 11.5,
|
||||
color: theme.colorScheme.onSurface.withValues(alpha: 0.58),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -6,11 +6,14 @@ import 'package:shimmer/shimmer.dart' as shimmer;
|
||||
|
||||
import '../estado/estado_busqueda.dart';
|
||||
import '../estado/estado_radio.dart';
|
||||
import '../l10n/display_names.dart';
|
||||
import '../l10n/gen/app_localizations.dart';
|
||||
import '../modelos/emisora.dart';
|
||||
import '../servicios/servicio_audio.dart';
|
||||
import '../tema/pluri_animate.dart';
|
||||
import '../tema/pluriwave_theme.dart';
|
||||
import '../tema/pluriwave_tokens.dart';
|
||||
import '../widgets/fila_emisora_plana.dart';
|
||||
import '../widgets/pluri_glass_surface.dart';
|
||||
import '../widgets/pluri_icon.dart';
|
||||
import '../widgets/pluri_layout.dart';
|
||||
@@ -214,6 +217,10 @@ class _PantallaBuscarState extends State<PantallaBuscar> {
|
||||
theme,
|
||||
l10n,
|
||||
),
|
||||
// Item 25 / audit 13.2 (t4:643-646): the reconnect card -- until
|
||||
// now the ONLY signal of a reconnect was a word in the mini
|
||||
// player.
|
||||
_TarjetaReconectando(estado: context.watch<EstadoRadio>()),
|
||||
_gridEmisoras(context, l10n),
|
||||
],
|
||||
],
|
||||
@@ -246,10 +253,25 @@ class _PantallaBuscarState extends State<PantallaBuscar> {
|
||||
}),
|
||||
];
|
||||
|
||||
if (pills.isEmpty && estado.resultados.isEmpty) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
// Audit 6.3 (t4:294-295): "Idioma" is an always-reachable entry chip
|
||||
// once a search is active -- there was no standalone entry point for
|
||||
// language filtering before this (only bundled inside "Filtros").
|
||||
// Opens the SAME existing filter sheet (all 3 sections) rather than a
|
||||
// new idioma-only picker: "Ordenar" is a genuinely separate control
|
||||
// (not part of that sheet), and "Filtros" itself must stay reachable
|
||||
// from the header regardless of active-filter state -- the quality
|
||||
// (bitrate) filter has no OTHER entry point anywhere in the app, so
|
||||
// this chip is additive, not a replacement.
|
||||
final entryChips = <Widget>[
|
||||
ActionChip(
|
||||
label: Text(l10n.searchLanguageFilterLabel),
|
||||
onPressed: _abrirFiltros,
|
||||
),
|
||||
];
|
||||
|
||||
// Audit 6.3: entryChips is never empty (always carries "Idioma"), so
|
||||
// this row now always renders while a search is active -- the old
|
||||
// "nothing to show" early return no longer applies.
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(
|
||||
PluriLayout.horizontal,
|
||||
@@ -260,18 +282,16 @@ class _PantallaBuscarState extends State<PantallaBuscar> {
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (pills.isNotEmpty)
|
||||
Wrap(spacing: 8, runSpacing: 8, children: pills),
|
||||
if (pills.isNotEmpty && estado.resultados.isNotEmpty)
|
||||
Wrap(spacing: 8, runSpacing: 8, children: [...pills, ...entryChips]),
|
||||
if (!estado.cargando && estado.resultados.isNotEmpty) ...[
|
||||
const SizedBox(height: 10),
|
||||
if (!estado.cargando && estado.resultados.isNotEmpty)
|
||||
Row(
|
||||
children: [
|
||||
// Audit 6.4 (t4:299): eyebrow styling (11/w800/ls.06em) --
|
||||
// was labelLarge (14/w800).
|
||||
Text(
|
||||
l10n.searchResultsCount(estado.resultados.length),
|
||||
style: theme.textTheme.labelLarge?.copyWith(
|
||||
fontWeight: FontWeight.w800,
|
||||
),
|
||||
style: context.pluriType.eyebrowLabel,
|
||||
),
|
||||
const Spacer(),
|
||||
PopupMenuButton<OrdenEmisoras>(
|
||||
@@ -299,16 +319,28 @@ class _PantallaBuscarState extends State<PantallaBuscar> {
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Audit 6.2 (t4:292-293): brand-teal tinted, radius 10, an inline 15px
|
||||
/// close glyph -- was Material's own `Chip` theming.
|
||||
Widget _pillFiltro(String label, VoidCallback onDeleted) {
|
||||
return Chip(
|
||||
label: Text(label),
|
||||
labelStyle: const TextStyle(
|
||||
fontWeight: FontWeight.w800,
|
||||
color: Color(0xFFF2F7FA),
|
||||
),
|
||||
backgroundColor: PluriWaveTokens.brand.withValues(alpha: 0.2),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
side: BorderSide(color: PluriWaveTokens.brand.withValues(alpha: 0.45)),
|
||||
),
|
||||
onDeleted: onDeleted,
|
||||
deleteIcon: const Icon(Icons.close, size: 18),
|
||||
deleteIcon: const Icon(Icons.close, size: 15),
|
||||
visualDensity: VisualDensity.compact,
|
||||
);
|
||||
}
|
||||
@@ -514,10 +546,21 @@ class _PantallaBuscarState extends State<PantallaBuscar> {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(PluriLayout.horizontal),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Audit 13.3 (t4:649): the "BUSCANDO EMISORAS…" eyebrow --
|
||||
// never rendered before.
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 10),
|
||||
child: Text(
|
||||
l10n.searchLoadingStationsLabel,
|
||||
style: context.pluriType.eyebrowLabel,
|
||||
),
|
||||
),
|
||||
for (var i = 0; i < 4; i++) ...[
|
||||
const TarjetaEmisoraShimmer(esCompacta: true),
|
||||
if (i < 3) const SizedBox(height: 10),
|
||||
// Audit 13.4 (t4:651): 4px between skeleton rows, not 10.
|
||||
if (i < 3) const SizedBox(height: 4),
|
||||
],
|
||||
],
|
||||
),
|
||||
@@ -528,46 +571,53 @@ class _PantallaBuscarState extends State<PantallaBuscar> {
|
||||
|
||||
if (resultados.isEmpty) {
|
||||
final sinFiltros = _controller.text.isEmpty && _filtrosActivosCount == 0;
|
||||
return Column(
|
||||
children: [
|
||||
SizedBox(
|
||||
height: 260,
|
||||
child: PluriEmptyState(
|
||||
glyph: PluriIconGlyph.search,
|
||||
title:
|
||||
sinFiltros
|
||||
? l10n.searchEmptyTitle
|
||||
: l10n.searchNoResultsTitle,
|
||||
subtitle:
|
||||
sinFiltros
|
||||
? l10n.searchEmptySubtitle
|
||||
: l10n.searchNoResultsSubtitle,
|
||||
),
|
||||
),
|
||||
// Audit 13.5/13.6 (t4:657-668): a purpose-built card for the
|
||||
// search-no-results state -- centred, `listSurface`, the title
|
||||
// QUOTES the typed query, and the clear-filters pill sits INSIDE
|
||||
// the card. A NEW widget, not a restyle of the shared
|
||||
// `PluriEmptyState` (used by several OTHER unrelated empty states
|
||||
// across the app -- favorites, the discovery grid -- which this
|
||||
// item does not touch).
|
||||
final query = _controller.text.trim();
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: PluriLayout.horizontal),
|
||||
child: _TarjetaSinResultados(
|
||||
titulo:
|
||||
sinFiltros
|
||||
? l10n.searchEmptyTitle
|
||||
: query.isNotEmpty
|
||||
? l10n.searchNoResultsForQueryTitle(query)
|
||||
: l10n.searchNoResultsTitle,
|
||||
subtitulo:
|
||||
sinFiltros
|
||||
? l10n.searchEmptySubtitle
|
||||
: l10n.searchNoResultsSubtitle,
|
||||
// task 6.6 / spec "One-Tap Clear-All-Filters on Empty Results":
|
||||
// only offered once 1+ pill-filters are active AND the search
|
||||
// came back empty — not merely "no query typed yet".
|
||||
if (_filtrosActivosCount > 0)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 16),
|
||||
child: OutlinedButton(
|
||||
onPressed: _quitarTodosLosFiltros,
|
||||
child: Text(
|
||||
l10n.searchClearFiltersAction(_filtrosActivosCount),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
accionQuitarFiltros:
|
||||
_filtrosActivosCount > 0
|
||||
? (
|
||||
etiqueta: l10n.searchClearFiltersAction(
|
||||
_filtrosActivosCount,
|
||||
),
|
||||
onTap: _quitarTodosLosFiltros,
|
||||
)
|
||||
: null,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final total = resultados.length + (estado.hayMas ? 1 : 0);
|
||||
return ListView.separated(
|
||||
// Item 23 / audit 6.6 (t4:299): rows sit back-to-back -- `ListView`, not
|
||||
// `.separated`, since there is no longer a 10px gap to insert between
|
||||
// them (the prototype's own results column is a bare `flex-direction:
|
||||
// column`, no `gap`).
|
||||
return ListView.builder(
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
padding: const EdgeInsets.all(PluriLayout.horizontal),
|
||||
itemCount: total,
|
||||
separatorBuilder: (_, __) => const SizedBox(height: 10),
|
||||
itemBuilder: (context, i) {
|
||||
if (i >= resultados.length) {
|
||||
if (!estado.cargandoMas) {
|
||||
@@ -581,10 +631,21 @@ class _PantallaBuscarState extends State<PantallaBuscar> {
|
||||
if (i >= resultados.length - 5 && estado.hayMas) {
|
||||
Future<void>.microtask(estado.cargarMas);
|
||||
}
|
||||
return TarjetaEmisora(
|
||||
emisora: resultados[i],
|
||||
esCompacta: true,
|
||||
onTap: () => reproducirMinimizado(context, resultados[i]),
|
||||
final emisora = resultados[i];
|
||||
// Item 23 / audit 6.5 (t4:302-306): a flat, background-less row --
|
||||
// square art, name+meta, a favourite toggle, and a circular play
|
||||
// affordance -- replacing the full glass TarjetaEmisora card.
|
||||
return FilaEmisoraPlana(
|
||||
key: ValueKey(emisora.uuid),
|
||||
emisora: emisora,
|
||||
meta: _metaResultado(emisora),
|
||||
onTap: () => reproducirMinimizado(context, emisora),
|
||||
trailing: [
|
||||
BotonFavoritoEmisora(emisora: emisora),
|
||||
BotonReproducirCircular(
|
||||
onPressed: () => reproducirMinimizado(context, emisora),
|
||||
),
|
||||
],
|
||||
).pluriFadeSlideIn(
|
||||
context,
|
||||
delay: Duration(milliseconds: i.clamp(0, 12) * 20),
|
||||
@@ -886,7 +947,21 @@ class _PantallaBuscarState extends State<PantallaBuscar> {
|
||||
onTap:
|
||||
() => PluriPushScaffold.push(
|
||||
context,
|
||||
(_) => const PantallaPaises(),
|
||||
// Item 24 / audit 5.2: tapping a country row
|
||||
// pops back to Búsqueda and filters results by
|
||||
// that ISO code -- the SAME `EstadoBusqueda.
|
||||
// buscar(pais: ...)` filter the picker sheet
|
||||
// already uses, just with an arbitrary code
|
||||
// instead of one of this screen's ~10 presets.
|
||||
(_) => PantallaPaises(
|
||||
onPaisSeleccionado: (pais) {
|
||||
Navigator.of(context).pop();
|
||||
setState(
|
||||
() => _paisSeleccionado = pais.codigoIso,
|
||||
);
|
||||
_buscar();
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -1126,6 +1201,21 @@ class _PantallaBuscarState extends State<PantallaBuscar> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Item 23 / audit 6.5 (t4:303): "genre - country - kbps" built ONLY from
|
||||
/// fields [Emisora] already carries -- mirrors the Escuchar hero's own
|
||||
/// `_metaEscuchar` (audit 1.6, `pantalla_inicio.dart`) exactly, duplicated
|
||||
/// rather than shared since each screen's meta line is free to evolve
|
||||
/// independently. Whatever a station lacks is omitted gracefully.
|
||||
String _metaResultado(Emisora emisora) {
|
||||
final partes = <String>[
|
||||
if (emisora.generos.isNotEmpty) emisora.generos.first,
|
||||
if (emisora.pais != null && emisora.pais!.isNotEmpty) emisora.pais!,
|
||||
if (emisora.bitrate != null && emisora.bitrate! > 0)
|
||||
'${emisora.bitrate} kbps',
|
||||
];
|
||||
return partes.join(' · ');
|
||||
}
|
||||
|
||||
String _genreName(AppLocalizations l10n, String tag) => switch (tag) {
|
||||
'pop' => l10n.genrePop,
|
||||
'rock' => l10n.genreRock,
|
||||
@@ -1163,6 +1253,111 @@ class _ChipShimmer extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
/// Audit 13.5/13.6 (t4:657-668): search-no-results card -- centred,
|
||||
/// `listSurface`, radius 22 (matches neither of the other 2 named token
|
||||
/// radii, so this is a local one-off like `_CeldaExplorarPor`'s own),
|
||||
/// with the clear-filters pill living INSIDE the card rather than below
|
||||
/// it.
|
||||
class _TarjetaSinResultados extends StatelessWidget {
|
||||
const _TarjetaSinResultados({
|
||||
required this.titulo,
|
||||
required this.subtitulo,
|
||||
this.accionQuitarFiltros,
|
||||
});
|
||||
|
||||
final String titulo;
|
||||
final String subtitulo;
|
||||
final ({String etiqueta, VoidCallback onTap})? accionQuitarFiltros;
|
||||
|
||||
static const _radio = 22.0;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final accion = accionQuitarFiltros;
|
||||
return DecoratedBox(
|
||||
key: const ValueKey('search-no-results-card'),
|
||||
decoration: BoxDecoration(
|
||||
color: PluriWaveTokens.dark.listSurface,
|
||||
borderRadius: BorderRadius.circular(_radio),
|
||||
border: Border.all(color: Colors.white.withValues(alpha: 0.07)),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 26),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.search_off_rounded,
|
||||
size: 40,
|
||||
color: PluriWaveTokens.brand.withValues(alpha: 0.6),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
titulo,
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w800),
|
||||
),
|
||||
const SizedBox(height: 5),
|
||||
Text(
|
||||
subtitulo,
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
fontSize: 12.5,
|
||||
height: 1.5,
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.onSurface.withValues(alpha: 0.6),
|
||||
),
|
||||
),
|
||||
if (accion != null) ...[
|
||||
const SizedBox(height: 14),
|
||||
Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
onTap: accion.onTap,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 15,
|
||||
vertical: 9,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: PluriWaveTokens.brand.withValues(alpha: 0.18),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: PluriWaveTokens.brand.withValues(alpha: 0.4),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.filter_alt_off_rounded,
|
||||
size: 17,
|
||||
color: PluriWaveTokens.brand,
|
||||
),
|
||||
const SizedBox(width: 7),
|
||||
Text(
|
||||
accion.etiqueta,
|
||||
style: const TextStyle(
|
||||
fontSize: 12.5,
|
||||
fontWeight: FontWeight.w800,
|
||||
color: PluriWaveTokens.brand,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Audit 3.2 (t4 lines 167-170): one "Explorar por" grid cell — icon,
|
||||
/// title (13.5/w800) and subtitle (11/55%). Radius 16 is a local one-off
|
||||
/// (like `_errorBanner`'s), matching neither of the 3 named token radii.
|
||||
@@ -1235,3 +1430,169 @@ class _CeldaExplorarPor extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Item 25 / audit 13.2 (t4:643-646): the reconnect card. Renders nothing
|
||||
/// unless playback is actively reconnecting AND a station is known.
|
||||
///
|
||||
/// NO attempt counter: the prototype shows "Reconectando · intento 2 de 5",
|
||||
/// backed by `ControladorReconexion.intentos`/`.maxReintentos`
|
||||
/// (`controlador_reconexion.dart:42`/`:33`). That controller instance lives
|
||||
/// as a PRIVATE field of `PluriWaveAudioHandler` inside
|
||||
/// `servicio_audio.dart` -- a file this task requires stay byte-identical
|
||||
/// to `main` -- and nothing else in the app already re-exposes it
|
||||
/// publicly. Reconstructing a count from the public `estadoStream`'s
|
||||
/// `reconectando` emissions would not be a faithful proxy (the stream can
|
||||
/// emit `reconectando` many times per actual backoff attempt), so it was
|
||||
/// deliberately not attempted: a fabricated number is worse than none.
|
||||
class _TarjetaReconectando extends StatelessWidget {
|
||||
const _TarjetaReconectando({required this.estado});
|
||||
|
||||
final EstadoRadio estado;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
return StreamBuilder<EstadoReproduccion>(
|
||||
stream: estado.estadoStream,
|
||||
builder: (context, snapshot) {
|
||||
final emisora = estado.emisoraActual;
|
||||
if (snapshot.data != EstadoReproduccion.reconectando ||
|
||||
emisora == null) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(top: 16),
|
||||
child: _CuerpoTarjetaReconectando(
|
||||
nombreEstacion: localizedStationName(l10n, emisora.nombre),
|
||||
etiquetaReconectando: l10n.playbackStatusReconnecting,
|
||||
tooltipDetener: l10n.stopAction,
|
||||
onDetener: estado.detenerReproduccion,
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _CuerpoTarjetaReconectando extends StatefulWidget {
|
||||
const _CuerpoTarjetaReconectando({
|
||||
required this.nombreEstacion,
|
||||
required this.etiquetaReconectando,
|
||||
required this.tooltipDetener,
|
||||
required this.onDetener,
|
||||
});
|
||||
|
||||
final String nombreEstacion;
|
||||
final String etiquetaReconectando;
|
||||
final String tooltipDetener;
|
||||
final VoidCallback onDetener;
|
||||
|
||||
@override
|
||||
State<_CuerpoTarjetaReconectando> createState() =>
|
||||
_CuerpoTarjetaReconectandoState();
|
||||
}
|
||||
|
||||
class _CuerpoTarjetaReconectandoState extends State<_CuerpoTarjetaReconectando>
|
||||
with SingleTickerProviderStateMixin {
|
||||
// t4:644: `animation:pw-ring .9s linear infinite`.
|
||||
late final AnimationController _ctrl = AnimationController(
|
||||
vsync: this,
|
||||
duration: const Duration(milliseconds: 900),
|
||||
)..repeat();
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_ctrl.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final onSurface = Theme.of(context).colorScheme.onSurface;
|
||||
return Container(
|
||||
key: const ValueKey('tarjeta-reconectando'),
|
||||
margin: const EdgeInsets.symmetric(horizontal: PluriLayout.horizontal),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(18),
|
||||
border: Border.all(color: Colors.white.withValues(alpha: 0.2)),
|
||||
color: Colors.white.withValues(alpha: 0.09),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
// t4:644: a 52x52 rotating ring around a radio icon.
|
||||
SizedBox(
|
||||
width: 52,
|
||||
height: 52,
|
||||
child: Stack(
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
RotationTransition(
|
||||
turns: _ctrl,
|
||||
child: CircularProgressIndicator(
|
||||
value: 0.25,
|
||||
strokeWidth: 3,
|
||||
backgroundColor: PluriWaveTokens.brand.withValues(
|
||||
alpha: 0.22,
|
||||
),
|
||||
valueColor: AlwaysStoppedAnimation(PluriWaveTokens.brand),
|
||||
),
|
||||
),
|
||||
const Icon(
|
||||
Icons.radio_rounded,
|
||||
size: 22,
|
||||
color: PluriWaveTokens.brand,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// t4:645: 15px/w800.
|
||||
Text(
|
||||
widget.nombreEstacion,
|
||||
style: const TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w800,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
const SizedBox(height: 3),
|
||||
// t4:645: 12px/w700/warmCoral (amber).
|
||||
Text(
|
||||
widget.etiquetaReconectando,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: PluriWaveTokens.dark.warmCoral,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
// t4:646: stop_circle 24px, neutral (not brand-coloured) -- this
|
||||
// stops playback outright, unlike the mini player's retry icon.
|
||||
SizedBox(
|
||||
width: 44,
|
||||
height: 44,
|
||||
child: IconButton(
|
||||
padding: EdgeInsets.zero,
|
||||
tooltip: widget.tooltipDetener,
|
||||
icon: Icon(
|
||||
Icons.stop_circle_rounded,
|
||||
size: 24,
|
||||
color: onSurface.withValues(alpha: 0.6),
|
||||
),
|
||||
onPressed: widget.onDetener,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,13 +6,14 @@ import '../l10n/display_names.dart';
|
||||
import '../l10n/gen/app_localizations.dart';
|
||||
import '../modelos/emisora.dart';
|
||||
import '../modelos/grupo_favoritos.dart';
|
||||
import '../tema/pluriwave_tokens.dart';
|
||||
import '../widgets/fila_emisora_plana.dart';
|
||||
import '../widgets/pluri_icon.dart';
|
||||
import '../widgets/pluri_layout.dart';
|
||||
import '../widgets/pluri_premium_widgets.dart';
|
||||
import '../widgets/pluri_push_scaffold.dart';
|
||||
import '../widgets/pluri_root_header.dart';
|
||||
import '../widgets/pluri_sleep_timer_sheet.dart';
|
||||
import 'package:pluriwave/widgets/tarjeta_emisora.dart';
|
||||
import 'ajustes/pantalla_ajustes_emisoras_personalizadas.dart';
|
||||
import 'ajustes/pantalla_ajustes_grupos_favoritos.dart';
|
||||
|
||||
@@ -186,22 +187,23 @@ class _PantallaFavoritosState extends State<PantallaFavoritos> {
|
||||
children: [
|
||||
// S1/S2 (Tier 1 visual fidelity): see the empty-state branch
|
||||
// above — PluriScreenHeader is retired everywhere.
|
||||
//
|
||||
// Audit 4.1 (t4:216): the prototype's two header icon actions
|
||||
// (create_new_folder, swap_vert) now live in PluriRootHeader's
|
||||
// own actions slot -- they used to be scattered as an
|
||||
// ActionChip inside the chip strip and a PopupMenuButton
|
||||
// sharing a Row with it. The back arrow the prototype also
|
||||
// draws stays absent (binding decision: this root keeps its
|
||||
// bottom tab bar, unlike the prototype's own pushed shape).
|
||||
PluriRootHeader(
|
||||
title: l10n.favoritesTitle,
|
||||
onSleepTimer: () => showPluriSleepTimerSheet(context),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _FilaChipsGrupos(
|
||||
grupos: gruposVisibles,
|
||||
favoritos: favoritos,
|
||||
seleccionado: seleccionEfectiva,
|
||||
onSeleccionar:
|
||||
(id) => setState(() => _grupoSeleccionadoId = id),
|
||||
onGestionar: _abrirGestionDeListas,
|
||||
),
|
||||
actions: [
|
||||
IconButton(
|
||||
key: const ValueKey('favorites-manage-groups-action'),
|
||||
icon: const Icon(Icons.create_new_folder_rounded),
|
||||
tooltip: l10n.favoriteGroupsManage,
|
||||
onPressed: _abrirGestionDeListas,
|
||||
),
|
||||
PopupMenuButton<OrdenEmisoras>(
|
||||
icon: const Icon(Icons.swap_vert_rounded),
|
||||
@@ -221,6 +223,13 @@ class _PantallaFavoritosState extends State<PantallaFavoritos> {
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_FilaChipsGrupos(
|
||||
grupos: gruposVisibles,
|
||||
favoritos: favoritos,
|
||||
seleccionado: seleccionEfectiva,
|
||||
onSeleccionar: (id) => setState(() => _grupoSeleccionadoId = id),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -256,18 +265,45 @@ class _FilaChipsGrupos extends StatelessWidget {
|
||||
required this.favoritos,
|
||||
required this.seleccionado,
|
||||
required this.onSeleccionar,
|
||||
required this.onGestionar,
|
||||
});
|
||||
|
||||
final List<GrupoFavoritos> grupos;
|
||||
final List<Emisora> favoritos;
|
||||
final String? seleccionado;
|
||||
final ValueChanged<String?> onSeleccionar;
|
||||
final VoidCallback onGestionar;
|
||||
|
||||
String _nombreVisible(AppLocalizations l10n, GrupoFavoritos grupo) =>
|
||||
grupo.esSinAsignar ? l10n.favoriteGroupsUnassigned : grupo.nombre;
|
||||
|
||||
/// Audit 4.2 (t4:219-221): active `#21D4D9`/`#062126` w800, inactive
|
||||
/// `listSurface` + a faint border / w700 -- was Material's own
|
||||
/// `ChoiceChip` theming (a plain checkbox-style selected fill).
|
||||
Widget _chip({
|
||||
required String label,
|
||||
required bool selected,
|
||||
required VoidCallback onTap,
|
||||
}) {
|
||||
return ChoiceChip(
|
||||
label: Text(label),
|
||||
labelStyle: TextStyle(
|
||||
fontWeight: selected ? FontWeight.w800 : FontWeight.w700,
|
||||
color: selected ? const Color(0xFF062126) : const Color(0xFFF2F7FA),
|
||||
),
|
||||
selected: selected,
|
||||
showCheckmark: false,
|
||||
selectedColor: PluriWaveTokens.brand,
|
||||
backgroundColor: PluriWaveTokens.dark.listSurface,
|
||||
side: BorderSide(
|
||||
color:
|
||||
selected
|
||||
? Colors.transparent
|
||||
: Colors.white.withValues(alpha: 0.09),
|
||||
),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||
onSelected: (_) => onTap(),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
@@ -278,38 +314,27 @@ class _FilaChipsGrupos extends StatelessWidget {
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: 8),
|
||||
child: ChoiceChip(
|
||||
label: Text(
|
||||
l10n.favoriteGroupsChipLabel(
|
||||
l10n.favoritesFilterAllLabel,
|
||||
favoritos.length,
|
||||
),
|
||||
child: _chip(
|
||||
label: l10n.favoriteGroupsChipLabel(
|
||||
l10n.favoritesFilterAllLabel,
|
||||
favoritos.length,
|
||||
),
|
||||
selected: seleccionado == null,
|
||||
onSelected: (_) => onSeleccionar(null),
|
||||
onTap: () => onSeleccionar(null),
|
||||
),
|
||||
),
|
||||
for (final grupo in grupos)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: 8),
|
||||
child: ChoiceChip(
|
||||
label: Text(
|
||||
l10n.favoriteGroupsChipLabel(
|
||||
_nombreVisible(l10n, grupo),
|
||||
favoritos
|
||||
.where((e) => e.grupoFavoritosId == grupo.id)
|
||||
.length,
|
||||
),
|
||||
child: _chip(
|
||||
label: l10n.favoriteGroupsChipLabel(
|
||||
_nombreVisible(l10n, grupo),
|
||||
favoritos.where((e) => e.grupoFavoritosId == grupo.id).length,
|
||||
),
|
||||
selected: seleccionado == grupo.id,
|
||||
onSelected: (_) => onSeleccionar(grupo.id),
|
||||
onTap: () => onSeleccionar(grupo.id),
|
||||
),
|
||||
),
|
||||
ActionChip(
|
||||
avatar: const Icon(Icons.add_rounded, size: 18),
|
||||
label: Text(l10n.favoriteGroupsManage),
|
||||
onPressed: onGestionar,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
@@ -399,45 +424,65 @@ class _FilaFavorito extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8),
|
||||
child: Row(
|
||||
children: [
|
||||
ReorderableDragStartListener(
|
||||
index: index,
|
||||
child: const Padding(
|
||||
padding: EdgeInsets.only(right: 4),
|
||||
child: Icon(Icons.drag_handle_rounded),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: TarjetaEmisora(
|
||||
key: Key(emisora.uuid),
|
||||
emisora: emisora,
|
||||
esCompacta: true,
|
||||
onTap: () => reproducirMinimizado(context, emisora),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
IconButton.filledTonal(
|
||||
tooltip: l10n.favoriteGroupsAssignSubtitle(
|
||||
_nombreVisible(l10n, grupoActual),
|
||||
),
|
||||
icon: const Icon(Icons.drive_file_move_rounded),
|
||||
onPressed: () => _asignar(context),
|
||||
),
|
||||
IconButton.filledTonal(
|
||||
tooltip: l10n.favoritesRemoveTooltip,
|
||||
icon: const Icon(Icons.delete_outline_rounded),
|
||||
onPressed: () => _eliminar(context),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
final meta = [
|
||||
emisora.pais,
|
||||
emisora.idioma,
|
||||
].where((s) => s != null && s.isNotEmpty).join(' · ');
|
||||
|
||||
// Item 23 / audit 4.3 (t4:226-232): a flat, background-less row --
|
||||
// drag handle, square art, name+meta, and a circular play affordance --
|
||||
// replacing the full glass TarjetaEmisora card and its two stacked
|
||||
// filledTonal buttons. "Move to list"/"Remove from favorites" keep their
|
||||
// EXACT prior logic (`_asignar`/`_eliminar`, untouched), now reachable
|
||||
// from an overflow menu instead of two always-visible buttons -- the
|
||||
// prototype's row has no such menu, but dropping either capability
|
||||
// entirely would be a functional regression, not a fidelity fix.
|
||||
return FilaEmisoraPlana(
|
||||
key: Key(emisora.uuid),
|
||||
emisora: emisora,
|
||||
meta: meta,
|
||||
onTap: () => reproducirMinimizado(context, emisora),
|
||||
leading: ReorderableDragStartListener(
|
||||
index: index,
|
||||
child: Icon(
|
||||
// t4:227: drag_indicator, not drag_handle, at 22px/28%.
|
||||
Icons.drag_indicator_rounded,
|
||||
size: 22,
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.onSurface.withValues(alpha: 0.28),
|
||||
),
|
||||
),
|
||||
trailing: [
|
||||
BotonReproducirCircular(
|
||||
onPressed: () => reproducirMinimizado(context, emisora),
|
||||
),
|
||||
PopupMenuButton<String>(
|
||||
icon: Icon(
|
||||
Icons.more_vert_rounded,
|
||||
size: 20,
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.onSurface.withValues(alpha: 0.45),
|
||||
),
|
||||
constraints: const BoxConstraints.tightFor(width: 38, height: 42),
|
||||
onSelected: (accion) {
|
||||
if (accion == 'assign') _asignar(context);
|
||||
if (accion == 'remove') _eliminar(context);
|
||||
},
|
||||
itemBuilder:
|
||||
(context) => [
|
||||
PopupMenuItem(
|
||||
value: 'assign',
|
||||
child: Text(l10n.favoriteGroupsAssign),
|
||||
),
|
||||
PopupMenuItem(
|
||||
value: 'remove',
|
||||
child: Text(l10n.favoritesRemoveTooltip),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -454,24 +499,37 @@ class _CtaEmisoraPersonalizada extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final color = Theme.of(
|
||||
// Audit 4.5 (t4 line 235): the border and the label/icon are TWO
|
||||
// different opacities in the prototype — `rgba(255,255,255,.16)` for
|
||||
// the dashed stroke, `rgba(242,247,250,.6)` for the text/icon — not one
|
||||
// shared 50% colour for both.
|
||||
final colorBorde = Colors.white.withValues(alpha: 0.16);
|
||||
final colorTexto = Theme.of(
|
||||
context,
|
||||
).colorScheme.onSurface.withValues(alpha: 0.5);
|
||||
).colorScheme.onSurface.withValues(alpha: 0.6);
|
||||
return CustomPaint(
|
||||
painter: _DashedBorderPainter(color: color),
|
||||
painter: _DashedBorderPainter(color: colorBorde),
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
onTap: onTap,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 18, horizontal: 16),
|
||||
key: const Key('custom-station-cta-padding'),
|
||||
padding: const EdgeInsets.all(14),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(Icons.add_circle_outline_rounded, color: color),
|
||||
Icon(Icons.add_rounded, size: 20, color: colorTexto),
|
||||
const SizedBox(width: 8),
|
||||
Text(l10n.customStationsAddCta, style: TextStyle(color: color)),
|
||||
Text(
|
||||
l10n.customStationsAddCta,
|
||||
style: TextStyle(
|
||||
fontSize: 13.5,
|
||||
fontWeight: FontWeight.w800,
|
||||
color: colorTexto,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -8,6 +8,7 @@ import 'package:share_plus/share_plus.dart' show Share, XFile;
|
||||
import '../estado/estado_grabacion.dart';
|
||||
import '../l10n/gen/app_localizations.dart';
|
||||
import '../modelos/archivo_grabacion.dart';
|
||||
import '../tema/pluriwave_tokens.dart';
|
||||
import '../widgets/pluri_glass_surface.dart';
|
||||
import '../widgets/pluri_icon.dart';
|
||||
import '../widgets/pluri_layout.dart';
|
||||
@@ -231,7 +232,9 @@ class _PantallaGrabacionesState extends State<PantallaGrabaciones> {
|
||||
title: l10n.recordingsLibraryTitle,
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.settings_outlined),
|
||||
// Audit 12.1 (t4:610): the header action is `folder_open` at
|
||||
// 22px, not a generic gear.
|
||||
icon: const Icon(Icons.folder_open_rounded, size: 22),
|
||||
tooltip: l10n.recordingsLibrarySettingsTooltip,
|
||||
onPressed:
|
||||
() => PluriPushScaffold.push(
|
||||
@@ -257,20 +260,27 @@ class _PantallaGrabacionesState extends State<PantallaGrabaciones> {
|
||||
subtitle: l10n.recordingsLibraryEmptySubtitle,
|
||||
)
|
||||
else
|
||||
for (final archivo in archivos)
|
||||
_FilaGrabacion(
|
||||
archivo: archivo,
|
||||
reproduciendo:
|
||||
_reproductor.rutaActual == archivo.ruta &&
|
||||
_reproductor.reproduciendo,
|
||||
duracion: _duracionPara(archivo.ruta),
|
||||
formatearDuracion: _formatearDuracion,
|
||||
formatearFecha: _formatearFecha,
|
||||
formatearBytes: _formatearBytes,
|
||||
onAlternarReproduccion:
|
||||
() => _alternarReproduccion(archivo.ruta),
|
||||
onAccionMenu: (accion) => _manejarAccion(accion, archivo),
|
||||
),
|
||||
// t4:618: `gap:2px` between rows.
|
||||
PluriPanelColumn(
|
||||
gap: 2,
|
||||
children: [
|
||||
for (final archivo in archivos)
|
||||
_FilaGrabacion(
|
||||
archivo: archivo,
|
||||
reproduciendo:
|
||||
_reproductor.rutaActual == archivo.ruta &&
|
||||
_reproductor.reproduciendo,
|
||||
duracion: _duracionPara(archivo.ruta),
|
||||
formatearDuracion: _formatearDuracion,
|
||||
formatearFecha: _formatearFecha,
|
||||
formatearBytes: _formatearBytes,
|
||||
onAlternarReproduccion:
|
||||
() => _alternarReproduccion(archivo.ruta),
|
||||
onAccionMenu:
|
||||
(accion) => _manejarAccion(accion, archivo),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
@@ -298,13 +308,28 @@ class _BarraDeAlmacenamiento extends StatelessWidget {
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: LinearProgressIndicator(value: fraccion, minHeight: 8),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
// Audit 12.2 (t4 line 613): the bold "used of total" headline
|
||||
// sits ABOVE the bar -- was the bar first, then this same string
|
||||
// rendered small below it as the only caption.
|
||||
Text(
|
||||
l10n.recordingsLibraryStorageCaption(usadoMb, totalMb),
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.bodySmall?.copyWith(fontWeight: FontWeight.w800),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
// Audit 12.2: 6px bar, radius 3 (was minHeight 8, radius 8).
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(3),
|
||||
child: LinearProgressIndicator(value: fraccion, minHeight: 6),
|
||||
),
|
||||
const SizedBox(height: 7),
|
||||
// Audit 12.3 (t4 line 613): the real caption names the folder
|
||||
// and the purge policy -- the generic "X of Y used" line moved
|
||||
// up to become the headline above, it never described either of
|
||||
// those.
|
||||
Text(
|
||||
l10n.recordingsLibraryStorageFolderCaption,
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
],
|
||||
@@ -337,48 +362,116 @@ class _FilaGrabacion extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return PluriGlassSurface(
|
||||
child: ListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
leading: IconButton(
|
||||
icon: Icon(
|
||||
reproduciendo
|
||||
? Icons.pause_circle_filled_rounded
|
||||
: Icons.play_circle_fill_rounded,
|
||||
// Item 23 / audit 12.4 (t4:616-619): a flat, background-less row --
|
||||
// 44x44/radius-12 art placeholder (recordings carry no per-station
|
||||
// favicon, so this is a themed fallback square, not invented artwork),
|
||||
// name, meta line, a 24px play/pause affordance, and the SAME "-"
|
||||
// menu (Rename/Share/Delete) as before, just restyled.
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 10),
|
||||
child: Row(
|
||||
children: [
|
||||
ClipRRect(
|
||||
key: const ValueKey('fila-grabacion-arte'),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: Container(
|
||||
width: 44,
|
||||
height: 44,
|
||||
color: theme.colorScheme.primaryContainer,
|
||||
child: Icon(
|
||||
Icons.radio_rounded,
|
||||
size: 22,
|
||||
color: theme.colorScheme.onPrimaryContainer,
|
||||
),
|
||||
),
|
||||
),
|
||||
onPressed: onAlternarReproduccion,
|
||||
),
|
||||
title: Text(archivo.nombre),
|
||||
subtitle: FutureBuilder<Duration?>(
|
||||
future: duracion,
|
||||
builder: (context, snap) {
|
||||
return Text(
|
||||
'${formatearFecha(archivo.fecha)} · '
|
||||
'${formatearDuracion(snap.data)} · '
|
||||
'${formatearBytes(archivo.tamanoBytes)}',
|
||||
);
|
||||
},
|
||||
),
|
||||
trailing: PopupMenuButton<String>(
|
||||
icon: const Icon(Icons.more_vert_rounded),
|
||||
onSelected: onAccionMenu,
|
||||
itemBuilder:
|
||||
(context) => [
|
||||
PopupMenuItem(
|
||||
value: 'rename',
|
||||
child: Text(l10n.recordingActionRename),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// t4:619: 14.5px/w700.
|
||||
Text(
|
||||
archivo.nombre,
|
||||
style: const TextStyle(
|
||||
fontSize: 14.5,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
PopupMenuItem(
|
||||
value: 'share',
|
||||
child: Text(l10n.recordingActionShare),
|
||||
),
|
||||
PopupMenuItem(
|
||||
value: 'delete',
|
||||
child: Text(l10n.recordingActionDelete),
|
||||
// t4:619: 12px/rgba(242,247,250,.55).
|
||||
FutureBuilder<Duration?>(
|
||||
future: duracion,
|
||||
builder: (context, snap) {
|
||||
return Text(
|
||||
'${formatearFecha(archivo.fecha)} · '
|
||||
'${formatearDuracion(snap.data)} · '
|
||||
'${formatearBytes(archivo.tamanoBytes)}',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: theme.colorScheme.onSurface.withValues(
|
||||
alpha: 0.55,
|
||||
),
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
// t4:619: play_circle 24px brand teal in a 42x42 target.
|
||||
SizedBox(
|
||||
width: 42,
|
||||
height: 42,
|
||||
child: IconButton(
|
||||
padding: EdgeInsets.zero,
|
||||
tooltip: reproduciendo ? l10n.pauseAction : l10n.playAction,
|
||||
icon: Icon(
|
||||
reproduciendo
|
||||
? Icons.pause_circle_filled_rounded
|
||||
: Icons.play_circle_fill_rounded,
|
||||
size: 24,
|
||||
color: PluriWaveTokens.brand,
|
||||
),
|
||||
onPressed: onAlternarReproduccion,
|
||||
),
|
||||
),
|
||||
// t4:619: more_vert 20px/45% in a 38x42 target.
|
||||
SizedBox(
|
||||
width: 38,
|
||||
height: 42,
|
||||
child: PopupMenuButton<String>(
|
||||
padding: EdgeInsets.zero,
|
||||
icon: Icon(
|
||||
Icons.more_vert_rounded,
|
||||
size: 20,
|
||||
color: theme.colorScheme.onSurface.withValues(alpha: 0.45),
|
||||
),
|
||||
onSelected: onAccionMenu,
|
||||
itemBuilder:
|
||||
(context) => [
|
||||
PopupMenuItem(
|
||||
value: 'rename',
|
||||
child: Text(l10n.recordingActionRename),
|
||||
),
|
||||
PopupMenuItem(
|
||||
value: 'share',
|
||||
child: Text(l10n.recordingActionShare),
|
||||
),
|
||||
PopupMenuItem(
|
||||
value: 'delete',
|
||||
child: Text(l10n.recordingActionDelete),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -14,7 +14,6 @@ import '../tema/pluriwave_theme.dart';
|
||||
import '../widgets/pluri_glass_surface.dart';
|
||||
import '../widgets/pluri_layout.dart';
|
||||
import '../widgets/pluri_premium_widgets.dart';
|
||||
import '../widgets/pluri_root_header.dart';
|
||||
import '../widgets/pluri_sleep_timer_sheet.dart';
|
||||
import '../widgets/visualizador_audio.dart';
|
||||
|
||||
@@ -38,35 +37,46 @@ class _PantallaInicioState extends State<PantallaInicio> {
|
||||
final theme = Theme.of(context);
|
||||
final l10n = AppLocalizations.of(context);
|
||||
|
||||
return CustomScrollView(
|
||||
slivers: [
|
||||
// S1 (Tier 1 visual fidelity): the prototype has no global AppBar —
|
||||
// this root now draws its own 56px title row instead of relying on
|
||||
// app.dart's removed shared chrome (which is also where the
|
||||
// sleep-timer action used to live).
|
||||
SliverToBoxAdapter(
|
||||
child: PluriRootHeader(
|
||||
title: l10n.navHome,
|
||||
onSleepTimer: () => showPluriSleepTimerSheet(context),
|
||||
),
|
||||
),
|
||||
// WU5 built the hero; WU6 relocated the discovery sections that
|
||||
// used to follow it (_seccionCercanas, _seccionTendencias,
|
||||
// _chipGeneros, _errorBanner, the browse grid) into
|
||||
// PantallaBuscar's landing state and DELETED them here (this WU's
|
||||
// own task 6.5 — completing WU5's task 5.9 deferral). Escuchar's
|
||||
// content is now just the hero and the favorites preview below;
|
||||
// pull-to-refresh was dropped along with the grid it refreshed —
|
||||
// the retry button that used to live in the (now relocated) error
|
||||
// banner already covers manual recovery on Buscar.
|
||||
const SliverToBoxAdapter(child: _EscucharHero()),
|
||||
SliverToBoxAdapter(child: _seccionTusEmisoras(context, theme, l10n)),
|
||||
// ADR-7(b): the mini player is hidden on this whole screen
|
||||
// (app.dart), so its content needs less bottom padding than every
|
||||
// other root — escucharBottomChromeInset, not the plain
|
||||
// bottomChromeInset every other root/scrollable uses.
|
||||
const SliverToBoxAdapter(
|
||||
child: SizedBox(height: PluriLayout.escucharBottomChromeInset),
|
||||
// Audit 1.1 (t4 lines 49-51): a full-bleed art/gradient/noise wash sits
|
||||
// BEHIND the whole screen, not just inside the hero card -- the Stack
|
||||
// wrapper is new; everything that used to be the bare CustomScrollView
|
||||
// return value is unchanged below.
|
||||
return Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
const _FondoEscuchar(),
|
||||
CustomScrollView(
|
||||
slivers: [
|
||||
// Audit 1.2 (t4 line 53): unlike the other 3 roots' plain 56px
|
||||
// PluriRootHeader, Escuchar's own header is a 52px "Now
|
||||
// listening" eyebrow row -- the tab bar already identifies this
|
||||
// destination, so it never repeats "Escuchar" as a title.
|
||||
SliverToBoxAdapter(
|
||||
child: _CabeceraEscuchar(
|
||||
onSleepTimer: () => showPluriSleepTimerSheet(context),
|
||||
),
|
||||
),
|
||||
// WU5 built the hero; WU6 relocated the discovery sections that
|
||||
// used to follow it (_seccionCercanas, _seccionTendencias,
|
||||
// _chipGeneros, _errorBanner, the browse grid) into
|
||||
// PantallaBuscar's landing state and DELETED them here (this WU's
|
||||
// own task 6.5 — completing WU5's task 5.9 deferral). Escuchar's
|
||||
// content is now just the hero and the favorites preview below;
|
||||
// pull-to-refresh was dropped along with the grid it refreshed —
|
||||
// the retry button that used to live in the (now relocated) error
|
||||
// banner already covers manual recovery on Buscar.
|
||||
const SliverToBoxAdapter(child: _EscucharHero()),
|
||||
SliverToBoxAdapter(
|
||||
child: _seccionTusEmisoras(context, theme, l10n),
|
||||
),
|
||||
// ADR-7(b): the mini player is hidden on this whole screen
|
||||
// (app.dart), so its content needs less bottom padding than every
|
||||
// other root — escucharBottomChromeInset, not the plain
|
||||
// bottomChromeInset every other root/scrollable uses.
|
||||
const SliverToBoxAdapter(
|
||||
child: SizedBox(height: PluriLayout.escucharBottomChromeInset),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
@@ -107,19 +117,47 @@ class _PantallaInicioState extends State<PantallaInicio> {
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
l10n.yourStationsTitle,
|
||||
style: theme.textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w900,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Flexible(
|
||||
child: Text(
|
||||
l10n.yourStationsTitle,
|
||||
// S7 (visual fidelity, t4 line 80): the in-page
|
||||
// section heading style (15/w800/ls-.2), not
|
||||
// titleMedium(16)/w900.
|
||||
style: context.pluriType.inPageSectionHeading,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
// Audit 1.11 (t4 line 80): a count badge next to the
|
||||
// section title — the TOTAL favorite count, not the
|
||||
// 8-capped grid size shown below it.
|
||||
if (favoritos.isNotEmpty) ...[
|
||||
const SizedBox(width: 10),
|
||||
_InsigniaRecuento(cuenta: favoritos.length),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
TextButton(
|
||||
onPressed:
|
||||
() => context.read<EstadoNavegacionRaiz>().irA(
|
||||
RaizPluriWave.favoritos,
|
||||
// Audit 1.12 (t4 line 80): "Ver todas" is plain 12px/w800
|
||||
// brand-teal text — not a Material TextButton with its own
|
||||
// padding and splash.
|
||||
Semantics(
|
||||
button: true,
|
||||
child: GestureDetector(
|
||||
onTap:
|
||||
() => context.read<EstadoNavegacionRaiz>().irA(
|
||||
RaizPluriWave.favoritos,
|
||||
),
|
||||
child: Text(
|
||||
l10n.seeAllAction,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w800,
|
||||
color: context.pluriTokens.electricMagenta,
|
||||
),
|
||||
child: Text(l10n.seeAllAction),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -160,6 +198,134 @@ class _PantallaInicioState extends State<PantallaInicio> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Audit 1.1 (t4 lines 49-51): a full-bleed backdrop behind the ENTIRE
|
||||
/// screen -- 400px of the current station's own art at 50% opacity, a
|
||||
/// gradient fading it into the page background by 430px, and a faint noise
|
||||
/// texture. Reads `emisoraActual` ITSELF (not a parent watch), so this stays
|
||||
/// the only new subscriber -- `_PantallaInicioState.build()` keeps its
|
||||
/// documented S4-R5 "no root watch" rule. Renders the gradient/noise even
|
||||
/// with nothing playing (there is simply no image layer to show then) so
|
||||
/// the page keeps the same base depth in every state.
|
||||
class _FondoEscuchar extends StatelessWidget {
|
||||
const _FondoEscuchar();
|
||||
|
||||
static const double _altoArte = 400;
|
||||
static const double _altoDegradado = 430;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final emisora = context.select<EstadoRadio, Emisora?>(
|
||||
(e) => e.emisoraActual,
|
||||
);
|
||||
final tokens = context.pluriTokens;
|
||||
final favicon = emisora?.favicon;
|
||||
|
||||
return IgnorePointer(
|
||||
key: const ValueKey('escuchar-background-art'),
|
||||
child: Stack(
|
||||
children: [
|
||||
if (favicon != null && favicon.isNotEmpty)
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
height: _altoArte,
|
||||
child: Opacity(
|
||||
opacity: 0.5,
|
||||
child: CachedNetworkImage(
|
||||
imageUrl: favicon,
|
||||
fit: BoxFit.cover,
|
||||
errorWidget: (_, __, ___) => const SizedBox.shrink(),
|
||||
),
|
||||
),
|
||||
),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
height: _altoDegradado,
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [
|
||||
tokens.deepViolet.withValues(alpha: 0.4),
|
||||
tokens.deepViolet.withValues(alpha: 0.86),
|
||||
tokens.deepViolet,
|
||||
],
|
||||
stops: const [0, 0.58, 1],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
height: _altoDegradado,
|
||||
child: Opacity(
|
||||
opacity: 0.05,
|
||||
child: Image.asset(
|
||||
'assets/images/noise_soft.png',
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (_, __, ___) => const SizedBox.shrink(),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Audit 1.2 (t4 line 53): Escuchar's own header -- 52px, an eyebrow-styled
|
||||
/// "Now listening" label (not a screen title -- the tab bar already
|
||||
/// identifies this destination) and a single trailing action.
|
||||
///
|
||||
/// The prototype also draws a `cast` icon here. This app has no casting
|
||||
/// service anywhere in `lib/servicios` (checked before writing this) --
|
||||
/// inventing a Chromecast-style button with no backing capability would fail
|
||||
/// the same "don't invent a capability absent from the domain" test WU5/WU9
|
||||
/// already established elsewhere in this app, so it is omitted, not faked.
|
||||
/// The ONE real action this row keeps is the sleep timer, which used to be
|
||||
/// [PluriRootHeader]'s bedtime button on this exact screen.
|
||||
class _CabeceraEscuchar extends StatelessWidget {
|
||||
const _CabeceraEscuchar({required this.onSleepTimer});
|
||||
|
||||
final VoidCallback onSleepTimer;
|
||||
|
||||
static const double altura = 52;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
return SizedBox(
|
||||
height: altura,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 0, 10, 0),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
l10n.nowListeningLabel,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w800,
|
||||
letterSpacing: 0.3,
|
||||
color: const Color(0xFFF2F7FA).withValues(alpha: 0.55),
|
||||
),
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.bedtime_outlined),
|
||||
iconSize: 23,
|
||||
tooltip: l10n.sleepTimer,
|
||||
onPressed: onSleepTimer,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// WU5, design ADR-7: the Escuchar embedded player. `EstadoRadio` is the
|
||||
/// single source of truth (`emisoraActual` already feeds `MiniReproductor`
|
||||
/// and `PantallaReproductor`) — this hero is a third VIEW, never a third
|
||||
@@ -265,8 +431,14 @@ class _EscucharHero extends StatelessWidget {
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
stationName,
|
||||
// Audit 1.5 (t4 line 60): 24/w800/ls-.5/height 1.12
|
||||
// -- titleLarge is 22 with neither letter-spacing
|
||||
// nor an explicit line height.
|
||||
style: theme.textTheme.titleLarge?.copyWith(
|
||||
fontSize: 24,
|
||||
fontWeight: FontWeight.w800,
|
||||
letterSpacing: -0.5,
|
||||
height: 1.12,
|
||||
),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
@@ -307,12 +479,55 @@ class _EscucharHero extends StatelessWidget {
|
||||
const SizedBox(height: 14),
|
||||
_FilaTransporteEscuchar(emisora: emisora),
|
||||
const SizedBox(height: 10),
|
||||
Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: OutlinedButton.icon(
|
||||
icon: const Icon(Icons.tune_rounded, size: 18),
|
||||
label: Text(l10n.openFullPlayerTooltip),
|
||||
onPressed: () => PantallaReproductor.abrir(context, emisora),
|
||||
// Audit 1.9 (t4 line 78): a CENTRED pill -- padding:9px 15px,
|
||||
// radius:999, rgba(255,255,255,.07) + border .1, 12px/w700,
|
||||
// icon expand_less 17px -- not a left-aligned OutlinedButton
|
||||
// with tune_rounded at 18px.
|
||||
Center(
|
||||
child: Material(
|
||||
type: MaterialType.transparency,
|
||||
child: InkWell(
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
onTap: () => PantallaReproductor.abrir(context, emisora),
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withValues(alpha: 0.07),
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
border: Border.all(
|
||||
color: Colors.white.withValues(alpha: 0.1),
|
||||
),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 15,
|
||||
vertical: 9,
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.expand_less_rounded,
|
||||
size: 17,
|
||||
color: theme.colorScheme.onSurface.withValues(
|
||||
alpha: 0.78,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
l10n.openFullPlayerTooltip,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: theme.colorScheme.onSurface.withValues(
|
||||
alpha: 0.78,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -447,8 +662,10 @@ class _FilaTransporteEscuchar extends StatelessWidget {
|
||||
s == EstadoReproduccion.cargando ||
|
||||
s == EstadoReproduccion.reconectando;
|
||||
return SizedBox(
|
||||
width: 56,
|
||||
height: 56,
|
||||
// Audit 1.8 (t4 line 73): 72x72 with a 38px icon, not 56x56
|
||||
// with a 28px icon.
|
||||
width: 72,
|
||||
height: 72,
|
||||
child: FilledButton(
|
||||
style: FilledButton.styleFrom(
|
||||
shape: const CircleBorder(),
|
||||
@@ -476,7 +693,7 @@ class _FilaTransporteEscuchar extends StatelessWidget {
|
||||
reproduciendo
|
||||
? Icons.pause_rounded
|
||||
: Icons.play_arrow_rounded,
|
||||
size: 28,
|
||||
size: 38,
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -574,6 +791,35 @@ String? _metaEscuchar(Emisora emisora) {
|
||||
return partes.isEmpty ? null : partes.join(' · ');
|
||||
}
|
||||
|
||||
/// Audit 1.11 (t4 line 80): the pill badge next to "Tus emisoras" showing
|
||||
/// the total favorite count — `rgba(255,255,255,.08)` fill, 11px/w800/60%.
|
||||
class _InsigniaRecuento extends StatelessWidget {
|
||||
const _InsigniaRecuento({required this.cuenta});
|
||||
|
||||
final int cuenta;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withValues(alpha: 0.08),
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
|
||||
child: Text(
|
||||
'$cuenta',
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w800,
|
||||
color: const Color(0xFFF2F7FA).withValues(alpha: 0.6),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Audit 1.10 (t4 lines 82-88): the "Tus emisoras" grid cell — a 44px
|
||||
/// square thumbnail (radius 11), name (13/w700/lh1.2) and genre
|
||||
/// (11/55%). Deliberately NOT `TarjetaEmisora(esCompacta: true)`: that
|
||||
|
||||
@@ -4,7 +4,8 @@ import 'package:provider/provider.dart';
|
||||
import '../estado/estado_busqueda.dart';
|
||||
import '../l10n/gen/app_localizations.dart';
|
||||
import '../modelos/pais_radio.dart';
|
||||
import '../widgets/pluri_glass_surface.dart';
|
||||
import '../tema/pluriwave_theme.dart';
|
||||
import '../tema/pluriwave_tokens.dart';
|
||||
import '../widgets/pluri_layout.dart';
|
||||
import '../widgets/pluri_push_scaffold.dart';
|
||||
|
||||
@@ -15,13 +16,26 @@ import '../widgets/pluri_push_scaffold.dart';
|
||||
/// live station counts already parsed from the API's `stationcount`
|
||||
/// **string** field (`PaisRadio.fromApi`, Engram id 2500).
|
||||
class PantallaPaises extends StatefulWidget {
|
||||
const PantallaPaises({super.key});
|
||||
const PantallaPaises({super.key, this.onPaisSeleccionado});
|
||||
|
||||
/// Item 24 / audit 5.2 (t4:256-269): every row is tappable (the prototype
|
||||
/// draws a `chevron_right` on all of them). Optional so this screen stays
|
||||
/// usable stand-alone; the one production call site
|
||||
/// (`pantalla_buscar.dart`'s "Explorar por" grid) wires this to filter
|
||||
/// search results by the tapped country and pop back.
|
||||
final ValueChanged<PaisRadio>? onPaisSeleccionado;
|
||||
|
||||
@override
|
||||
State<PantallaPaises> createState() => _PantallaPaisesState();
|
||||
}
|
||||
|
||||
class _PantallaPaisesState extends State<PantallaPaises> {
|
||||
/// Audit 5.7 (t4:252): the header's `search` action. Null means "not
|
||||
/// searching" — an ephemeral UI concern (design's "State is for
|
||||
/// ephemeral UI only" ruling), never persisted.
|
||||
bool _buscando = false;
|
||||
final _controladorBusqueda = TextEditingController();
|
||||
|
||||
/// One representative country per app-supported locale (the same 13
|
||||
/// locales as `pantalla_ajustes_idioma.dart`'s `_idiomas` list). "Tus
|
||||
/// idiomas" is named by the proposal/spec but its derivation is not
|
||||
@@ -57,27 +71,82 @@ class _PantallaPaisesState extends State<PantallaPaises> {
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controladorBusqueda.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final estado = context.watch<EstadoBusqueda>();
|
||||
final query = _controladorBusqueda.text.trim().toLowerCase();
|
||||
final paisesFiltrados =
|
||||
query.isEmpty
|
||||
? estado.paises
|
||||
: estado.paises
|
||||
.where(
|
||||
(p) =>
|
||||
p.nombre.toLowerCase().contains(query) ||
|
||||
p.codigoIso.toLowerCase().contains(query),
|
||||
)
|
||||
.toList();
|
||||
|
||||
return PluriPushScaffold(
|
||||
title: l10n.countriesScreenTitle,
|
||||
// Audit 5.7 (t4:252): a `search` header action -- toggles an inline
|
||||
// filter field over the SAME country list, rather than a decorative
|
||||
// no-op button. `PluriPushScaffold.titleOverride` stays reserved for
|
||||
// its one documented exception (the player's "EN DIRECTO" pill) --
|
||||
// the search field lives in the body instead, not the AppBar title.
|
||||
actions: [
|
||||
IconButton(
|
||||
key: const ValueKey('countries-search-toggle'),
|
||||
icon: Icon(_buscando ? Icons.close_rounded : Icons.search_rounded),
|
||||
tooltip: l10n.navSearch,
|
||||
onPressed:
|
||||
() => setState(() {
|
||||
_buscando = !_buscando;
|
||||
if (!_buscando) _controladorBusqueda.clear();
|
||||
}),
|
||||
),
|
||||
],
|
||||
body:
|
||||
estado.cargandoPaises && estado.paises.isEmpty
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: ListView(
|
||||
padding: PluriLayout.pageContentPadding,
|
||||
children: [
|
||||
_seccionTusIdiomas(context, estado.paises, l10n),
|
||||
const SizedBox(height: 16),
|
||||
_seccionTodos(context, estado.paises, l10n),
|
||||
if (_buscando)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 12),
|
||||
child: TextField(
|
||||
key: const ValueKey('countries-search-field'),
|
||||
controller: _controladorBusqueda,
|
||||
autofocus: true,
|
||||
decoration: InputDecoration(
|
||||
hintText: l10n.countriesSearchHint,
|
||||
prefixIcon: const Icon(Icons.search_rounded),
|
||||
),
|
||||
onChanged: (_) => setState(() {}),
|
||||
),
|
||||
),
|
||||
if (query.isEmpty) ...[
|
||||
_seccionTusIdiomas(context, estado.paises, l10n),
|
||||
const SizedBox(height: 16),
|
||||
_seccionTodos(context, estado.paises, l10n),
|
||||
] else
|
||||
_seccionTodos(context, paisesFiltrados, l10n),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _seleccionar(PaisRadio pais) {
|
||||
widget.onPaisSeleccionado?.call(pais);
|
||||
}
|
||||
|
||||
Widget _seccionTusIdiomas(
|
||||
BuildContext context,
|
||||
List<PaisRadio> paises,
|
||||
@@ -92,33 +161,45 @@ class _PantallaPaisesState extends State<PantallaPaises> {
|
||||
|
||||
if (destacados.isEmpty) return const SizedBox.shrink();
|
||||
|
||||
final theme = Theme.of(context);
|
||||
return PluriGlassSurface(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
l10n.countriesYourLanguagesTitle,
|
||||
style: theme.textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w900,
|
||||
),
|
||||
final type = context.pluriType;
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Audit 5.4 (t4:254): an eyebrow OUTSIDE any card, title-tier
|
||||
// (20px) padding -- was titleMedium w900 inside a PluriGlassSurface.
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(
|
||||
PluriLayout.titleHorizontal,
|
||||
0,
|
||||
PluriLayout.titleHorizontal,
|
||||
8,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
child: Text(
|
||||
l10n.countriesYourLanguagesTitle,
|
||||
style: type.eyebrowLabel,
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: PluriLayout.rowHorizontal,
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
// Item 24 / audit 5.1 (t4:255-258): a column of tappable ISO
|
||||
// rows, not a Wrap of non-interactive Chips.
|
||||
for (final pais in destacados)
|
||||
Chip(
|
||||
label: Text(
|
||||
'${pais.nombre} · ${l10n.stationsCount(pais.numeroEmisoras)}',
|
||||
),
|
||||
_FilaPais(
|
||||
pais: pais,
|
||||
l10n: l10n,
|
||||
// Item 24 / audit 5.5 (t4:256): the first row is
|
||||
// highlighted.
|
||||
destacado: pais == destacados.first,
|
||||
onTap: () => _seleccionar(pais),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -127,34 +208,130 @@ class _PantallaPaisesState extends State<PantallaPaises> {
|
||||
List<PaisRadio> paises,
|
||||
AppLocalizations l10n,
|
||||
) {
|
||||
final theme = Theme.of(context);
|
||||
return PluriGlassSurface(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
l10n.countriesAllTitle,
|
||||
style: theme.textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w900,
|
||||
final type = context.pluriType;
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Audit 5.4 (t4:260-261): "TODOS · 238" -- an eyebrow OUTSIDE any
|
||||
// card, carrying the total country count, which never rendered
|
||||
// anywhere before.
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(
|
||||
PluriLayout.titleHorizontal,
|
||||
0,
|
||||
PluriLayout.titleHorizontal,
|
||||
8,
|
||||
),
|
||||
child: Text(
|
||||
'${l10n.countriesAllTitle} · ${paises.length}',
|
||||
style: type.eyebrowLabel,
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: PluriLayout.rowHorizontal,
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
// Item 24 / audit 5.1-5.3 (t4:262-269): the same tappable ISO
|
||||
// row as "Tus idiomas" -- not the previous ListTile, which
|
||||
// had no ISO column and no onTap.
|
||||
for (final pais in paises)
|
||||
_FilaPais(
|
||||
pais: pais,
|
||||
l10n: l10n,
|
||||
onTap: () => _seleccionar(pais),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Item 24 / audit 5.1-5.3, 5.5 (t4:256-269): a tappable row -- ISO code
|
||||
/// column, name, station count, and a chevron -- shared by "Tus idiomas"
|
||||
/// and "Todos". [destacado] applies the t4:256 highlight (teal-tinted
|
||||
/// background, bold name) reserved for the very first "Tus idiomas" row.
|
||||
class _FilaPais extends StatelessWidget {
|
||||
const _FilaPais({
|
||||
required this.pais,
|
||||
required this.l10n,
|
||||
this.destacado = false,
|
||||
this.onTap,
|
||||
});
|
||||
|
||||
final PaisRadio pais;
|
||||
final AppLocalizations l10n;
|
||||
final bool destacado;
|
||||
final VoidCallback? onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final onSurface = Theme.of(context).colorScheme.onSurface;
|
||||
return DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
color:
|
||||
destacado
|
||||
? PluriWaveTokens.brand.withValues(alpha: 0.1)
|
||||
: Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
),
|
||||
child: Material(
|
||||
type: MaterialType.transparency,
|
||||
child: InkWell(
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
onTap: onTap,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: Row(
|
||||
children: [
|
||||
// t4:256-258: 26px/lh1, centred in a 34-wide column.
|
||||
SizedBox(
|
||||
width: 34,
|
||||
child: Text(
|
||||
pais.codigoIso,
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(fontSize: 26, height: 1),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 14),
|
||||
Expanded(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
pais.nombre,
|
||||
style: TextStyle(
|
||||
fontSize: 15.5,
|
||||
fontWeight:
|
||||
destacado ? FontWeight.w800 : FontWeight.w700,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
l10n.stationsCount(pais.numeroEmisoras),
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: onSurface.withValues(alpha: 0.55),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Icon(
|
||||
Icons.chevron_right_rounded,
|
||||
size: 20,
|
||||
color: onSurface.withValues(alpha: 0.4),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
ListView.separated(
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
itemCount: paises.length,
|
||||
separatorBuilder: (_, __) => const Divider(height: 1),
|
||||
itemBuilder: (context, i) {
|
||||
final pais = paises[i];
|
||||
return ListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
title: Text(pais.nombre),
|
||||
trailing: Text(l10n.stationsCount(pais.numeroEmisoras)),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import 'dart:ui';
|
||||
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
@@ -13,6 +15,7 @@ import '../servicios/servicio_audio.dart';
|
||||
import '../servicios/servicio_timer.dart';
|
||||
import '../tema/pluri_animate.dart';
|
||||
import '../tema/pluriwave_theme.dart';
|
||||
import '../tema/pluriwave_tokens.dart';
|
||||
import '../widgets/ecualizador_widget.dart';
|
||||
import '../widgets/pluri_glass_surface.dart';
|
||||
import '../widgets/pluri_premium_widgets.dart';
|
||||
@@ -117,80 +120,110 @@ class _PantallaReproductorState extends State<PantallaReproductor> {
|
||||
);
|
||||
},
|
||||
),
|
||||
body: SafeArea(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.fromLTRB(20, 8, 20, 24),
|
||||
child: Column(
|
||||
children: [
|
||||
_ArteReproductor(
|
||||
emisora: emisoraActiva,
|
||||
estadoStream: estado.estadoStream,
|
||||
).pluriScaleIn(
|
||||
context,
|
||||
begin: 0.86,
|
||||
duration: const Duration(milliseconds: 420),
|
||||
curve: Curves.easeOutBack,
|
||||
// Audit 2.2 (t4 lines 108-109): a full-bleed blurred backdrop of the
|
||||
// station's own art sits BEHIND the scrollable content -- the Stack
|
||||
// wrapper is new; the SafeArea/SingleChildScrollView subtree below is
|
||||
// unchanged.
|
||||
body: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
_FondoReproductor(emisora: emisoraActiva, tokens: tokens),
|
||||
SafeArea(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.fromLTRB(20, 8, 20, 24),
|
||||
child: Column(
|
||||
children: [
|
||||
_ArteReproductor(
|
||||
emisora: emisoraActiva,
|
||||
estadoStream: estado.estadoStream,
|
||||
).pluriScaleIn(
|
||||
context,
|
||||
begin: 0.86,
|
||||
duration: const Duration(milliseconds: 420),
|
||||
curve: Curves.easeOutBack,
|
||||
),
|
||||
const SizedBox(height: 18),
|
||||
Text(
|
||||
emisoraActiva.nombre,
|
||||
// Audit 2.3 (t4 line 116): 26/w800/ls-.5/height 1.15 --
|
||||
// headlineSmall is 24/w700 with neither letter-spacing
|
||||
// nor an explicit line height.
|
||||
style: theme.textTheme.headlineSmall?.copyWith(
|
||||
fontSize: 26,
|
||||
fontWeight: FontWeight.w800,
|
||||
letterSpacing: -0.5,
|
||||
height: 1.15,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
).pluriFadeIn(
|
||||
context,
|
||||
delay: const Duration(milliseconds: 150),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
_SubtituloInfo(emisora: emisoraActiva).pluriFadeIn(
|
||||
context,
|
||||
delay: const Duration(milliseconds: 190),
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
// Audit 2.10 (t4 lines 120-122): the bars sit directly on
|
||||
// the background -- no wrapping glass card. The key moves
|
||||
// from the (removed) PluriGlassSurface onto the
|
||||
// visualizer itself so existing finders keep working.
|
||||
VisualizadorAudio(
|
||||
key: const Key('player-visualizer'),
|
||||
estadoStream: estado.estadoStream,
|
||||
androidAudioSessionIdStream:
|
||||
estado.audio.androidAudioSessionIdStream,
|
||||
// Audit 2.5 (t4 lines 120-122): 30 discrete bars at
|
||||
// 40px, gradient ending at 45% alpha (not the hero's
|
||||
// 30%).
|
||||
barras: 30,
|
||||
color: tokens.warmCoral,
|
||||
altura: 40,
|
||||
barrasDiscretas: true,
|
||||
gradienteFinAlpha: 0.45,
|
||||
).pluriFadeIn(
|
||||
context,
|
||||
delay: const Duration(milliseconds: 270),
|
||||
),
|
||||
const SizedBox(height: 22),
|
||||
_Controles(
|
||||
estado: estado,
|
||||
emisora: emisoraActiva,
|
||||
esFavorito: esFavorito,
|
||||
).pluriFadeSlideIn(
|
||||
context,
|
||||
delay: const Duration(milliseconds: 310),
|
||||
beginY: 0.3,
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
_BandejaHerramientas(
|
||||
estado: estado,
|
||||
eq: eq,
|
||||
emisora: emisoraActiva,
|
||||
compartir: _compartir,
|
||||
).pluriFadeIn(
|
||||
context,
|
||||
delay: const Duration(milliseconds: 350),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
// Item 26 / audit 2.4 (t4:114-140): the quality row is
|
||||
// LAST, after the tool tray -- it used to sit right after
|
||||
// the subtitle, 4 positions too early.
|
||||
_FilaCalidad(
|
||||
estado: estado,
|
||||
emisora: emisoraActiva,
|
||||
).pluriFadeIn(
|
||||
context,
|
||||
delay: const Duration(milliseconds: 390),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 18),
|
||||
Text(
|
||||
emisoraActiva.nombre,
|
||||
style: theme.textTheme.headlineSmall?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
).pluriFadeIn(context, delay: const Duration(milliseconds: 150)),
|
||||
const SizedBox(height: 6),
|
||||
_SubtituloInfo(
|
||||
emisora: emisoraActiva,
|
||||
).pluriFadeIn(context, delay: const Duration(milliseconds: 190)),
|
||||
const SizedBox(height: 12),
|
||||
_FilaCalidad(
|
||||
estado: estado,
|
||||
emisora: emisoraActiva,
|
||||
).pluriFadeIn(context, delay: const Duration(milliseconds: 230)),
|
||||
const SizedBox(height: 14),
|
||||
PluriGlassSurface(
|
||||
key: const Key('player-visualizer'),
|
||||
borderRadius: BorderRadius.circular(tokens.radiusLg),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 14,
|
||||
vertical: 12,
|
||||
),
|
||||
child: VisualizadorAudio(
|
||||
estadoStream: estado.estadoStream,
|
||||
androidAudioSessionIdStream:
|
||||
estado.audio.androidAudioSessionIdStream,
|
||||
// Audit 2.5 (t4 lines 120-122): 30 discrete bars at 40px,
|
||||
// gradient ending at 45% alpha (not the hero's 30%).
|
||||
barras: 30,
|
||||
color: tokens.warmCoral,
|
||||
altura: 40,
|
||||
barrasDiscretas: true,
|
||||
gradienteFinAlpha: 0.45,
|
||||
),
|
||||
).pluriFadeIn(context, delay: const Duration(milliseconds: 270)),
|
||||
const SizedBox(height: 22),
|
||||
_Controles(
|
||||
estado: estado,
|
||||
emisora: emisoraActiva,
|
||||
esFavorito: esFavorito,
|
||||
).pluriFadeSlideIn(
|
||||
context,
|
||||
delay: const Duration(milliseconds: 310),
|
||||
beginY: 0.3,
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
_BandejaHerramientas(
|
||||
estado: estado,
|
||||
eq: eq,
|
||||
emisora: emisoraActiva,
|
||||
compartir: _compartir,
|
||||
).pluriFadeIn(context, delay: const Duration(milliseconds: 350)),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -198,25 +231,32 @@ class _PantallaReproductorState extends State<PantallaReproductor> {
|
||||
|
||||
/// Square art (design proposal WU14 row: "square art" replaces the old
|
||||
/// circular `_WaveHero`). Loading/error overlays and the fallback icon are
|
||||
/// unchanged from the prior circular version — only the clip shape and the
|
||||
/// decorative halo geometry changed from circle to rounded-square.
|
||||
/// unchanged from the prior circular version.
|
||||
///
|
||||
/// Audit 2.1 (t4 line 114): the prototype draws a FIXED 246x246 square,
|
||||
/// radius 28, with only a drop shadow -- no halo, no ring. The previous
|
||||
/// `width*0.62`-derived size plus a radial-gradient halo Container and a
|
||||
/// bordered ring Container (neither present in the prototype) are both
|
||||
/// removed; `_radio` (28) is deliberately its own constant, distinct from
|
||||
/// every named token radius, mirroring this codebase's established
|
||||
/// one-off-radius precedent (e.g. the ringing screen's stop button).
|
||||
class _ArteReproductor extends StatelessWidget {
|
||||
final Emisora emisora;
|
||||
final Stream<EstadoReproduccion> estadoStream;
|
||||
|
||||
const _ArteReproductor({required this.emisora, required this.estadoStream});
|
||||
|
||||
static const double _lado = 246;
|
||||
static const double _radio = 28;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final t = context.pluriTokens;
|
||||
final size = MediaQuery.of(context).size.width * 0.62;
|
||||
final radio = BorderRadius.circular(t.radiusLg);
|
||||
final radio = BorderRadius.circular(_radio);
|
||||
|
||||
return StreamBuilder<EstadoReproduccion>(
|
||||
stream: estadoStream,
|
||||
builder: (context, snapshot) {
|
||||
final reproduciendo = snapshot.data == EstadoReproduccion.reproduciendo;
|
||||
// S7-R3: reconectando renders as loading, never as error.
|
||||
final cargando =
|
||||
snapshot.data == EstadoReproduccion.cargando ||
|
||||
@@ -225,83 +265,62 @@ class _ArteReproductor extends StatelessWidget {
|
||||
|
||||
return SizedBox(
|
||||
key: const Key('player-hero-art'),
|
||||
width: size + 40,
|
||||
height: size + 40,
|
||||
child: Stack(
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
Container(
|
||||
width: size + 34,
|
||||
height: size + 34,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(t.radiusLg + 16),
|
||||
gradient: RadialGradient(
|
||||
colors: [
|
||||
t.electricMagenta.withValues(
|
||||
alpha: reproduciendo ? 0.35 : 0.18,
|
||||
),
|
||||
t.deepViolet.withValues(alpha: 0.0),
|
||||
],
|
||||
),
|
||||
width: _lado,
|
||||
height: _lado,
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: radio,
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: 0.55),
|
||||
blurRadius: 60,
|
||||
offset: const Offset(0, 26),
|
||||
),
|
||||
),
|
||||
Container(
|
||||
width: size + 12,
|
||||
height: size + 12,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(t.radiusLg + 4),
|
||||
border: Border.all(color: t.glassBorder),
|
||||
),
|
||||
),
|
||||
PluriGlassSurface(
|
||||
],
|
||||
),
|
||||
child: PluriGlassSurface(
|
||||
borderRadius: radio,
|
||||
padding: EdgeInsets.zero,
|
||||
child: ClipRRect(
|
||||
borderRadius: radio,
|
||||
padding: EdgeInsets.zero,
|
||||
child: SizedBox(
|
||||
width: size,
|
||||
height: size,
|
||||
child: ClipRRect(
|
||||
borderRadius: radio,
|
||||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
if (emisora.favicon != null &&
|
||||
emisora.favicon!.isNotEmpty)
|
||||
CachedNetworkImage(
|
||||
imageUrl: emisora.favicon!,
|
||||
fit: BoxFit.cover,
|
||||
placeholder: (_, __) => _shimmer(theme),
|
||||
errorWidget: (_, __, ___) => _iconoFallback(theme),
|
||||
)
|
||||
else
|
||||
_iconoFallback(theme),
|
||||
if (cargando)
|
||||
Container(
|
||||
color: Colors.black45,
|
||||
child: Center(
|
||||
child: CircularProgressIndicator(
|
||||
color: theme.colorScheme.onSurface,
|
||||
),
|
||||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
if (emisora.favicon != null && emisora.favicon!.isNotEmpty)
|
||||
CachedNetworkImage(
|
||||
imageUrl: emisora.favicon!,
|
||||
fit: BoxFit.cover,
|
||||
placeholder: (_, __) => _shimmer(theme),
|
||||
errorWidget: (_, __, ___) => _iconoFallback(theme),
|
||||
)
|
||||
else
|
||||
_iconoFallback(theme),
|
||||
if (cargando)
|
||||
Container(
|
||||
color: Colors.black45,
|
||||
child: Center(
|
||||
child: CircularProgressIndicator(
|
||||
color: theme.colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (hayError)
|
||||
Container(
|
||||
color: Colors.black54,
|
||||
child: Center(
|
||||
child: Icon(
|
||||
Icons.wifi_off_rounded,
|
||||
size: 56,
|
||||
color: theme.colorScheme.onSurface.withValues(
|
||||
alpha: 0.85,
|
||||
),
|
||||
),
|
||||
if (hayError)
|
||||
Container(
|
||||
color: Colors.black54,
|
||||
child: Center(
|
||||
child: Icon(
|
||||
Icons.wifi_off_rounded,
|
||||
size: 56,
|
||||
color: theme.colorScheme.onSurface.withValues(
|
||||
alpha: 0.85,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
@@ -324,6 +343,63 @@ class _ArteReproductor extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
/// Audit 2.2 (t4 lines 108-109): a full-bleed blurred backdrop of the
|
||||
/// CURRENT station's own art, sitting behind the whole screen. Unlike the
|
||||
/// ringing screen's `_FondoArteDifuminado` (which cannot use
|
||||
/// `Emisora.favicon` -- a cold-boot download after a screen-off wake would
|
||||
/// hang widget tests without a mocked `HttpClient`), this screen already
|
||||
/// renders `emisora.favicon` via `CachedNetworkImage` for its OWN foreground
|
||||
/// art (`_ArteReproductor` above) with no such hazard -- reusing it here for
|
||||
/// the backdrop is equally safe. Renders the gradient tint even without a
|
||||
/// favicon, so the page keeps the same base depth in every state.
|
||||
class _FondoReproductor extends StatelessWidget {
|
||||
const _FondoReproductor({required this.emisora, required this.tokens});
|
||||
|
||||
final Emisora emisora;
|
||||
final PluriWaveTokens tokens;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final favicon = emisora.favicon;
|
||||
return Positioned.fill(
|
||||
key: const ValueKey('player-background-art'),
|
||||
child: IgnorePointer(
|
||||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
if (favicon != null && favicon.isNotEmpty)
|
||||
ImageFiltered(
|
||||
imageFilter: ImageFilter.blur(sigmaX: 26, sigmaY: 26),
|
||||
child: Opacity(
|
||||
opacity: 0.45,
|
||||
child: CachedNetworkImage(
|
||||
imageUrl: favicon,
|
||||
fit: BoxFit.cover,
|
||||
errorWidget: (_, __, ___) => const SizedBox.shrink(),
|
||||
),
|
||||
),
|
||||
),
|
||||
DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [
|
||||
tokens.deepViolet.withValues(alpha: 0.55),
|
||||
tokens.deepViolet.withValues(alpha: 0.9),
|
||||
tokens.deepViolet,
|
||||
],
|
||||
stops: const [0, 0.46, 1],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Single subtitle line (WU14: collapses the old `_InfoChips` `Wrap` of
|
||||
/// separate chips — country/language now join as one line; codec/bitrate
|
||||
/// moved into their own [_FilaCalidad] row below).
|
||||
@@ -752,9 +828,10 @@ class _Controles extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
return PluriGlassSurface(
|
||||
borderRadius: BorderRadius.circular(28),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
|
||||
// Audit 2.9 (t4 lines 124-131): the transport row sits directly on
|
||||
// the background -- the prototype draws no wrapping card here.
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 10),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||
children: [
|
||||
@@ -799,8 +876,9 @@ class _Controles extends StatelessWidget {
|
||||
),
|
||||
AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
width: 72,
|
||||
height: 72,
|
||||
// Item 26 / audit 2.6 (t4:127): 78x78, not 72x72.
|
||||
width: 78,
|
||||
height: 78,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: theme.colorScheme.primary,
|
||||
@@ -817,7 +895,7 @@ class _Controles extends StatelessWidget {
|
||||
shape: const CircleBorder(),
|
||||
child: InkWell(
|
||||
customBorder: const CircleBorder(),
|
||||
radius: 40,
|
||||
radius: 39,
|
||||
onTap:
|
||||
cargando
|
||||
? null
|
||||
@@ -850,7 +928,8 @@ class _Controles extends StatelessWidget {
|
||||
reproduciendo
|
||||
? Icons.pause_rounded
|
||||
: Icons.play_arrow_rounded,
|
||||
size: 40,
|
||||
// Item 26 / audit 2.6 (t4:127): 42px icon.
|
||||
size: 42,
|
||||
color: theme.colorScheme.onPrimary,
|
||||
),
|
||||
),
|
||||
@@ -869,6 +948,13 @@ class _Controles extends StatelessWidget {
|
||||
/// One tile of the 4-tile tool tray (WU14: EQ propio / Grabar / sleep timer
|
||||
/// / Compartir), each opening its own bottom sheet (Compartir invokes
|
||||
/// directly instead — there is no sheet content for it).
|
||||
///
|
||||
/// Audit 2.8 (t4 lines 133-136): only the FIRST tile ("EQ propio") is
|
||||
/// accented -- tinted background/border, brand-colour icon, bold brand
|
||||
/// label. The other three share a neutral OPAQUE surface (not `listSurface`
|
||||
/// at 55% alpha) with a full-weight icon/label -- this app's ambient
|
||||
/// default colour, not the `tokens.electricMagenta` fallback every tile
|
||||
/// used to render in regardless of [accent].
|
||||
class _TileHerramienta extends StatelessWidget {
|
||||
const _TileHerramienta({
|
||||
super.key,
|
||||
@@ -876,6 +962,7 @@ class _TileHerramienta extends StatelessWidget {
|
||||
required this.label,
|
||||
required this.onTap,
|
||||
this.iconColor,
|
||||
this.accent = false,
|
||||
});
|
||||
|
||||
final IconData icon;
|
||||
@@ -883,26 +970,42 @@ class _TileHerramienta extends StatelessWidget {
|
||||
final VoidCallback onTap;
|
||||
final Color? iconColor;
|
||||
|
||||
/// See class doc — `false` (neutral) is every tile except "EQ propio".
|
||||
final bool accent;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final tokens = context.pluriTokens;
|
||||
final resolvedIconColor =
|
||||
iconColor ?? (accent ? tokens.electricMagenta : null);
|
||||
|
||||
return Material(
|
||||
type: MaterialType.transparency,
|
||||
child: InkWell(
|
||||
borderRadius: BorderRadius.circular(tokens.radiusSm),
|
||||
borderRadius: BorderRadius.circular(tokens.radiusMd),
|
||||
onTap: onTap,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: tokens.listSurface.withValues(alpha: 0.55),
|
||||
borderRadius: BorderRadius.circular(tokens.radiusSm),
|
||||
color:
|
||||
accent
|
||||
? tokens.electricMagenta.withValues(alpha: 0.16)
|
||||
: tokens.listSurface,
|
||||
// Audit 2.8 (t4 lines 133-136): radius 18 for BOTH variants --
|
||||
// `radiusMd`, not the smaller `radiusSm` every tile used before.
|
||||
borderRadius: BorderRadius.circular(tokens.radiusMd),
|
||||
border: Border.all(
|
||||
color:
|
||||
accent
|
||||
? tokens.electricMagenta.withValues(alpha: 0.4)
|
||||
: Colors.white.withValues(alpha: 0.09),
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(icon, size: 23, color: iconColor ?? tokens.electricMagenta),
|
||||
Icon(icon, size: 23, color: resolvedIconColor),
|
||||
const SizedBox(height: 5),
|
||||
Text(
|
||||
label,
|
||||
@@ -910,7 +1013,11 @@ class _TileHerramienta extends StatelessWidget {
|
||||
overflow: TextOverflow.ellipsis,
|
||||
textAlign: TextAlign.center,
|
||||
style: theme.textTheme.labelSmall?.copyWith(
|
||||
fontWeight: FontWeight.w800,
|
||||
fontWeight: accent ? FontWeight.w800 : FontWeight.w700,
|
||||
color:
|
||||
accent
|
||||
? tokens.electricMagenta
|
||||
: theme.colorScheme.onSurface.withValues(alpha: 0.72),
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -947,6 +1054,7 @@ class _BandejaHerramientas extends StatelessWidget {
|
||||
icon: Icons.equalizer_rounded,
|
||||
label: l10n.playerToolEqLabel,
|
||||
onTap: () => _mostrarHojaEq(context, eq, emisora),
|
||||
accent: true,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
|
||||
@@ -31,9 +31,40 @@ class PantallaVacaciones extends StatelessWidget {
|
||||
|
||||
return PluriPushScaffold(
|
||||
title: l10n.vacationRangesTitle,
|
||||
// Audit 9b.1 (t4:446): a solid brand-teal "Add" header action --
|
||||
// the prototype's OWN mid-page CTA (audit 9b.6, still present below,
|
||||
// now dashed) is a SECOND, additional entry point in the prototype,
|
||||
// not a replacement for this one.
|
||||
actions: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: 8),
|
||||
child: FilledButton.icon(
|
||||
key: const ValueKey('vacation-add-header'),
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: PluriWaveTokens.brand,
|
||||
foregroundColor: const Color(0xFF062126),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 13, vertical: 8),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(11),
|
||||
),
|
||||
textStyle: const TextStyle(
|
||||
fontSize: 12.5,
|
||||
fontWeight: FontWeight.w800,
|
||||
),
|
||||
),
|
||||
onPressed: () => _abrirAlta(context),
|
||||
icon: const Icon(Icons.add_rounded, size: 17),
|
||||
label: Text(l10n.addAction),
|
||||
),
|
||||
),
|
||||
],
|
||||
body: ListView(
|
||||
padding: PluriLayout.pageContentPadding,
|
||||
children: [
|
||||
// Audit 9b.2 (t4:448): the explanatory banner is ALWAYS visible
|
||||
// -- never rendered anywhere before.
|
||||
_BannerExplicativo(texto: l10n.vacationExplainerBanner),
|
||||
const SizedBox(height: 18),
|
||||
if (activo != null)
|
||||
_HeroRangoActivo(estado: estado, rango: activo)
|
||||
else
|
||||
@@ -41,15 +72,10 @@ class PantallaVacaciones extends StatelessWidget {
|
||||
const SizedBox(height: 16),
|
||||
_SeccionProgramados(proximas: proximas),
|
||||
const SizedBox(height: 16),
|
||||
Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: () => _abrirAlta(context),
|
||||
icon: const Icon(Icons.add_rounded),
|
||||
label: Text(l10n.addVacationRangeCta),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
// Audit 9b.6 (t4:487): a dashed border + `date_range` icon --
|
||||
// was a solid `OutlinedButton` with an `add` glyph.
|
||||
_CtaAnadirRango(onTap: () => _abrirAlta(context)),
|
||||
const SizedBox(height: 14),
|
||||
_SeccionRangosPasados(pasadas: pasadas),
|
||||
],
|
||||
),
|
||||
@@ -67,6 +93,136 @@ class PantallaVacaciones extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
/// Audit 9b.2 (t4:448): teal-tinted explainer banner, always visible above
|
||||
/// the active-range hero.
|
||||
class _BannerExplicativo extends StatelessWidget {
|
||||
const _BannerExplicativo({required this.texto});
|
||||
|
||||
final String texto;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tokens = context.pluriTokens;
|
||||
return DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
color: tokens.liveGreen.withValues(alpha: 0.09),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(color: tokens.liveGreen.withValues(alpha: 0.26)),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 15, vertical: 13),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Icon(Icons.info_outline_rounded, size: 20, color: tokens.liveGreen),
|
||||
const SizedBox(width: 11),
|
||||
Expanded(
|
||||
child: Text(
|
||||
texto,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
height: 1.5,
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.onSurface.withValues(alpha: 0.72),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Audit 9b.6 (t4:487): dashed-border CTA with a `date_range` glyph --
|
||||
/// reuses the same dashed-painter shape already established in
|
||||
/// `pantalla_favoritos.dart`'s custom-station CTA (audit 4.5), duplicated
|
||||
/// rather than shared (small, self-contained, matching this codebase's own
|
||||
/// precedent for tiny per-screen painters).
|
||||
class _CtaAnadirRango extends StatelessWidget {
|
||||
const _CtaAnadirRango({required this.onTap});
|
||||
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final colorTexto = Theme.of(
|
||||
context,
|
||||
).colorScheme.onSurface.withValues(alpha: 0.6);
|
||||
return CustomPaint(
|
||||
painter: _DashedBorderPainter(
|
||||
color: Colors.white.withValues(alpha: 0.16),
|
||||
),
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
onTap: onTap,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(15),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(Icons.date_range_rounded, size: 20, color: colorTexto),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
l10n.addVacationRangeCta,
|
||||
style: TextStyle(
|
||||
fontSize: 13.5,
|
||||
fontWeight: FontWeight.w800,
|
||||
color: colorTexto,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _DashedBorderPainter extends CustomPainter {
|
||||
const _DashedBorderPainter({required this.color});
|
||||
|
||||
final Color color;
|
||||
|
||||
static const _radius = 16.0;
|
||||
static const _dashWidth = 6.0;
|
||||
static const _gapWidth = 4.0;
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
final rrect = RRect.fromRectAndRadius(
|
||||
Offset.zero & size,
|
||||
const Radius.circular(_radius),
|
||||
);
|
||||
final path = Path()..addRRect(rrect);
|
||||
final paint =
|
||||
Paint()
|
||||
..color = color
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeWidth = 1.5;
|
||||
for (final metric in path.computeMetrics()) {
|
||||
var distance = 0.0;
|
||||
while (distance < metric.length) {
|
||||
final next = distance + _dashWidth;
|
||||
canvas.drawPath(
|
||||
metric.extractPath(distance, next.clamp(0.0, metric.length)),
|
||||
paint,
|
||||
);
|
||||
distance = next + _gapWidth;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(covariant _DashedBorderPainter oldDelegate) =>
|
||||
oldDelegate.color != color;
|
||||
}
|
||||
|
||||
/// Active-range hero: name, days-remaining countdown (reusing WU8's own
|
||||
/// `vacationSummaryActiveCountdown` string — same concept, bigger stage),
|
||||
/// a start/end date pair (item 21 / audit 9b.4, replacing the former
|
||||
@@ -184,31 +340,92 @@ class _SeccionProgramados extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
class _SeccionRangosPasados extends StatelessWidget {
|
||||
/// Audit 9b.7 (t4:489): a collapsible row -- icon, title, count, chevron
|
||||
/// -- COLLAPSED by default; tapping reveals the full list below it. Was
|
||||
/// always fully expanded inline.
|
||||
class _SeccionRangosPasados extends StatefulWidget {
|
||||
const _SeccionRangosPasados({required this.pasadas});
|
||||
|
||||
final List<RangoVacaciones> pasadas;
|
||||
|
||||
@override
|
||||
State<_SeccionRangosPasados> createState() => _SeccionRangosPasadosState();
|
||||
}
|
||||
|
||||
class _SeccionRangosPasadosState extends State<_SeccionRangosPasados> {
|
||||
/// Ephemeral UI state only (design's "State is for ephemeral UI only"
|
||||
/// ruling) -- collapsed by default, matching the prototype's own count
|
||||
/// only row.
|
||||
bool _expandido = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (pasadas.isEmpty) return const SizedBox.shrink();
|
||||
if (widget.pasadas.isEmpty) return const SizedBox.shrink();
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final type = context.pluriType;
|
||||
return PluriGlassSurface(
|
||||
padding: EdgeInsets.zero,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(l10n.vacationPastSectionTitle, style: type.eyebrowLabel),
|
||||
const SizedBox(height: 8),
|
||||
// No prototype-specified header text exists for an already-ended
|
||||
// range, so `encabezado` is omitted rather than invented.
|
||||
PluriPanelColumn(
|
||||
gap: 10,
|
||||
children: [
|
||||
for (final rango in pasadas)
|
||||
_TarjetaRangoVacaciones(rango: rango),
|
||||
],
|
||||
Material(
|
||||
type: MaterialType.transparency,
|
||||
child: InkWell(
|
||||
onTap: () => setState(() => _expandido = !_expandido),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 14,
|
||||
vertical: 12,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.history_rounded,
|
||||
size: 20,
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.onSurface.withValues(alpha: 0.6),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
l10n.vacationPastSectionTitle,
|
||||
style: context.pluriType.cardTitle,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'${widget.pasadas.length}',
|
||||
style: TextStyle(
|
||||
fontSize: 12.5,
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.onSurface.withValues(alpha: 0.5),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Icon(
|
||||
_expandido
|
||||
? Icons.expand_less_rounded
|
||||
: Icons.chevron_right_rounded,
|
||||
size: 19,
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.onSurface.withValues(alpha: 0.4),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (_expandido)
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
|
||||
child: PluriPanelColumn(
|
||||
gap: 10,
|
||||
children: [
|
||||
for (final rango in widget.pasadas)
|
||||
_TarjetaRangoVacaciones(rango: rango),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
@@ -2,8 +2,11 @@ import 'package:flutter/material.dart';
|
||||
|
||||
/// Design ADR-1: the named type scale, split out of [PluriWaveTokens] into
|
||||
/// its own ThemeExtension so screens stop copy-pasting
|
||||
/// `.copyWith(fontWeight: w900, letterSpacing: …)`. The set is closed at six
|
||||
/// styles — a seventh requires amending ADR-1.
|
||||
/// `.copyWith(fontWeight: w900, letterSpacing: …)`. The set was closed at
|
||||
/// six styles; S7 (visual fidelity pass) amended ADR-1 to add a seventh —
|
||||
/// [inPageSectionHeading] — rather than have four screens each keep
|
||||
/// copy-pasting their own `titleMedium.copyWith(fontWeight: w900)` override,
|
||||
/// the exact duplication this extension exists to eliminate.
|
||||
///
|
||||
/// [eyebrowLabel] never applies `toUpperCase()` — casing is a
|
||||
/// locale-hostile transform (Turkish dotless i, and scripts with no case at
|
||||
@@ -18,6 +21,7 @@ class PluriWaveTypography extends ThemeExtension<PluriWaveTypography> {
|
||||
required this.cardTitle,
|
||||
required this.bodyStrong,
|
||||
required this.eyebrowLabel,
|
||||
required this.inPageSectionHeading,
|
||||
});
|
||||
|
||||
/// 88 / w800 / height 1.0 / ls -2.0. Alarm ringing, inline time editor.
|
||||
@@ -41,6 +45,14 @@ class PluriWaveTypography extends ThemeExtension<PluriWaveTypography> {
|
||||
/// headers.
|
||||
final TextStyle eyebrowLabel;
|
||||
|
||||
/// 15 / w800 / ls -0.2. S7 (visual fidelity, t4 lines 80, 153, 164, 173):
|
||||
/// an in-page section heading living directly on a screen's content —
|
||||
/// "Tus emisoras", "Cerca de ti", "Explorar por", "Populares ahora".
|
||||
/// Distinct from both [sectionTitle] (23, a ROOT screen's own title) and
|
||||
/// [screenTitle] (19, a pushed screen's AppBar title) — this is smaller
|
||||
/// than either, for a heading living one level below the screen itself.
|
||||
final TextStyle inPageSectionHeading;
|
||||
|
||||
/// Builds the scale from [family] — the SAME resolved Google Fonts
|
||||
/// TextTheme instance [PluriWaveTheme.dark] already computes. Building the
|
||||
/// styles anywhere else would re-merge the font family, which is the
|
||||
@@ -79,6 +91,7 @@ class PluriWaveTypography extends ThemeExtension<PluriWaveTypography> {
|
||||
letterSpacing: 0.8,
|
||||
color: const Color(0xFFF2F7FA).withValues(alpha: 0.42),
|
||||
),
|
||||
inPageSectionHeading: style(15, FontWeight.w800, letterSpacing: -0.2),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -90,6 +103,7 @@ class PluriWaveTypography extends ThemeExtension<PluriWaveTypography> {
|
||||
TextStyle? cardTitle,
|
||||
TextStyle? bodyStrong,
|
||||
TextStyle? eyebrowLabel,
|
||||
TextStyle? inPageSectionHeading,
|
||||
}) {
|
||||
return PluriWaveTypography(
|
||||
heroTime: heroTime ?? this.heroTime,
|
||||
@@ -98,6 +112,7 @@ class PluriWaveTypography extends ThemeExtension<PluriWaveTypography> {
|
||||
cardTitle: cardTitle ?? this.cardTitle,
|
||||
bodyStrong: bodyStrong ?? this.bodyStrong,
|
||||
eyebrowLabel: eyebrowLabel ?? this.eyebrowLabel,
|
||||
inPageSectionHeading: inPageSectionHeading ?? this.inPageSectionHeading,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -117,6 +132,9 @@ class PluriWaveTypography extends ThemeExtension<PluriWaveTypography> {
|
||||
bodyStrong: TextStyle.lerp(bodyStrong, other.bodyStrong, t) ?? bodyStrong,
|
||||
eyebrowLabel:
|
||||
TextStyle.lerp(eyebrowLabel, other.eyebrowLabel, t) ?? eyebrowLabel,
|
||||
inPageSectionHeading:
|
||||
TextStyle.lerp(inPageSectionHeading, other.inPageSectionHeading, t) ??
|
||||
inPageSectionHeading,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -113,6 +113,10 @@ class _EcualizadorWidgetState extends State<EcualizadorWidget> {
|
||||
theme.colorScheme.surfaceContainerHighest,
|
||||
overlayColor: PluriWaveTokens.brand
|
||||
.withValues(alpha: 0.15),
|
||||
// Audit 11.5 (t4 line 585): a 20x20 thumb
|
||||
// with a 14px brand-teal glow -- was the
|
||||
// Material default round thumb shape.
|
||||
thumbShape: const _GlowSliderThumbShape(),
|
||||
),
|
||||
child: Slider(
|
||||
value: _bandas[i],
|
||||
@@ -130,8 +134,12 @@ class _EcualizadorWidgetState extends State<EcualizadorWidget> {
|
||||
),
|
||||
Text(
|
||||
'${_bandas[i].toStringAsFixed(1)}dB',
|
||||
// Audit 11.7 (t4 line 584): the prototype's dB
|
||||
// label is brand teal at 90% alpha -- `liveGreen`
|
||||
// is the LIVE-badge colour, an unrelated wrong
|
||||
// family untouched by 11.6's slider-only fix.
|
||||
style: theme.textTheme.labelSmall?.copyWith(
|
||||
color: tokens.liveGreen,
|
||||
color: PluriWaveTokens.brand.withValues(alpha: 0.9),
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
@@ -154,6 +162,50 @@ class _EcualizadorWidgetState extends State<EcualizadorWidget> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Audit 11.5 (t4 line 585): `width:6px;border-radius:3px` track with a
|
||||
/// `20x20` thumb carrying `box-shadow:0 0 14px rgba(33,212,217,.6)` --
|
||||
/// Material's stock `RoundSliderThumbShape` has neither the exact size nor
|
||||
/// a coloured glow (its own elevation shadow is a neutral drop shadow, not
|
||||
/// brand-tinted). Paints a soft blurred glow first, then the solid thumb
|
||||
/// on top, both centred on the slider's reported thumb position.
|
||||
class _GlowSliderThumbShape extends SliderComponentShape {
|
||||
const _GlowSliderThumbShape();
|
||||
|
||||
static const _radius = 10.0;
|
||||
|
||||
@override
|
||||
Size getPreferredSize(bool isEnabled, bool isDiscrete) =>
|
||||
const Size(_radius * 2, _radius * 2);
|
||||
|
||||
@override
|
||||
void paint(
|
||||
PaintingContext context,
|
||||
Offset center, {
|
||||
required Animation<double> activationAnimation,
|
||||
required Animation<double> enableAnimation,
|
||||
required bool isDiscrete,
|
||||
required TextPainter labelPainter,
|
||||
required RenderBox parentBox,
|
||||
required SliderThemeData sliderTheme,
|
||||
required TextDirection textDirection,
|
||||
required double value,
|
||||
required double textScaleFactor,
|
||||
required Size sizeWithOverflow,
|
||||
}) {
|
||||
final canvas = context.canvas;
|
||||
final color = sliderTheme.thumbColor ?? PluriWaveTokens.brand;
|
||||
|
||||
final glowPaint =
|
||||
Paint()
|
||||
..color = color.withValues(alpha: 0.6)
|
||||
..maskFilter = const MaskFilter.blur(BlurStyle.normal, 7);
|
||||
canvas.drawCircle(center, _radius + 4, glowPaint);
|
||||
|
||||
final thumbPaint = Paint()..color = color;
|
||||
canvas.drawCircle(center, _radius, thumbPaint);
|
||||
}
|
||||
}
|
||||
|
||||
String _nombrePreset(AppLocalizations l10n, String nombre) {
|
||||
return switch (nombre) {
|
||||
'Flat' => l10n.equalizerPresetFlat,
|
||||
@@ -188,7 +240,6 @@ class PresetsEcualizadorWidget extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final todos = [...PresetEcualizador.presets, ...personalizados];
|
||||
return Wrap(
|
||||
@@ -197,13 +248,32 @@ class PresetsEcualizadorWidget extends StatelessWidget {
|
||||
children:
|
||||
todos.map((p) {
|
||||
final selected = p.nombre == presetActual.nombre;
|
||||
// Audit 11.3 (t4 lines 574-577): a solid brand-teal chip with
|
||||
// dark text when active, `listSurface` + a faint border when
|
||||
// not -- was Material's own `ChoiceChip` theming
|
||||
// (`primaryContainer` selected / translucent grey unselected).
|
||||
return ChoiceChip(
|
||||
label: Text(_nombrePreset(l10n, p.nombre)),
|
||||
labelStyle: TextStyle(
|
||||
fontWeight: FontWeight.w800,
|
||||
color:
|
||||
selected
|
||||
? const Color(0xFF062126)
|
||||
: const Color(0xFFF2F7FA),
|
||||
),
|
||||
selected: selected,
|
||||
showCheckmark: false,
|
||||
selectedColor: theme.colorScheme.primaryContainer,
|
||||
backgroundColor: theme.colorScheme.surfaceContainerHighest
|
||||
.withValues(alpha: 0.32),
|
||||
selectedColor: PluriWaveTokens.brand,
|
||||
backgroundColor: PluriWaveTokens.dark.listSurface,
|
||||
side: BorderSide(
|
||||
color:
|
||||
selected
|
||||
? Colors.transparent
|
||||
: Colors.white.withValues(alpha: 0.09),
|
||||
),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
onSelected: (_) => onSeleccionar(p),
|
||||
);
|
||||
}).toList(),
|
||||
|
||||
@@ -0,0 +1,257 @@
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:shimmer/shimmer.dart' as shimmer;
|
||||
|
||||
import '../estado/estado_radio.dart';
|
||||
import '../l10n/display_names.dart';
|
||||
import '../l10n/gen/app_localizations.dart';
|
||||
import '../modelos/emisora.dart';
|
||||
import '../tema/pluriwave_tokens.dart';
|
||||
|
||||
/// Item 23 / audit 4.3 + 6.5 (t4:226-232, 302-306): a flat, background-less
|
||||
/// station row — square thumbnail, name, meta line, and a caller-supplied
|
||||
/// set of trailing actions. Shared by Favoritos and search results, which
|
||||
/// the prototype draws identically apart from their trailing actions.
|
||||
/// Grabaciones (12.4) has its own row: a different domain model
|
||||
/// (recordings, not stations) with different trailing actions.
|
||||
class FilaEmisoraPlana extends StatelessWidget {
|
||||
const FilaEmisoraPlana({
|
||||
super.key,
|
||||
required this.emisora,
|
||||
required this.meta,
|
||||
this.leading,
|
||||
this.trailing = const [],
|
||||
this.onTap,
|
||||
});
|
||||
|
||||
final Emisora emisora;
|
||||
|
||||
/// Pre-joined meta line (e.g. "genre - country - kbps"). Omitted from
|
||||
/// layout entirely when empty, rather than reserving blank space.
|
||||
final String meta;
|
||||
|
||||
/// e.g. a drag handle (Favoritos only — search results have none).
|
||||
final Widget? leading;
|
||||
|
||||
/// e.g. a favourite toggle, a circular play button, an overflow menu.
|
||||
final List<Widget> trailing;
|
||||
|
||||
final VoidCallback? onTap;
|
||||
|
||||
/// t4:226, t4:302: 48x48, radius 12.
|
||||
static const double lado = 48;
|
||||
static const double radio = 12;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final stationName = localizedStationName(l10n, emisora.nombre);
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(8),
|
||||
child: Row(
|
||||
children: [
|
||||
if (leading != null) ...[leading!, const SizedBox(width: 4)],
|
||||
ClipRRect(
|
||||
key: const ValueKey('fila-emisora-plana-arte'),
|
||||
borderRadius: BorderRadius.circular(radio),
|
||||
child: SizedBox(
|
||||
width: lado,
|
||||
height: lado,
|
||||
child: _ArteFilaEmisora(emisora: emisora),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Material(
|
||||
type: MaterialType.transparency,
|
||||
child: InkWell(
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
onTap: onTap,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 2),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// t4:229, t4:303: 15px/w700/lh1.25.
|
||||
Text(
|
||||
stationName,
|
||||
style: const TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w700,
|
||||
height: 1.25,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
if (meta.isNotEmpty) ...[
|
||||
const SizedBox(height: 2),
|
||||
// t4:229, t4:303: 12px/rgba(242,247,250,.58).
|
||||
Text(
|
||||
meta,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.onSurface.withValues(alpha: 0.58),
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
...trailing,
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// t4:230, t4:305: a 44x44 circular affordance, `rgba(33,212,217,.14)`
|
||||
/// background, `play_arrow` at 22px brand teal.
|
||||
class BotonReproducirCircular extends StatelessWidget {
|
||||
const BotonReproducirCircular({
|
||||
super.key,
|
||||
required this.onPressed,
|
||||
this.tooltip,
|
||||
});
|
||||
|
||||
final VoidCallback? onPressed;
|
||||
final String? tooltip;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Material(
|
||||
key: const ValueKey('boton-reproducir-circular'),
|
||||
color: PluriWaveTokens.brand.withValues(alpha: 0.14),
|
||||
shape: const CircleBorder(),
|
||||
child: IconButton(
|
||||
tooltip: tooltip,
|
||||
icon: const Icon(
|
||||
Icons.play_arrow_rounded,
|
||||
size: 22,
|
||||
color: PluriWaveTokens.brand,
|
||||
),
|
||||
onPressed: onPressed,
|
||||
constraints: const BoxConstraints.tightFor(width: 44, height: 44),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// t4:304: a 44x44 favourite toggle, 22px icon at 55% opacity. Mirrors
|
||||
/// `TarjetaEmisora`'s own toggle+snackbar behaviour (duplicated rather than
|
||||
/// extracted from it — `tarjeta_emisora.dart`'s exact widget tree is pinned
|
||||
/// by several pre-existing tests this change must not disturb).
|
||||
class BotonFavoritoEmisora extends StatefulWidget {
|
||||
const BotonFavoritoEmisora({super.key, required this.emisora});
|
||||
|
||||
final Emisora emisora;
|
||||
|
||||
@override
|
||||
State<BotonFavoritoEmisora> createState() => _BotonFavoritoEmisoraState();
|
||||
}
|
||||
|
||||
class _BotonFavoritoEmisoraState extends State<BotonFavoritoEmisora> {
|
||||
bool _toggling = false;
|
||||
|
||||
Future<void> _toggle() async {
|
||||
if (_toggling) return;
|
||||
setState(() => _toggling = true);
|
||||
final estado = context.read<EstadoRadio>();
|
||||
final esFav = await estado.toggleFavorito(widget.emisora);
|
||||
if (mounted) setState(() => _toggling = false);
|
||||
if (mounted) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final stationName = localizedStationName(l10n, widget.emisora.nombre);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
esFav
|
||||
? l10n.favoritesAddedMessage(stationName)
|
||||
: l10n.favoritesRemovedMessage(stationName),
|
||||
),
|
||||
duration: const Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final esFavorito = context.select<EstadoRadio, bool>(
|
||||
(estado) =>
|
||||
estado.listaFavoritos.any((e) => e.uuid == widget.emisora.uuid),
|
||||
);
|
||||
return Semantics(
|
||||
container: true,
|
||||
button: true,
|
||||
toggled: esFavorito,
|
||||
label:
|
||||
esFavorito ? l10n.favoritesRemoveTooltip : l10n.favoritesAddTooltip,
|
||||
child: Material(
|
||||
key: const ValueKey('boton-favorito-emisora'),
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
customBorder: const CircleBorder(),
|
||||
onTap: _toggling ? null : _toggle,
|
||||
child: SizedBox(
|
||||
width: 44,
|
||||
height: 44,
|
||||
child: Icon(
|
||||
esFavorito
|
||||
? Icons.favorite_rounded
|
||||
: Icons.favorite_outline_rounded,
|
||||
size: 22,
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.onSurface.withValues(alpha: 0.55),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ArteFilaEmisora extends StatelessWidget {
|
||||
const _ArteFilaEmisora({required this.emisora});
|
||||
|
||||
final Emisora emisora;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
if (emisora.favicon != null && emisora.favicon!.isNotEmpty) {
|
||||
return CachedNetworkImage(
|
||||
imageUrl: emisora.favicon!,
|
||||
fit: BoxFit.cover,
|
||||
placeholder: (_, __) => _shimmer(theme),
|
||||
errorWidget: (_, __, ___) => _iconoFallback(theme),
|
||||
);
|
||||
}
|
||||
return _iconoFallback(theme);
|
||||
}
|
||||
|
||||
Widget _shimmer(ThemeData theme) => shimmer.Shimmer.fromColors(
|
||||
baseColor: theme.colorScheme.surfaceContainerHighest,
|
||||
highlightColor: theme.colorScheme.surface,
|
||||
child: Container(color: theme.colorScheme.surfaceContainerHighest),
|
||||
);
|
||||
|
||||
Widget _iconoFallback(ThemeData theme) => Container(
|
||||
color: theme.colorScheme.primaryContainer,
|
||||
child: Icon(
|
||||
Icons.radio_rounded,
|
||||
size: 22,
|
||||
color: theme.colorScheme.onPrimaryContainer,
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import 'package:pluriwave/modelos/dispositivo_audio.dart';
|
||||
import 'package:pluriwave/modelos/emisora.dart';
|
||||
import 'package:pluriwave/modelos/preset_ecualizador.dart';
|
||||
import 'package:pluriwave/pantallas/ajustes/pantalla_ajustes_ecualizador.dart';
|
||||
import 'package:pluriwave/tema/pluriwave_tokens.dart';
|
||||
import 'package:pluriwave/widgets/ecualizador_widget.dart';
|
||||
import 'package:pluriwave/widgets/pluri_glass_surface.dart';
|
||||
import 'package:pluriwave/widgets/pluri_push_scaffold.dart';
|
||||
@@ -119,9 +120,8 @@ void main() {
|
||||
expect((appBar.title as Text).data, equals('Equalizer'));
|
||||
});
|
||||
|
||||
testWidgets('moved control still responds: enable switch toggles activo', (
|
||||
tester,
|
||||
) async {
|
||||
testWidgets('moved control still responds: enable switch toggles activo '
|
||||
'(audit 11.1: now in the header, t4 line 566)', (tester) async {
|
||||
_suppressListTileInkAssertion();
|
||||
final estado = await crearEstado();
|
||||
addTearDown(estado.dispose);
|
||||
@@ -130,7 +130,7 @@ void main() {
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final before = estado.ecualizador.activo;
|
||||
await tester.tap(find.text('Enable equalizer'));
|
||||
await tester.tap(find.byKey(const ValueKey('eq-master-switch')));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(estado.ecualizador.activo, equals(!before));
|
||||
@@ -447,6 +447,74 @@ void main() {
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets(
|
||||
'visual fidelity (audit 11.1): the master switch is a header action, '
|
||||
'not the body\'s first row (t4 line 566)',
|
||||
(tester) async {
|
||||
_suppressListTileInkAssertion();
|
||||
final estado = await crearEstado();
|
||||
addTearDown(estado.dispose);
|
||||
|
||||
await tester.pumpWidget(buildScreen(estado));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(
|
||||
find.descendant(
|
||||
of: find.byType(AppBar),
|
||||
matching: find.byKey(const ValueKey('eq-master-switch')),
|
||||
),
|
||||
findsOneWidget,
|
||||
reason: 'the master switch lives in the AppBar, not the body',
|
||||
);
|
||||
expect(
|
||||
find.descendant(
|
||||
of: find.byType(ListView),
|
||||
matching: find.byKey(const ValueKey('eq-master-switch')),
|
||||
),
|
||||
findsNothing,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets(
|
||||
'visual fidelity (audit 11.2): the explainer banner radius is 16, '
|
||||
'not radiusSm\'s 14 (t4 line 571)',
|
||||
(tester) async {
|
||||
_suppressListTileInkAssertion();
|
||||
final estado = await crearEstado();
|
||||
addTearDown(estado.dispose);
|
||||
|
||||
await tester.pumpWidget(buildScreen(estado));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final banner = tester.widget<Container>(
|
||||
find.byKey(const Key('eq-base-explainer-banner')),
|
||||
);
|
||||
final decoration = banner.decoration as BoxDecoration;
|
||||
expect(
|
||||
(decoration.borderRadius as BorderRadius).topLeft,
|
||||
const Radius.circular(16),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets('visual fidelity (audit 11.3): the active preset chip is solid '
|
||||
'brand teal with dark text (t4 lines 574-577)', (tester) async {
|
||||
_suppressListTileInkAssertion();
|
||||
final estado = await crearEstado();
|
||||
addTearDown(estado.dispose);
|
||||
|
||||
await tester.pumpWidget(buildScreen(estado));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final chip = tester.widget<ChoiceChip>(
|
||||
find.widgetWithText(ChoiceChip, 'Flat'),
|
||||
);
|
||||
expect(chip.selected, isTrue);
|
||||
expect(chip.selectedColor, PluriWaveTokens.brand);
|
||||
expect(chip.labelStyle?.color, const Color(0xFF062126));
|
||||
});
|
||||
|
||||
testWidgets(
|
||||
'Guardar como preset with a whitespace-only name shows the same validation message',
|
||||
(tester) async {
|
||||
|
||||
@@ -2,12 +2,31 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:pluriwave/pantallas/ajustes/widgets/fila_ajuste.dart';
|
||||
import 'package:pluriwave/tema/pluriwave_theme.dart';
|
||||
import 'package:pluriwave/widgets/pluri_glass_surface.dart';
|
||||
|
||||
/// S8 (Tier 1 visual fidelity): the prototype puts a trailing "current
|
||||
/// value" on nearly every settings row, 13px `rgba(242,247,250,.55)` (t4
|
||||
/// lines 512-539, 625 — "Voz clara", "Alta", "3 guardados", "Alfabético",
|
||||
/// "7 · 84 MB", "Español", "Hoy, 08:12", "200 MB"). `FilaAjuste` used to
|
||||
/// accept only `icon`/`titulo`/`onTap` — no value slot at all.
|
||||
/// Pre-existing project constraint (see `pantalla_ajustes_test.dart`):
|
||||
/// `PluriGlassSurface` paints a background over `ListTile`'s ink layer,
|
||||
/// which Flutter flags as a warning-level assertion, not a correctness bug.
|
||||
/// Only needed by the `GrupoAjustes` test below — the bare `FilaAjuste`
|
||||
/// tests above never wrap a `ListTile` inside that surface.
|
||||
void _suppressListTileInkAssertion() {
|
||||
final original = FlutterError.onError;
|
||||
FlutterError.onError = (details) {
|
||||
if (details.exceptionAsString().contains(
|
||||
'ListTile background color or ink splashes may be invisible',
|
||||
)) {
|
||||
return;
|
||||
}
|
||||
original?.call(details);
|
||||
};
|
||||
addTearDown(() => FlutterError.onError = original);
|
||||
}
|
||||
|
||||
void main() {
|
||||
Widget host(Widget child) {
|
||||
return MaterialApp(
|
||||
@@ -49,4 +68,136 @@ void main() {
|
||||
expect(find.text('Info'), findsOneWidget);
|
||||
expect(find.byIcon(Icons.chevron_right_rounded), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets(
|
||||
'visual fidelity (audit 10.6/10.8/10.9): leading icon is 21px, the '
|
||||
'title is 14px, and the chevron is 19px at 40% opacity (t4 lines '
|
||||
'514-515)',
|
||||
(tester) async {
|
||||
await tester.pumpWidget(
|
||||
host(
|
||||
FilaAjuste(
|
||||
icon: Icons.equalizer_rounded,
|
||||
titulo: 'Ecualizador base',
|
||||
onTap: () {},
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
final icon = tester.widget<Icon>(find.byIcon(Icons.equalizer_rounded));
|
||||
expect(icon.size, 21);
|
||||
|
||||
final titulo = tester.widget<Text>(find.text('Ecualizador base'));
|
||||
expect(titulo.style?.fontSize, 14);
|
||||
|
||||
final chevron = tester.widget<Icon>(
|
||||
find.byIcon(Icons.chevron_right_rounded),
|
||||
);
|
||||
expect(chevron.size, 19);
|
||||
expect(chevron.color, const Color(0xFFF2F7FA).withValues(alpha: 0.4));
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets(
|
||||
'visual fidelity (audit S10): GrupoAjustes insets its row divider by '
|
||||
'47px, not full-bleed (t4 line 516)',
|
||||
(tester) async {
|
||||
_suppressListTileInkAssertion();
|
||||
await tester.pumpWidget(
|
||||
host(
|
||||
GrupoAjustes(
|
||||
titulo: 'AUDIO',
|
||||
filas: [
|
||||
FilaAjuste(
|
||||
icon: Icons.equalizer_rounded,
|
||||
titulo: 'Uno',
|
||||
onTap: () {},
|
||||
),
|
||||
FilaAjuste(icon: Icons.hd_rounded, titulo: 'Dos', onTap: () {}),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
final divider = tester.widget<Divider>(find.byType(Divider));
|
||||
expect(divider.indent, 47);
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets('visual fidelity (audit 10.5): an explicit iconColor tints the '
|
||||
'leading icon (t4 lines 514/516/522 -- equalizer/hd/folder accents)', (
|
||||
tester,
|
||||
) async {
|
||||
await tester.pumpWidget(
|
||||
host(
|
||||
FilaAjuste(
|
||||
icon: Icons.equalizer_rounded,
|
||||
titulo: 'Ecualizador base',
|
||||
iconColor: const Color(0xFF21D4D9),
|
||||
onTap: () {},
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
final icon = tester.widget<Icon>(find.byIcon(Icons.equalizer_rounded));
|
||||
expect(icon.color, const Color(0xFF21D4D9));
|
||||
});
|
||||
|
||||
testWidgets(
|
||||
'visual fidelity (audit 10.5): omitting iconColor keeps the default '
|
||||
'(unchanged pre-10.5 behaviour)',
|
||||
(tester) async {
|
||||
await tester.pumpWidget(
|
||||
host(
|
||||
FilaAjuste(
|
||||
icon: Icons.language_rounded,
|
||||
titulo: 'Idioma',
|
||||
onTap: () {},
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
final icon = tester.widget<Icon>(find.byIcon(Icons.language_rounded));
|
||||
expect(icon.color, isNull);
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets(
|
||||
'visual fidelity (audit 10.2): the group eyebrow sits OUTSIDE the '
|
||||
'card, at title-tier (20px) padding -- not inside the '
|
||||
'PluriGlassSurface (t4 line 511)',
|
||||
(tester) async {
|
||||
_suppressListTileInkAssertion();
|
||||
await tester.pumpWidget(
|
||||
host(
|
||||
GrupoAjustes(
|
||||
titulo: 'AUDIO',
|
||||
filas: [
|
||||
FilaAjuste(
|
||||
icon: Icons.equalizer_rounded,
|
||||
titulo: 'Uno',
|
||||
onTap: () {},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
expect(
|
||||
find.ancestor(
|
||||
of: find.text('AUDIO'),
|
||||
matching: find.byType(PluriGlassSurface),
|
||||
),
|
||||
findsNothing,
|
||||
reason: 'the eyebrow must not be a descendant of the card',
|
||||
);
|
||||
|
||||
final eyebrowLeft = tester.getTopLeft(find.text('AUDIO')).dx;
|
||||
expect(
|
||||
eyebrowLeft,
|
||||
20,
|
||||
reason: 'S5 title tier -- PluriLayout.titleHorizontal',
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -162,9 +162,10 @@ void main() {
|
||||
await pumpStable(tester);
|
||||
|
||||
// Pushed, not index-switched: exactly one PluriPushScaffold now exists,
|
||||
// and its moved control (the enable switch) is reachable.
|
||||
// and its moved control (the enable switch, audit 11.1 -- now a
|
||||
// header action, t4 line 566) is reachable.
|
||||
expect(find.byType(PluriPushScaffold), findsOneWidget);
|
||||
expect(find.text('Enable equalizer'), findsOneWidget);
|
||||
expect(find.byKey(const ValueKey('eq-master-switch')), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('tapping the Orden de listas row pushes its detail screen', (
|
||||
|
||||
@@ -146,7 +146,8 @@ void main() {
|
||||
|
||||
group('WU11 — restyle a pantalla completa', () {
|
||||
testWidgets(
|
||||
'el tiempo gigante usa PluriWaveTypography.heroTime envuelto en '
|
||||
'el tiempo gigante usa PluriWaveTypography.heroTime (con un ajuste '
|
||||
'local de letter-spacing/height, audit 9.5) envuelto en '
|
||||
'FittedBox(scaleDown)',
|
||||
(tester) async {
|
||||
await _montarPantalla(tester);
|
||||
@@ -155,7 +156,22 @@ void main() {
|
||||
expect(heroFinder, findsOneWidget);
|
||||
final texto = tester.widget<Text>(heroFinder);
|
||||
final contexto = tester.element(heroFinder);
|
||||
expect(texto.style, contexto.pluriType.heroTime);
|
||||
final base = contexto.pluriType.heroTime;
|
||||
// Audit 9.5 (t4 line 417): the prototype wants 88px/w800/ls-4/lh.95
|
||||
// on THIS screen only. heroTime itself must stay ls-2.0/height 1.0
|
||||
// — EditorHoraInline (the alarm editor's hour block) is the other
|
||||
// consumer and wants height:1 (t4 line 379) — so this is a LOCAL
|
||||
// copyWith override, not a change to the shared token.
|
||||
expect(texto.style?.fontSize, base.fontSize);
|
||||
expect(texto.style?.fontWeight, base.fontWeight);
|
||||
expect(texto.style?.letterSpacing, -4);
|
||||
expect(texto.style?.height, 0.95);
|
||||
expect(
|
||||
base.letterSpacing,
|
||||
-2.0,
|
||||
reason: 'the shared token is unchanged',
|
||||
);
|
||||
expect(base.height, 1.0, reason: 'the shared token is unchanged');
|
||||
expect(
|
||||
find.ancestor(of: heroFinder, matching: find.byType(FittedBox)),
|
||||
findsOneWidget,
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_animate/flutter_animate.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:pluriwave/estado/estado_alarmas.dart';
|
||||
import 'package:pluriwave/estado/estado_radio.dart';
|
||||
import 'package:pluriwave/l10n/gen/app_localizations.dart';
|
||||
import 'package:pluriwave/modelos/alarma_musical.dart';
|
||||
import 'package:pluriwave/modelos/emisora.dart';
|
||||
import 'package:pluriwave/pantallas/pantalla_alarma_sonando.dart';
|
||||
import 'package:pluriwave/servicios/servicio_alarmas.dart';
|
||||
import 'package:pluriwave/servicios/servicio_audio.dart';
|
||||
import 'package:pluriwave/tema/pluriwave_theme.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import '../helpers/fakes.dart';
|
||||
import '../helpers/fakes_alarmas.dart';
|
||||
|
||||
/// Tier 4 visual fidelity, ringing screen: 9.1 (pulsing amber halo), 9.6
|
||||
/// (art geometry), 9.8 (snooze eyebrow icon). Item 9.5 (hero time metrics)
|
||||
/// is covered in `pantalla_alarma_sonando_scaffold_test.dart`, which already
|
||||
/// owned the pre-existing `heroTime`-equality assertion that item revises.
|
||||
///
|
||||
/// 9.1 is the one with real hazard: the prototype's halo is a literal CSS
|
||||
/// `animation: pw-pulse 2.4s ease-in-out infinite`. This screen's dismiss
|
||||
/// guard (`pantalla_alarma_sonando_dismiss_guard_test.dart`, protected,
|
||||
/// byte-identical to `main`) calls `pumpAndSettle()` after every mount and
|
||||
/// every interaction — an actually-infinite `AnimationController.repeat()`
|
||||
/// anywhere in this widget's subtree would hang every one of those calls
|
||||
/// forever, with no way to fix it since that file cannot be touched. The
|
||||
/// halo below is a BOUNDED pulse (one grow/settle cycle, no `.repeat()`) —
|
||||
/// genuine animated motion, but one that always finishes.
|
||||
Future<void> _montarPantalla(
|
||||
WidgetTester tester, {
|
||||
bool disableAnimations = false,
|
||||
}) async {
|
||||
tester.view.physicalSize = const Size(1440, 3200);
|
||||
tester.view.devicePixelRatio = 1.0;
|
||||
addTearDown(tester.view.resetPhysicalSize);
|
||||
addTearDown(tester.view.resetDevicePixelRatio);
|
||||
|
||||
final audio = FakeServicioAudio();
|
||||
audio.emitirEstado(EstadoReproduccion.reproduciendo);
|
||||
final radio = EstadoRadio(
|
||||
audio: audio,
|
||||
favoritos: FakeServicioFavoritos(),
|
||||
radio: FakeServicioRadio(),
|
||||
servicioEcualizador: FakeServicioEcualizador(),
|
||||
servicioGrabacion: FakeServicioGrabacionRadioInactiva(),
|
||||
iniciarAutomaticamente: false,
|
||||
);
|
||||
addTearDown(radio.dispose);
|
||||
|
||||
final android = FakePuertoAlarmasAndroid();
|
||||
final estadoAlarmas = EstadoAlarmas(
|
||||
servicio: ServicioAlarmas(reloj: () => DateTime(2026, 6, 11, 7, 0)),
|
||||
android: android,
|
||||
iniciarAutomaticamente: false,
|
||||
);
|
||||
addTearDown(estadoAlarmas.dispose);
|
||||
addTearDown(android.dispose);
|
||||
await estadoAlarmas.guardarAlarma(
|
||||
const AlarmaMusical(
|
||||
id: 'tier4-1',
|
||||
nombre: 'Despertar',
|
||||
hora: 7,
|
||||
minuto: 30,
|
||||
tipoProgramacion: TipoProgramacionAlarma.diaria,
|
||||
diasSemana: [],
|
||||
emisora: Emisora(
|
||||
uuid: 'e1',
|
||||
nombre: 'Radio Uno',
|
||||
url: 'https://radio.example/stream',
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
await tester.pumpWidget(
|
||||
MultiProvider(
|
||||
providers: [
|
||||
ChangeNotifierProvider<EstadoRadio>.value(value: radio),
|
||||
ChangeNotifierProvider<EstadoAlarmas>.value(value: estadoAlarmas),
|
||||
],
|
||||
child: MaterialApp(
|
||||
locale: const Locale('es'),
|
||||
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||
supportedLocales: AppLocalizations.supportedLocales,
|
||||
theme: PluriWaveTheme.dark(),
|
||||
builder:
|
||||
(context, child) => MediaQuery(
|
||||
data: MediaQuery.of(
|
||||
context,
|
||||
).copyWith(disableAnimations: disableAnimations),
|
||||
child: child!,
|
||||
),
|
||||
home: const SizedBox.shrink(),
|
||||
),
|
||||
),
|
||||
);
|
||||
final navigator = tester.state<NavigatorState>(find.byType(Navigator));
|
||||
unawaited(
|
||||
navigator.push(
|
||||
MaterialPageRoute<void>(
|
||||
builder:
|
||||
(_) => PantallaAlarmaSonando(alarma: estadoAlarmas.alarmas.single),
|
||||
fullscreenDialog: true,
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 50));
|
||||
}
|
||||
|
||||
void main() {
|
||||
setUp(() {
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
});
|
||||
|
||||
testWidgets(
|
||||
'visual fidelity (audit 9.1): a pulsing amber halo renders behind the '
|
||||
'hero content, and settling it NEVER hangs (protects the dismiss-guard '
|
||||
"test's repeated pumpAndSettle calls)",
|
||||
(tester) async {
|
||||
await _montarPantalla(tester);
|
||||
|
||||
expect(find.byKey(const ValueKey('ringing-pulse-halo')), findsOneWidget);
|
||||
|
||||
// The critical regression guard: this must COMPLETE. An infinite
|
||||
// AnimationController.repeat() here would hang every pumpAndSettle()
|
||||
// call in the protected dismiss-guard test forever.
|
||||
await tester.pumpAndSettle();
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets(
|
||||
'visual fidelity (audit 9.1): the halo skips its Animate wrapper under '
|
||||
'reduced motion (matches every other entry animation in this app)',
|
||||
(tester) async {
|
||||
await _montarPantalla(tester, disableAnimations: true);
|
||||
|
||||
expect(find.byType(Animate), findsNothing);
|
||||
expect(find.byKey(const ValueKey('ringing-pulse-halo')), findsOneWidget);
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets(
|
||||
'visual fidelity (audit 9.6): the ringing art is 180 square with a 36 '
|
||||
'corner radius, not 168/30',
|
||||
(tester) async {
|
||||
await _montarPantalla(tester);
|
||||
|
||||
final art = tester
|
||||
.widgetList<Image>(find.byType(Image))
|
||||
.where((img) => img.width == 180 && img.height == 180);
|
||||
expect(
|
||||
art,
|
||||
isNotEmpty,
|
||||
reason: 'prototype t4 line 421 sizes the ringing-screen art at 180',
|
||||
);
|
||||
|
||||
final clip = tester.widget<ClipRRect>(
|
||||
find.ancestor(
|
||||
of: find.byWidgetPredicate(
|
||||
(w) => w is Image && w.width == 180 && w.height == 180,
|
||||
),
|
||||
matching: find.byType(ClipRRect),
|
||||
),
|
||||
);
|
||||
expect(
|
||||
(clip.borderRadius as BorderRadius).topLeft,
|
||||
const Radius.circular(36),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets(
|
||||
'visual fidelity (audit 9.8): the POSPONER eyebrow keeps its snooze '
|
||||
'icon at 19px warmCoral',
|
||||
(tester) async {
|
||||
await _montarPantalla(tester);
|
||||
|
||||
final icon = tester.widget<Icon>(find.byIcon(Icons.snooze_rounded));
|
||||
expect(icon.size, 19);
|
||||
expect(icon.color, const Color(0xFFF4B860));
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:pluriwave/estado/estado_alarmas.dart';
|
||||
import 'package:pluriwave/estado/estado_radio.dart';
|
||||
import 'package:pluriwave/l10n/formato_fechas.dart';
|
||||
import 'package:pluriwave/l10n/gen/app_localizations.dart';
|
||||
import 'package:pluriwave/modelos/alarma_musical.dart';
|
||||
import 'package:pluriwave/modelos/emisora.dart';
|
||||
import 'package:pluriwave/pantallas/pantalla_alarma_sonando.dart';
|
||||
import 'package:pluriwave/servicios/servicio_alarmas.dart';
|
||||
import 'package:pluriwave/servicios/servicio_audio.dart';
|
||||
import 'package:pluriwave/tema/pluriwave_theme.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import '../helpers/fakes.dart';
|
||||
import '../helpers/fakes_alarmas.dart';
|
||||
|
||||
/// Closing batch (items 9.4, 9.10, audit id 2521) -- the ringing screen.
|
||||
/// Mirrors `pantalla_alarma_sonando_tier4_test.dart`'s own harness (kept as
|
||||
/// a local copy, not a shared import, matching this file's established
|
||||
/// precedent of small helper duplication over cross-test-file coupling).
|
||||
Future<void> _montarPantalla(WidgetTester tester) async {
|
||||
tester.view.physicalSize = const Size(1440, 3200);
|
||||
tester.view.devicePixelRatio = 1.0;
|
||||
addTearDown(tester.view.resetPhysicalSize);
|
||||
addTearDown(tester.view.resetDevicePixelRatio);
|
||||
|
||||
final audio = FakeServicioAudio();
|
||||
audio.emitirEstado(EstadoReproduccion.reproduciendo);
|
||||
final radio = EstadoRadio(
|
||||
audio: audio,
|
||||
favoritos: FakeServicioFavoritos(),
|
||||
radio: FakeServicioRadio(),
|
||||
servicioEcualizador: FakeServicioEcualizador(),
|
||||
servicioGrabacion: FakeServicioGrabacionRadioInactiva(),
|
||||
iniciarAutomaticamente: false,
|
||||
);
|
||||
addTearDown(radio.dispose);
|
||||
|
||||
final android = FakePuertoAlarmasAndroid();
|
||||
final estadoAlarmas = EstadoAlarmas(
|
||||
servicio: ServicioAlarmas(reloj: () => DateTime(2026, 6, 11, 7, 0)),
|
||||
android: android,
|
||||
iniciarAutomaticamente: false,
|
||||
);
|
||||
addTearDown(estadoAlarmas.dispose);
|
||||
addTearDown(android.dispose);
|
||||
await estadoAlarmas.guardarAlarma(
|
||||
const AlarmaMusical(
|
||||
id: 'tier5-1',
|
||||
nombre: 'Despertar',
|
||||
hora: 7,
|
||||
minuto: 30,
|
||||
tipoProgramacion: TipoProgramacionAlarma.diaria,
|
||||
diasSemana: [],
|
||||
snoozeMinutos: 10,
|
||||
emisora: Emisora(
|
||||
uuid: 'e1',
|
||||
nombre: 'Radio Uno',
|
||||
url: 'https://radio.example/stream',
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
await tester.pumpWidget(
|
||||
MultiProvider(
|
||||
providers: [
|
||||
ChangeNotifierProvider<EstadoRadio>.value(value: radio),
|
||||
ChangeNotifierProvider<EstadoAlarmas>.value(value: estadoAlarmas),
|
||||
],
|
||||
child: MaterialApp(
|
||||
locale: const Locale('es'),
|
||||
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||
supportedLocales: AppLocalizations.supportedLocales,
|
||||
theme: PluriWaveTheme.dark(),
|
||||
home: const SizedBox.shrink(),
|
||||
),
|
||||
),
|
||||
);
|
||||
final navigator = tester.state<NavigatorState>(find.byType(Navigator));
|
||||
unawaited(
|
||||
navigator.push(
|
||||
MaterialPageRoute<void>(
|
||||
builder:
|
||||
(_) => PantallaAlarmaSonando(alarma: estadoAlarmas.alarmas.single),
|
||||
fullscreenDialog: true,
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 50));
|
||||
}
|
||||
|
||||
void main() {
|
||||
setUp(() {
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
});
|
||||
|
||||
testWidgets('visual fidelity (audit 9.4): the date line renders between the '
|
||||
'schedule pill and the hero time (t4:419)', (tester) async {
|
||||
await _montarPantalla(tester);
|
||||
|
||||
final localeTag =
|
||||
Localizations.localeOf(
|
||||
tester.element(find.byType(PantallaAlarmaSonando)),
|
||||
).toString();
|
||||
final esperado = fechaLargaConDiaSemana(localeTag, DateTime.now());
|
||||
|
||||
expect(find.text(esperado), findsOneWidget);
|
||||
|
||||
// Order: pill above the date line, date line above the hero time.
|
||||
final pillY =
|
||||
tester
|
||||
.getBottomLeft(find.byKey(const ValueKey('ringing-schedule-pill')))
|
||||
.dy;
|
||||
final dateY = tester.getTopLeft(find.text(esperado)).dy;
|
||||
final timeY =
|
||||
tester.getTopLeft(find.byKey(const ValueKey('ringing-hero-time'))).dy;
|
||||
expect(pillY <= dateY, isTrue);
|
||||
expect(dateY <= timeY, isTrue);
|
||||
|
||||
// Regression guard: pumpAndSettle must still complete (purely
|
||||
// additive static text, no new animation).
|
||||
await tester.pumpAndSettle();
|
||||
});
|
||||
|
||||
testWidgets(
|
||||
'visual fidelity (audit 9.10): the highlighted snooze tile carries a '
|
||||
'"usual" qualifier ADDITIVELY -- the original flat label the dismiss '
|
||||
'guard finds by text stays fully intact',
|
||||
(tester) async {
|
||||
await _montarPantalla(tester);
|
||||
|
||||
final l10n = AppLocalizations.of(
|
||||
tester.element(find.byType(PantallaAlarmaSonando)),
|
||||
);
|
||||
|
||||
// The ORIGINAL flat label must still resolve to exactly one Text --
|
||||
// this IS the dismiss-guard's own finder contract.
|
||||
expect(find.text(l10n.alarmSnoozeOptionLabel(10)), findsOneWidget);
|
||||
// The additive qualifier renders too, but only on the destacado tile.
|
||||
expect(find.text(l10n.alarmSnoozeUsualLabel), findsOneWidget);
|
||||
expect(find.text(l10n.alarmSnoozeOptionLabel(3)), findsOneWidget);
|
||||
expect(find.text(l10n.alarmSnoozeOptionLabel(5)), findsOneWidget);
|
||||
|
||||
// Regression guard: the exact hazard 9.9/9.10 share -- pumpAndSettle
|
||||
// must still complete, and the flat label must remain the SAME
|
||||
// widget the dismiss guard taps (still inside a FilledButton).
|
||||
expect(
|
||||
find.ancestor(
|
||||
of: find.text(l10n.alarmSnoozeOptionLabel(10)),
|
||||
matching: find.byType(FilledButton),
|
||||
),
|
||||
findsOneWidget,
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:pluriwave/estado/estado_alarmas.dart';
|
||||
import 'package:pluriwave/estado/estado_radio.dart';
|
||||
import 'package:pluriwave/l10n/gen/app_localizations.dart';
|
||||
import 'package:pluriwave/modelos/alarma_musical.dart';
|
||||
import 'package:pluriwave/pantallas/pantalla_alarmas.dart';
|
||||
import 'package:pluriwave/servicios/servicio_alarmas.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import '../helpers/fakes.dart';
|
||||
import '../helpers/fakes_alarmas.dart';
|
||||
|
||||
/// Audit 7.5 (Tier 4 visual fidelity): the prototype's gap between stacked
|
||||
/// alarm cards is 10px (t4 line 334, `gap:10` on the alarms column), not the
|
||||
/// build's previous 12px.
|
||||
void main() {
|
||||
setUp(() {
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
});
|
||||
|
||||
testWidgets('spaces stacked alarm cards 10px apart, not 12', (tester) async {
|
||||
tester.view.physicalSize = const Size(1440, 3200);
|
||||
tester.view.devicePixelRatio = 1.0;
|
||||
addTearDown(tester.view.resetPhysicalSize);
|
||||
addTearDown(tester.view.resetDevicePixelRatio);
|
||||
|
||||
final radio = EstadoRadio(
|
||||
audio: FakeServicioAudio(),
|
||||
favoritos: FakeServicioFavoritos(),
|
||||
radio: FakeServicioRadio(),
|
||||
servicioEcualizador: FakeServicioEcualizador(),
|
||||
servicioGrabacion: FakeServicioGrabacionRadioInactiva(),
|
||||
iniciarAutomaticamente: false,
|
||||
);
|
||||
addTearDown(radio.dispose);
|
||||
|
||||
final android = FakePuertoAlarmasAndroid();
|
||||
final estadoAlarmas = EstadoAlarmas(
|
||||
servicio: ServicioAlarmas(reloj: () => DateTime(2026, 6, 11, 6, 0)),
|
||||
android: android,
|
||||
iniciarAutomaticamente: false,
|
||||
);
|
||||
addTearDown(estadoAlarmas.dispose);
|
||||
addTearDown(android.dispose);
|
||||
await estadoAlarmas.guardarAlarma(
|
||||
const AlarmaMusical(
|
||||
id: 'card-gap-a',
|
||||
nombre: 'Primera',
|
||||
hora: 7,
|
||||
minuto: 0,
|
||||
tipoProgramacion: TipoProgramacionAlarma.diaria,
|
||||
diasSemana: [],
|
||||
),
|
||||
);
|
||||
await estadoAlarmas.guardarAlarma(
|
||||
const AlarmaMusical(
|
||||
id: 'card-gap-b',
|
||||
nombre: 'Segunda',
|
||||
hora: 8,
|
||||
minuto: 0,
|
||||
tipoProgramacion: TipoProgramacionAlarma.diaria,
|
||||
diasSemana: [],
|
||||
),
|
||||
);
|
||||
|
||||
await tester.pumpWidget(
|
||||
MultiProvider(
|
||||
providers: [
|
||||
ChangeNotifierProvider<EstadoRadio>.value(value: radio),
|
||||
ChangeNotifierProvider<EstadoAlarmas>.value(value: estadoAlarmas),
|
||||
],
|
||||
child: MaterialApp(
|
||||
locale: const Locale('es'),
|
||||
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||
supportedLocales: AppLocalizations.supportedLocales,
|
||||
home: const Scaffold(body: PantallaAlarmas()),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 100));
|
||||
|
||||
final gap = tester.widget<SizedBox>(
|
||||
find.byKey(const ValueKey('alarm-card-gap-card-gap-a')),
|
||||
);
|
||||
expect(gap.height, 10);
|
||||
});
|
||||
}
|
||||
@@ -400,6 +400,66 @@ void main() {
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets('8.5: the "REPETIR" eyebrow sits above the weekday circles', (
|
||||
tester,
|
||||
) async {
|
||||
await _abrirEditor(tester);
|
||||
|
||||
expect(find.text(l10n.alarmRepeatSectionLabel), findsOneWidget);
|
||||
final eyebrowY =
|
||||
tester.getBottomLeft(find.text(l10n.alarmRepeatSectionLabel)).dy;
|
||||
final circlesY =
|
||||
tester
|
||||
.getTopLeft(find.byKey(const ValueKey('alarm-weekday-circles')))
|
||||
.dy;
|
||||
expect(eyebrowY <= circlesY, isTrue);
|
||||
});
|
||||
|
||||
testWidgets(
|
||||
'8.6: the station field, volume, fade-in and vacation toggle share '
|
||||
'ONE grouped card',
|
||||
(tester) async {
|
||||
await _abrirEditor(tester);
|
||||
|
||||
final tarjeta = find.byKey(const ValueKey('alarm-editor-grouped-card'));
|
||||
expect(tarjeta, findsOneWidget);
|
||||
expect(
|
||||
find.descendant(
|
||||
of: tarjeta,
|
||||
matching: find.byKey(const ValueKey('alarm-station-field')),
|
||||
),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(
|
||||
find.descendant(of: tarjeta, matching: find.byType(Slider)),
|
||||
findsNWidgets(2),
|
||||
reason: 'volume + fade-in sliders both live inside the card',
|
||||
);
|
||||
expect(
|
||||
find.descendant(of: tarjeta, matching: find.byType(SwitchListTile)),
|
||||
findsOneWidget,
|
||||
reason: 'the vacation toggle lives inside the card',
|
||||
);
|
||||
// Snooze duration is a deliberate EXTRA (audit 8.8), kept OUTSIDE
|
||||
// the prototype-mandated 4-row card.
|
||||
expect(
|
||||
find.descendant(
|
||||
of: tarjeta,
|
||||
matching: find.text(l10n.alarmSnoozeDurationTitle),
|
||||
),
|
||||
findsNothing,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets('8.7: the volume row shows a live percentage label next to a '
|
||||
'compact track', (tester) async {
|
||||
await _abrirEditor(tester);
|
||||
|
||||
expect(find.text(l10n.alarmVolumeLabel), findsOneWidget);
|
||||
expect(find.text('85%'), findsOneWidget);
|
||||
});
|
||||
});
|
||||
|
||||
group('WU8 — tarjeta de alarma simplificada', () {
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:pluriwave/estado/estado_alarmas.dart';
|
||||
import 'package:pluriwave/estado/estado_radio.dart';
|
||||
import 'package:pluriwave/l10n/gen/app_localizations.dart';
|
||||
import 'package:pluriwave/modelos/alarma_musical.dart';
|
||||
import 'package:pluriwave/modelos/emisora.dart';
|
||||
import 'package:pluriwave/pantallas/pantalla_alarmas.dart';
|
||||
import 'package:pluriwave/servicios/servicio_alarmas.dart';
|
||||
import 'package:pluriwave/tema/pluriwave_tokens.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import '../helpers/fakes.dart';
|
||||
import '../helpers/fakes_alarmas.dart';
|
||||
|
||||
/// Tier 5 visual-fidelity closeout (items 7.1-7.4, audit id 2521) --
|
||||
/// screens 4-14 batch.
|
||||
void main() {
|
||||
setUp(() {
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
});
|
||||
|
||||
Future<void> montar(
|
||||
WidgetTester tester, {
|
||||
required EstadoAlarmas estadoAlarmas,
|
||||
EstadoRadio? radio,
|
||||
}) async {
|
||||
tester.view.physicalSize = const Size(1440, 3200);
|
||||
tester.view.devicePixelRatio = 1.0;
|
||||
addTearDown(tester.view.resetPhysicalSize);
|
||||
addTearDown(tester.view.resetDevicePixelRatio);
|
||||
|
||||
final estadoRadio =
|
||||
radio ??
|
||||
EstadoRadio(
|
||||
audio: FakeServicioAudio(),
|
||||
favoritos: FakeServicioFavoritos(),
|
||||
radio: FakeServicioRadio(),
|
||||
servicioEcualizador: FakeServicioEcualizador(),
|
||||
servicioGrabacion: FakeServicioGrabacionRadioInactiva(),
|
||||
iniciarAutomaticamente: false,
|
||||
);
|
||||
if (radio == null) addTearDown(estadoRadio.dispose);
|
||||
|
||||
await tester.pumpWidget(
|
||||
MultiProvider(
|
||||
providers: [
|
||||
ChangeNotifierProvider<EstadoRadio>.value(value: estadoRadio),
|
||||
ChangeNotifierProvider<EstadoAlarmas>.value(value: estadoAlarmas),
|
||||
],
|
||||
child: MaterialApp(
|
||||
locale: const Locale('en'),
|
||||
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||
supportedLocales: AppLocalizations.supportedLocales,
|
||||
home: const Scaffold(body: PantallaAlarmas()),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 100));
|
||||
}
|
||||
|
||||
EstadoAlarmas crearEstado() {
|
||||
final android = FakePuertoAlarmasAndroid();
|
||||
final estado = EstadoAlarmas(
|
||||
servicio: ServicioAlarmas(reloj: () => DateTime(2026, 6, 11, 6, 0)),
|
||||
android: android,
|
||||
iniciarAutomaticamente: false,
|
||||
);
|
||||
return estado;
|
||||
}
|
||||
|
||||
group('visual fidelity (audit 7.1): "New" action', () {
|
||||
testWidgets('is a solid brand-teal pill with an add glyph, not the tonal '
|
||||
'auto_awesome button (t4:325)', (tester) async {
|
||||
final estado = crearEstado();
|
||||
addTearDown(estado.dispose);
|
||||
await montar(tester, estadoAlarmas: estado);
|
||||
|
||||
expect(find.byIcon(Icons.auto_awesome_rounded), findsNothing);
|
||||
expect(find.byIcon(Icons.add_rounded), findsOneWidget);
|
||||
|
||||
final l10n = lookupAppLocalizations(const Locale('en'));
|
||||
final boton = tester.widget<FilledButton>(find.byType(FilledButton));
|
||||
final resuelto = boton.style?.backgroundColor?.resolve(<WidgetState>{});
|
||||
expect(resuelto, PluriWaveTokens.brand);
|
||||
expect(find.text(l10n.createAlarmAction), findsOneWidget);
|
||||
});
|
||||
});
|
||||
|
||||
group('visual fidelity (audit 7.2): next-alarm banner', () {
|
||||
testWidgets(
|
||||
'is warmCoral-tinted and the Skip chip sits BESIDE the text, not '
|
||||
'below it (t4:327-331)',
|
||||
(tester) async {
|
||||
final estado = crearEstado();
|
||||
addTearDown(estado.dispose);
|
||||
await estado.guardarAlarma(
|
||||
const AlarmaMusical(
|
||||
id: 'proxima',
|
||||
nombre: 'Despertar',
|
||||
hora: 7,
|
||||
minuto: 30,
|
||||
tipoProgramacion: TipoProgramacionAlarma.diaria,
|
||||
diasSemana: [],
|
||||
),
|
||||
);
|
||||
await montar(tester, estadoAlarmas: estado);
|
||||
|
||||
final l10n = lookupAppLocalizations(const Locale('en'));
|
||||
final banner = tester.widget<DecoratedBox>(
|
||||
find.byKey(const ValueKey('next-alarm-banner')),
|
||||
);
|
||||
final decoration = banner.decoration as BoxDecoration;
|
||||
expect(
|
||||
decoration.color,
|
||||
const Color(0xFFF4B860).withValues(alpha: 0.13),
|
||||
);
|
||||
|
||||
final skipY = tester.getCenter(find.text(l10n.alarmHeroSkipAction)).dy;
|
||||
final titleY = tester.getCenter(find.byIcon(Icons.alarm_on)).dy;
|
||||
expect(
|
||||
(skipY - titleY).abs() < 4,
|
||||
isTrue,
|
||||
reason: 'the Skip chip sits on the SAME row as the icon/text',
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
group('visual fidelity (audit 7.3): vacation row date pill', () {
|
||||
testWidgets('shows the upcoming range as a "d-d MON" pill (t4:332)', (
|
||||
tester,
|
||||
) async {
|
||||
final estado = crearEstado();
|
||||
addTearDown(estado.dispose);
|
||||
await estado.crearRangoVacaciones(
|
||||
estado.servicio.crearRangoVacaciones(
|
||||
inicio: DateTime(2026, 8, 4),
|
||||
fin: DateTime(2026, 8, 18),
|
||||
nombre: 'Summer',
|
||||
),
|
||||
);
|
||||
await montar(tester, estadoAlarmas: estado);
|
||||
|
||||
expect(find.text('4–18 AUG'), findsOneWidget);
|
||||
});
|
||||
});
|
||||
|
||||
group('visual fidelity (audit 7.4): alarm card', () {
|
||||
testWidgets(
|
||||
'shows a recurrence label next to the time and a station icon next '
|
||||
'to the station name (t4:337-345)',
|
||||
(tester) async {
|
||||
final estado = crearEstado();
|
||||
addTearDown(estado.dispose);
|
||||
await estado.guardarAlarma(
|
||||
const AlarmaMusical(
|
||||
id: 'tarjeta',
|
||||
nombre: 'Despertar',
|
||||
hora: 7,
|
||||
minuto: 30,
|
||||
tipoProgramacion: TipoProgramacionAlarma.diaria,
|
||||
diasSemana: [],
|
||||
emisora: Emisora(
|
||||
uuid: 'e1',
|
||||
nombre: 'Radio Uno',
|
||||
url: 'https://radio.example/stream',
|
||||
),
|
||||
),
|
||||
);
|
||||
await montar(tester, estadoAlarmas: estado);
|
||||
|
||||
final l10n = lookupAppLocalizations(const Locale('en'));
|
||||
expect(
|
||||
find.descendant(
|
||||
of: find.byKey(const ValueKey('tarjeta-alarma-tarjeta')),
|
||||
matching: find.text(l10n.dailyOption),
|
||||
),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(
|
||||
find.descendant(
|
||||
of: find.byKey(const ValueKey('tarjeta-alarma-tarjeta')),
|
||||
matching: find.byKey(const ValueKey('tarjeta-alarma-arte')),
|
||||
),
|
||||
findsOneWidget,
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -65,6 +65,110 @@ void main() {
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets(
|
||||
'visual fidelity (audit 14.4/14.5): body copy is 14.5px, bullet titles '
|
||||
'are 13.5px/w800, bullet subtitles are 11.5px (t4 lines 697/700-702)',
|
||||
(tester) async {
|
||||
final navegacion = EstadoNavegacionRaiz();
|
||||
addTearDown(navegacion.dispose);
|
||||
|
||||
await tester.pumpWidget(_app(navegacion: navegacion));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final l10n = lookupAppLocalizations(const Locale('es'));
|
||||
|
||||
final body = tester.widget<Text>(
|
||||
find.textContaining('53.412 emisoras de 238 países'),
|
||||
);
|
||||
expect(body.style?.fontSize, 14.5);
|
||||
|
||||
final tituloBullet = tester.widget<Text>(
|
||||
find.text(l10n.welcomeBullet1Title),
|
||||
);
|
||||
expect(tituloBullet.style?.fontSize, 13.5);
|
||||
expect(tituloBullet.style?.fontWeight, FontWeight.w800);
|
||||
|
||||
final subtituloBullet = tester.widget<Text>(
|
||||
find.text(l10n.welcomeBullet1Subtitle),
|
||||
);
|
||||
expect(subtituloBullet.style?.fontSize, 11.5);
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets(
|
||||
'visual fidelity (audit 14.6): the 3 feature bullets are spaced 12px '
|
||||
'apart, not 16 (t4 line 701)',
|
||||
(tester) async {
|
||||
final navegacion = EstadoNavegacionRaiz();
|
||||
addTearDown(navegacion.dispose);
|
||||
|
||||
await tester.pumpWidget(_app(navegacion: navegacion));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final bullets = find.byType(FilaCaracteristicaBienvenida);
|
||||
final gap =
|
||||
tester.getTopLeft(bullets.at(1)).dy -
|
||||
tester.getBottomLeft(bullets.at(0)).dy;
|
||||
expect(gap, 12);
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets(
|
||||
'visual fidelity (audit 14.8): the logo mark is clipped to a 20px '
|
||||
'rounded rect, not painted square (t4 line 693)',
|
||||
(tester) async {
|
||||
final navegacion = EstadoNavegacionRaiz();
|
||||
addTearDown(navegacion.dispose);
|
||||
|
||||
await tester.pumpWidget(_app(navegacion: navegacion));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final clip = tester.widget<ClipRRect>(
|
||||
find.ancestor(
|
||||
of: find.byKey(const ValueKey('welcome-logo')),
|
||||
matching: find.byType(ClipRRect),
|
||||
),
|
||||
);
|
||||
expect(clip.borderRadius, BorderRadius.circular(20));
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets('visual fidelity (audit 14.3): the headline is 34px/w800/ls-1.2 '
|
||||
'(t4 line 696), not headlineMedium\'s 28', (tester) async {
|
||||
final navegacion = EstadoNavegacionRaiz();
|
||||
addTearDown(navegacion.dispose);
|
||||
|
||||
await tester.pumpWidget(_app(navegacion: navegacion));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final headline = tester.widget<Text>(find.text('Tu mundo, en directo'));
|
||||
expect(headline.style?.fontSize, 34);
|
||||
expect(headline.style?.fontWeight, FontWeight.w800);
|
||||
expect(headline.style?.letterSpacing, -1.2);
|
||||
});
|
||||
|
||||
testWidgets('visual fidelity (audit 14.7): the CTA is a radius-18 rounded '
|
||||
'rectangle (t4 line 715), not the default fully-round StadiumBorder', (
|
||||
tester,
|
||||
) async {
|
||||
final navegacion = EstadoNavegacionRaiz();
|
||||
addTearDown(navegacion.dispose);
|
||||
|
||||
await tester.pumpWidget(_app(navegacion: navegacion));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final boton = tester.widget<FilledButton>(find.byType(FilledButton));
|
||||
final forma = boton.style?.shape?.resolve(<WidgetState>{});
|
||||
expect(
|
||||
forma,
|
||||
isA<RoundedRectangleBorder>().having(
|
||||
(s) => (s.borderRadius as BorderRadius).topLeft,
|
||||
'topLeft radius',
|
||||
const Radius.circular(18),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('rendered content contains no monetization strings', (
|
||||
tester,
|
||||
) async {
|
||||
|
||||
@@ -89,4 +89,55 @@ void main() {
|
||||
expect(find.byType(TarjetaEmisoraShimmer), findsWidgets);
|
||||
expect(find.byType(CircularProgressIndicator), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('visual fidelity (audit 13.3/13.4): the "BUSCANDO EMISORAS..." '
|
||||
'eyebrow renders above the loading rows, spaced 4px apart (t4:649-651)', (
|
||||
tester,
|
||||
) async {
|
||||
final busqueda = _BusquedaCargando();
|
||||
addTearDown(busqueda.dispose);
|
||||
final estado = EstadoRadio(
|
||||
audio: FakeServicioAudio(),
|
||||
favoritos: FakeServicioFavoritos(),
|
||||
radio: FakeServicioRadio(),
|
||||
servicioEcualizador: FakeServicioEcualizador(),
|
||||
servicioGrabacion: FakeServicioGrabacionRadio(),
|
||||
resolverArchivoCustom: () async => throw UnimplementedError(),
|
||||
iniciarAutomaticamente: false,
|
||||
);
|
||||
addTearDown(estado.dispose);
|
||||
|
||||
await tester.pumpWidget(
|
||||
MultiProvider(
|
||||
providers: [
|
||||
ChangeNotifierProvider<EstadoRadio>.value(value: estado),
|
||||
ListenableProvider<EstadoEcualizador>.value(
|
||||
value: estado.ecualizador,
|
||||
),
|
||||
ListenableProvider<EstadoGrabacion>.value(value: estado.grabacion),
|
||||
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();
|
||||
await tester.enterText(find.byType(SearchBar), 'jazz');
|
||||
await tester.pump();
|
||||
|
||||
final l10n = AppLocalizations.of(
|
||||
tester.element(find.byType(PantallaBuscar)),
|
||||
);
|
||||
expect(find.text(l10n.searchLoadingStationsLabel), findsOneWidget);
|
||||
|
||||
final filas = find.byType(TarjetaEmisoraShimmer);
|
||||
expect(filas, findsWidgets);
|
||||
final primeraAbajo = tester.getBottomLeft(filas.at(0)).dy;
|
||||
final segundaArriba = tester.getTopLeft(filas.at(1)).dy;
|
||||
expect(segundaArriba - primeraAbajo, 4);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -7,11 +7,14 @@ import 'package:pluriwave/estado/estado_ecualizador.dart';
|
||||
import 'package:pluriwave/estado/estado_grabacion.dart';
|
||||
import 'package:pluriwave/estado/estado_radio.dart';
|
||||
import 'package:pluriwave/l10n/gen/app_localizations.dart';
|
||||
import 'package:pluriwave/modelos/pais_radio.dart';
|
||||
import 'package:pluriwave/pantallas/pantalla_buscar.dart';
|
||||
import 'package:pluriwave/pantallas/pantalla_favoritos.dart';
|
||||
import 'package:pluriwave/pantallas/pantalla_paises.dart';
|
||||
import 'package:pluriwave/servicios/servicio_audio.dart';
|
||||
import 'package:pluriwave/tema/pluriwave_theme.dart';
|
||||
import 'package:pluriwave/tema/pluriwave_tokens.dart';
|
||||
import 'package:pluriwave/widgets/fila_emisora_plana.dart';
|
||||
import 'package:pluriwave/widgets/tarjeta_emisora.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
@@ -178,6 +181,101 @@ void main() {
|
||||
);
|
||||
});
|
||||
|
||||
group('visual fidelity (audit 6.2/6.3/6.4)', () {
|
||||
testWidgets(
|
||||
'6.2: an active filter pill is brand-teal tinted with an inline '
|
||||
'close glyph (t4:292-293)',
|
||||
(tester) async {
|
||||
_setLargeSurfaceSize(tester);
|
||||
final estado = _crearEstado(
|
||||
radio: FakeServicioRadio(
|
||||
busqueda: [emisoraDemo(uuid: 'es-1', nombre: 'Radio Espana')],
|
||||
),
|
||||
);
|
||||
addTearDown(estado.dispose);
|
||||
await tester.runAsync(estado.inicializar);
|
||||
|
||||
await tester.pumpWidget(_conProviders(estado, _testApp()));
|
||||
await _pumpStableFrame(tester);
|
||||
|
||||
final l10n = _l10nDe(tester);
|
||||
await _abrirYSeleccionarPais(tester, l10n.countrySpain);
|
||||
await _pumpStableFrame(tester);
|
||||
|
||||
final chip = tester.widget<Chip>(
|
||||
find.ancestor(
|
||||
of: find.text(l10n.countrySpain),
|
||||
matching: find.byType(Chip),
|
||||
),
|
||||
);
|
||||
expect(
|
||||
chip.backgroundColor,
|
||||
const Color(0xFF21D4D9).withValues(alpha: 0.2),
|
||||
);
|
||||
expect(
|
||||
(chip.shape as RoundedRectangleBorder?)?.side.color,
|
||||
const Color(0xFF21D4D9).withValues(alpha: 0.45),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets(
|
||||
'6.3: an "Idioma" entry chip is always reachable once a search is '
|
||||
'active, opening the filters sheet (t4:294-295)',
|
||||
(tester) async {
|
||||
_setLargeSurfaceSize(tester);
|
||||
final estado = _crearEstado(
|
||||
radio: FakeServicioRadio(
|
||||
busqueda: [emisoraDemo(uuid: 'r-1', nombre: 'Radio Uno')],
|
||||
),
|
||||
);
|
||||
addTearDown(estado.dispose);
|
||||
await tester.runAsync(estado.inicializar);
|
||||
|
||||
await tester.pumpWidget(_conProviders(estado, _testApp()));
|
||||
await _pumpStableFrame(tester);
|
||||
await tester.enterText(find.byType(SearchBar), 'radio');
|
||||
await tester.testTextInput.receiveAction(TextInputAction.done);
|
||||
await _pumpStableFrame(tester);
|
||||
|
||||
final l10n = _l10nDe(tester);
|
||||
expect(find.text(l10n.searchLanguageFilterLabel), findsOneWidget);
|
||||
|
||||
await tester.tap(find.text(l10n.searchLanguageFilterLabel));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text(l10n.searchCountryFilterLabel), findsOneWidget);
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets(
|
||||
'6.4: the results-count eyebrow uses eyebrowLabel styling (t4:299)',
|
||||
(tester) async {
|
||||
_setLargeSurfaceSize(tester);
|
||||
final estado = _crearEstado(
|
||||
radio: FakeServicioRadio(
|
||||
busqueda: [emisoraDemo(uuid: 'r-1', nombre: 'Radio Uno')],
|
||||
),
|
||||
);
|
||||
addTearDown(estado.dispose);
|
||||
await tester.runAsync(estado.inicializar);
|
||||
|
||||
await tester.pumpWidget(_conProviders(estado, _testApp()));
|
||||
await _pumpStableFrame(tester);
|
||||
await tester.enterText(find.byType(SearchBar), 'radio');
|
||||
await tester.testTextInput.receiveAction(TextInputAction.done);
|
||||
await _pumpStableFrame(tester);
|
||||
|
||||
final l10n = _l10nDe(tester);
|
||||
final texto = tester.widget<Text>(
|
||||
find.text(l10n.searchResultsCount(1)),
|
||||
);
|
||||
expect(texto.style?.fontSize, 11);
|
||||
expect(texto.style?.fontWeight, FontWeight.w800);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
group('Buscar — Ordenar (client-side, WU6)', () {
|
||||
testWidgets(
|
||||
'cada opcion renderizada de Ordenar corresponde a un caso real de '
|
||||
@@ -541,6 +639,46 @@ void main() {
|
||||
expect(find.byType(PantallaPaises), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets(
|
||||
'Item 24: tapping a country row in Paises pops back and filters '
|
||||
'Buscar by that ISO code',
|
||||
(tester) async {
|
||||
_setLargeSurfaceSize(tester);
|
||||
final estado = _crearEstado(
|
||||
radio: FakeServicioRadio(
|
||||
paises: const [
|
||||
PaisRadio(
|
||||
nombre: 'Kazakhstan',
|
||||
codigoIso: 'KZ',
|
||||
numeroEmisoras: 12,
|
||||
),
|
||||
],
|
||||
busqueda: [emisoraDemo(uuid: 'kz-1', nombre: 'Radio Kazajstan')],
|
||||
),
|
||||
);
|
||||
addTearDown(estado.dispose);
|
||||
await tester.runAsync(estado.inicializar);
|
||||
|
||||
await tester.pumpWidget(_conProviders(estado, _testApp()));
|
||||
await _pumpStableFrame(tester);
|
||||
|
||||
await tester.tap(find.byKey(const Key('explore-cell-paises')));
|
||||
await _pumpStableFrame(tester);
|
||||
expect(find.byType(PantallaPaises), findsOneWidget);
|
||||
|
||||
await tester.tap(find.text('Kazakhstan'));
|
||||
await _pumpStableFrame(tester);
|
||||
|
||||
expect(
|
||||
find.byType(PantallaPaises),
|
||||
findsNothing,
|
||||
reason: 'selecting a country pops back to Buscar',
|
||||
);
|
||||
expect(find.text('Radio Kazajstan'), findsOneWidget);
|
||||
expect(estado.busqueda.resultados.map((e) => e.uuid), contains('kz-1'));
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets(
|
||||
'tapping Generos opens a picker sheet; selecting a genre closes it '
|
||||
'and filters the discovery grid (same capability, relocated)',
|
||||
@@ -621,6 +759,215 @@ void main() {
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
// Item 23 / audit 6.5 + 6.6 (t4:302-306): flat, background-less search-
|
||||
// result rows with a square thumbnail, a favourite toggle, and a circular
|
||||
// play affordance -- replacing the full glass TarjetaEmisora card. Rows
|
||||
// sit back-to-back (no 10px separator), matching t4:299's bare column.
|
||||
group('Item 23 -- flat search-result rows (audit 6.5, 6.6)', () {
|
||||
testWidgets(
|
||||
'each result is a flat FilaEmisoraPlana with a "genre - country - '
|
||||
'kbps" meta line, not the full glass card',
|
||||
(tester) async {
|
||||
_setLargeSurfaceSize(tester);
|
||||
final estado = _crearEstado(
|
||||
radio: FakeServicioRadio(
|
||||
busqueda: [
|
||||
emisoraDemo(
|
||||
uuid: 'r-1',
|
||||
nombre: 'Radio Uno',
|
||||
).copyWith(tags: 'Rock', pais: 'Spain', bitrate: 128),
|
||||
],
|
||||
),
|
||||
);
|
||||
addTearDown(estado.dispose);
|
||||
await tester.runAsync(estado.inicializar);
|
||||
|
||||
await tester.pumpWidget(_conProviders(estado, _testApp()));
|
||||
await _pumpStableFrame(tester);
|
||||
await tester.enterText(find.byType(SearchBar), 'radio');
|
||||
await tester.testTextInput.receiveAction(TextInputAction.done);
|
||||
await _pumpStableFrame(tester);
|
||||
|
||||
expect(find.byType(FilaEmisoraPlana), findsOneWidget);
|
||||
expect(
|
||||
find.byType(TarjetaEmisora),
|
||||
findsNothing,
|
||||
reason: 'audit 6.5 replaces the full glass card with a flat row',
|
||||
);
|
||||
expect(find.text('Rock · Spain · 128 kbps'), findsOneWidget);
|
||||
expect(find.byType(BotonFavoritoEmisora), findsOneWidget);
|
||||
expect(find.byType(BotonReproducirCircular), findsOneWidget);
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets('rows sit back-to-back, with no 10px separator (t4:299)', (
|
||||
tester,
|
||||
) async {
|
||||
_setLargeSurfaceSize(tester);
|
||||
final estado = _crearEstado(
|
||||
radio: FakeServicioRadio(
|
||||
busqueda: [
|
||||
emisoraDemo(uuid: 'r-1', nombre: 'Radio Uno'),
|
||||
emisoraDemo(uuid: 'r-2', nombre: 'Radio Dos'),
|
||||
],
|
||||
),
|
||||
);
|
||||
addTearDown(estado.dispose);
|
||||
await tester.runAsync(estado.inicializar);
|
||||
|
||||
await tester.pumpWidget(_conProviders(estado, _testApp()));
|
||||
await _pumpStableFrame(tester);
|
||||
await tester.enterText(find.byType(SearchBar), 'radio');
|
||||
await tester.testTextInput.receiveAction(TextInputAction.done);
|
||||
await _pumpStableFrame(tester);
|
||||
|
||||
final filas = find.byType(FilaEmisoraPlana);
|
||||
expect(filas, findsNWidgets(2));
|
||||
final primeraAbajo = tester.getBottomLeft(filas.at(0)).dy;
|
||||
final segundaArriba = tester.getTopLeft(filas.at(1)).dy;
|
||||
expect(
|
||||
segundaArriba - primeraAbajo,
|
||||
closeTo(0, 0.5),
|
||||
reason:
|
||||
't4:299 rows have no separator; each keeps only its own '
|
||||
'8px padding',
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets(
|
||||
'tapping the favourite toggle on a search result adds it to favorites',
|
||||
(tester) async {
|
||||
_setLargeSurfaceSize(tester);
|
||||
final estado = _crearEstado(
|
||||
radio: FakeServicioRadio(
|
||||
busqueda: [emisoraDemo(uuid: 'r-1', nombre: 'Radio Uno')],
|
||||
),
|
||||
);
|
||||
addTearDown(estado.dispose);
|
||||
await tester.runAsync(estado.inicializar);
|
||||
|
||||
await tester.pumpWidget(_conProviders(estado, _testApp()));
|
||||
await _pumpStableFrame(tester);
|
||||
await tester.enterText(find.byType(SearchBar), 'radio');
|
||||
await tester.testTextInput.receiveAction(TextInputAction.done);
|
||||
await _pumpStableFrame(tester);
|
||||
|
||||
expect(find.byIcon(Icons.favorite_outline_rounded), findsOneWidget);
|
||||
await tester.tap(find.byIcon(Icons.favorite_outline_rounded));
|
||||
await _pumpStableFrame(tester);
|
||||
|
||||
expect(await estado.esFavorito('r-1'), isTrue);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
// Item 25 / audit 13.2 (t4:643-646): a reconnect card with a rotating
|
||||
// ring, the station name, and a stop affordance -- previously the ONLY
|
||||
// signal of a reconnect was a word in the mini player.
|
||||
//
|
||||
// Hazard: `pumpAndSettle()` never terminates while this card's rotating
|
||||
// ring animates -- every assertion below uses a bounded `pump()` once
|
||||
// `reconectando` is emitted, never `_pumpStableFrame`'s `pumpAndSettle`.
|
||||
group('visual fidelity (audit 13.5/13.6)', () {
|
||||
testWidgets(
|
||||
'13.5/13.6: the no-results card quotes the query, and the "clear '
|
||||
'filters" pill sits INSIDE the same card (t4:657-663)',
|
||||
(tester) async {
|
||||
_setLargeSurfaceSize(tester);
|
||||
final estado = _crearEstado(radio: FakeServicioRadio(busqueda: []));
|
||||
addTearDown(estado.dispose);
|
||||
await tester.runAsync(estado.inicializar);
|
||||
|
||||
await tester.pumpWidget(_conProviders(estado, _testApp()));
|
||||
await _pumpStableFrame(tester);
|
||||
await _abrirYSeleccionarPais(tester, _l10nDe(tester).countrySpain);
|
||||
await _pumpStableFrame(tester);
|
||||
await tester.enterText(find.byType(SearchBar), 'jazzz');
|
||||
await tester.testTextInput.receiveAction(TextInputAction.done);
|
||||
await _pumpStableFrame(tester);
|
||||
|
||||
final l10n = _l10nDe(tester);
|
||||
expect(
|
||||
find.text(l10n.searchNoResultsForQueryTitle('jazzz')),
|
||||
findsOneWidget,
|
||||
);
|
||||
|
||||
final tarjeta = find.byKey(const ValueKey('search-no-results-card'));
|
||||
expect(tarjeta, findsOneWidget);
|
||||
expect(
|
||||
find.descendant(
|
||||
of: tarjeta,
|
||||
matching: find.text(l10n.searchClearFiltersAction(1)),
|
||||
),
|
||||
findsOneWidget,
|
||||
reason: 't4:667 the clear-filters pill sits INSIDE the card',
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
group('Item 25 -- reconnect card (audit 13.2)', () {
|
||||
testWidgets(
|
||||
'shows the station name, "Reconectando...", and a stop button while '
|
||||
'reconnecting',
|
||||
(tester) async {
|
||||
_setLargeSurfaceSize(tester);
|
||||
final audio = FakeServicioAudio();
|
||||
final estado = _crearEstado(audio: audio);
|
||||
addTearDown(estado.dispose);
|
||||
await tester.runAsync(estado.inicializar);
|
||||
await audio.reproducir(emisoraDemo(uuid: 'r1', nombre: 'Radio Uno'));
|
||||
|
||||
await tester.pumpWidget(_conProviders(estado, _testApp()));
|
||||
await _pumpStableFrame(tester);
|
||||
|
||||
expect(
|
||||
find.byIcon(Icons.stop_circle_rounded),
|
||||
findsNothing,
|
||||
reason: 'not reconnecting yet -- the card must not show',
|
||||
);
|
||||
|
||||
audio.emitirEstado(EstadoReproduccion.reconectando);
|
||||
await tester.pump();
|
||||
await tester.pump();
|
||||
|
||||
expect(find.text('Radio Uno'), findsOneWidget);
|
||||
final l10n = _l10nDe(tester);
|
||||
expect(find.text(l10n.playbackStatusReconnecting), findsOneWidget);
|
||||
expect(find.byIcon(Icons.stop_circle_rounded), findsOneWidget);
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets('tapping stop calls EstadoRadio.detenerReproduccion', (
|
||||
tester,
|
||||
) async {
|
||||
_setLargeSurfaceSize(tester);
|
||||
final audio = FakeServicioAudio();
|
||||
final estado = _crearEstado(audio: audio);
|
||||
addTearDown(estado.dispose);
|
||||
await tester.runAsync(estado.inicializar);
|
||||
await audio.reproducir(emisoraDemo(uuid: 'r1', nombre: 'Radio Uno'));
|
||||
|
||||
await tester.pumpWidget(_conProviders(estado, _testApp()));
|
||||
await _pumpStableFrame(tester);
|
||||
|
||||
audio.emitirEstado(EstadoReproduccion.reconectando);
|
||||
await tester.pump();
|
||||
await tester.pump();
|
||||
|
||||
await tester.tap(find.byIcon(Icons.stop_circle_rounded));
|
||||
await tester.pump();
|
||||
await tester.pump();
|
||||
|
||||
// `EstadoRadio.emisoraActual` deliberately keeps showing the last
|
||||
// selected station even once stopped (an existing, unrelated
|
||||
// property of `_emisoraSeleccionada`'s tracking) -- the real,
|
||||
// user-visible effect of tapping stop is that this card disappears,
|
||||
// since it only renders while `reconectando`.
|
||||
expect(find.byKey(const ValueKey('tarjeta-reconectando')), findsNothing);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
EstadoRadio _crearEstado({
|
||||
|
||||
@@ -6,7 +6,9 @@ import 'package:pluriwave/estado/estado_radio.dart';
|
||||
import 'package:pluriwave/l10n/gen/app_localizations.dart';
|
||||
import 'package:pluriwave/pantallas/ajustes/pantalla_ajustes_grupos_favoritos.dart';
|
||||
import 'package:pluriwave/pantallas/pantalla_favoritos.dart';
|
||||
import 'package:pluriwave/widgets/fila_emisora_plana.dart';
|
||||
import 'package:pluriwave/widgets/pluri_push_scaffold.dart';
|
||||
import 'package:pluriwave/widgets/tarjeta_emisora.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
@@ -100,6 +102,33 @@ void main() {
|
||||
await tester.pump(const Duration(milliseconds: 100));
|
||||
}
|
||||
|
||||
group('visual fidelity (audit 4.5): dashed custom-station CTA', () {
|
||||
testWidgets(
|
||||
'padding is 14 all around, the icon is a plain add glyph at 20px, '
|
||||
'and the label is 13.5px/w800 (t4 line 235)',
|
||||
(tester) async {
|
||||
final estado = await crearEstadoVacio();
|
||||
addTearDown(estado.dispose);
|
||||
await tester.pumpWidget(buildScreen(estado));
|
||||
await pumpStable(tester);
|
||||
|
||||
final padding = tester.widget<Padding>(
|
||||
find.byKey(const Key('custom-station-cta-padding')),
|
||||
);
|
||||
expect(padding.padding, const EdgeInsets.all(14));
|
||||
|
||||
expect(find.byIcon(Icons.add_circle_outline_rounded), findsNothing);
|
||||
final icon = tester.widget<Icon>(find.byIcon(Icons.add_rounded));
|
||||
expect(icon.size, 20);
|
||||
|
||||
final l10n = lookupAppLocalizations(const Locale('en'));
|
||||
final texto = tester.widget<Text>(find.text(l10n.customStationsAddCta));
|
||||
expect(texto.style?.fontSize, 13.5);
|
||||
expect(texto.style?.fontWeight, FontWeight.w800);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
// ── EstadoRadio: new state surface (state-layer, not widget-layer) ───────
|
||||
|
||||
test('listaFavoritosManual returns favorites in stored order, NOT re-sorted '
|
||||
@@ -328,7 +357,12 @@ void main() {
|
||||
await tester.pumpWidget(buildScreen(estado));
|
||||
await pumpStable(tester);
|
||||
|
||||
await tester.tap(find.text('Manage lists'));
|
||||
// Audit 4.1 (t4:216): the manage-groups action moved into the
|
||||
// header as a create_new_folder icon button, replacing the old
|
||||
// "Manage lists" ActionChip in the chip strip.
|
||||
await tester.tap(
|
||||
find.byKey(const ValueKey('favorites-manage-groups-action')),
|
||||
);
|
||||
await pumpStable(tester);
|
||||
|
||||
// "Group Management Reachable from Favoritos": the SAME screen
|
||||
@@ -356,7 +390,10 @@ void main() {
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byType(PantallaAjustesGruposFavoritos), findsNothing);
|
||||
expect(find.text('Manage lists'), findsOneWidget);
|
||||
expect(
|
||||
find.byKey(const ValueKey('favorites-manage-groups-action')),
|
||||
findsOneWidget,
|
||||
);
|
||||
|
||||
await estado.crearGrupoFavoritos('Road trip');
|
||||
await pumpStable(tester);
|
||||
@@ -381,6 +418,116 @@ void main() {
|
||||
|
||||
expect(find.widgetWithText(TextFormField, 'Name *'), findsOneWidget);
|
||||
});
|
||||
|
||||
// Item 23 / audit 4.3 (t4:226-232): flat, background-less rows with a
|
||||
// square thumbnail and a circular play affordance, replacing the full
|
||||
// glass TarjetaEmisora card + two stacked filledTonal buttons.
|
||||
group('Item 23 -- flat rows (audit 4.3)', () {
|
||||
testWidgets(
|
||||
'each row is a flat FilaEmisoraPlana, not the full glass card',
|
||||
(tester) async {
|
||||
setLargeSurface(tester);
|
||||
_suppressListTileInkAssertion();
|
||||
final estado = await crearEstadoConFavoritos();
|
||||
addTearDown(estado.dispose);
|
||||
|
||||
await tester.pumpWidget(buildScreen(estado));
|
||||
await pumpStable(tester);
|
||||
|
||||
expect(find.byType(FilaEmisoraPlana), findsNWidgets(3));
|
||||
expect(
|
||||
find.byType(TarjetaEmisora),
|
||||
findsNothing,
|
||||
reason: 'audit 4.3 replaces the full glass card with a flat row',
|
||||
);
|
||||
expect(
|
||||
find.byIcon(Icons.drag_indicator_rounded),
|
||||
findsNWidgets(3),
|
||||
reason: 't4:227 drag_indicator, not drag_handle',
|
||||
);
|
||||
expect(find.byIcon(Icons.drag_handle_rounded), findsNothing);
|
||||
expect(
|
||||
find.byType(BotonReproducirCircular),
|
||||
findsNWidgets(3),
|
||||
reason: 't4:230 a circular play affordance per row',
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets(
|
||||
'the overflow menu offers exactly "Move to list" and "Remove from '
|
||||
'favorites"',
|
||||
(tester) async {
|
||||
setLargeSurface(tester);
|
||||
_suppressListTileInkAssertion();
|
||||
final estado = await crearEstadoConFavoritos();
|
||||
addTearDown(estado.dispose);
|
||||
|
||||
await tester.pumpWidget(buildScreen(estado));
|
||||
await pumpStable(tester);
|
||||
|
||||
await tester.tap(find.byIcon(Icons.more_vert_rounded).first);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Move to list'), findsOneWidget);
|
||||
expect(find.text('Remove from favorites'), findsOneWidget);
|
||||
expect(find.byType(PopupMenuItem<String>), findsNWidgets(2));
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets(
|
||||
'"Remove from favorites" still calls the same removal path as before',
|
||||
(tester) async {
|
||||
setLargeSurface(tester);
|
||||
_suppressListTileInkAssertion();
|
||||
final estado = await crearEstadoConFavoritos();
|
||||
addTearDown(estado.dispose);
|
||||
|
||||
await tester.pumpWidget(buildScreen(estado));
|
||||
await pumpStable(tester);
|
||||
|
||||
// Invoke the callback directly rather than opening the overlay and
|
||||
// tapping its rendered item by screen position: leaving that modal
|
||||
// route open while the tap it triggers removes and unmounts the
|
||||
// very row that anchors it is a real hang hazard, confirmed while
|
||||
// developing this test (multi-minute stall, same class of risk as
|
||||
// the project's other documented `pumpAndSettle` traps).
|
||||
final boton = tester.widget<PopupMenuButton<String>>(
|
||||
find.byType(PopupMenuButton<String>).first,
|
||||
);
|
||||
boton.onSelected!('remove');
|
||||
await pumpStable(tester);
|
||||
|
||||
expect(find.text('Station A'), findsNothing);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
group('visual fidelity (audit 4.2)', () {
|
||||
testWidgets('the active group chip is solid brand teal with dark text; '
|
||||
'inactive chips use listSurface (t4:219-221)', (tester) async {
|
||||
setLargeSurface(tester);
|
||||
_suppressListTileInkAssertion();
|
||||
final estado = await crearEstadoConFavoritos();
|
||||
addTearDown(estado.dispose);
|
||||
|
||||
await tester.pumpWidget(buildScreen(estado));
|
||||
await pumpStable(tester);
|
||||
|
||||
final activo = tester.widget<ChoiceChip>(
|
||||
find.widgetWithText(ChoiceChip, 'All · 3'),
|
||||
);
|
||||
expect(activo.selected, isTrue);
|
||||
expect(activo.selectedColor, const Color(0xFF21D4D9));
|
||||
expect(activo.labelStyle?.color, const Color(0xFF062126));
|
||||
|
||||
final inactivo = tester.widget<ChoiceChip>(
|
||||
find.widgetWithText(ChoiceChip, 'Rock · 2'),
|
||||
);
|
||||
expect(inactivo.selected, isFalse);
|
||||
expect(inactivo.backgroundColor, const Color(0xFF102532));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
void setLargeSurface(WidgetTester tester) {
|
||||
|
||||
@@ -8,6 +8,7 @@ import 'package:pluriwave/l10n/gen/app_localizations.dart';
|
||||
import 'package:pluriwave/modelos/archivo_grabacion.dart';
|
||||
import 'package:pluriwave/pantallas/pantalla_grabaciones.dart';
|
||||
import 'package:pluriwave/servicios/servicio_grabacion_radio.dart';
|
||||
import 'package:pluriwave/widgets/pluri_glass_surface.dart';
|
||||
import 'package:pluriwave/widgets/pluri_push_scaffold.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
@@ -137,7 +138,9 @@ void main() {
|
||||
);
|
||||
await pumpStable(tester);
|
||||
|
||||
await tester.tap(find.byIcon(Icons.settings_outlined));
|
||||
// Audit 12.1 (t4:610): the header action icon is `folder_open`, not
|
||||
// a generic gear.
|
||||
await tester.tap(find.byIcon(Icons.folder_open_rounded));
|
||||
await pumpStable(tester);
|
||||
|
||||
expect(find.byType(PluriPushScaffold), findsNWidgets(2));
|
||||
@@ -170,6 +173,49 @@ void main() {
|
||||
expect(find.text('84 MB of 200 MB used'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets(
|
||||
'visual fidelity (audit 12.2/12.3): the storage card shows the bold '
|
||||
'"used of total" headline ABOVE the bar, then a folder-path + purge '
|
||||
'caption below it (t4 line 613)',
|
||||
(tester) async {
|
||||
final estado = EstadoGrabacion(
|
||||
servicio: _FakeServicioGrabacionConArchivos([
|
||||
fijaA,
|
||||
fijaB,
|
||||
fijaC,
|
||||
], maxBytesFijo: 200 * 1024 * 1024),
|
||||
);
|
||||
addTearDown(estado.dispose);
|
||||
|
||||
await tester.pumpWidget(
|
||||
buildScreen(
|
||||
estado: estado,
|
||||
reproductor: _ReproductorGrabacionesFake(const {}),
|
||||
),
|
||||
);
|
||||
await pumpStable(tester);
|
||||
|
||||
final headline = tester.widget<Text>(find.text('84 MB of 200 MB used'));
|
||||
expect(headline.style?.fontWeight, FontWeight.w800);
|
||||
expect(
|
||||
find.text('Music/PluriWave · purges oldest at limit'),
|
||||
findsOneWidget,
|
||||
);
|
||||
|
||||
final headlineY = tester.getTopLeft(find.text('84 MB of 200 MB used')).dy;
|
||||
final barY = tester.getTopLeft(find.byType(LinearProgressIndicator)).dy;
|
||||
final captionY =
|
||||
tester
|
||||
.getTopLeft(find.text('Music/PluriWave · purges oldest at limit'))
|
||||
.dy;
|
||||
expect(
|
||||
headlineY < barY && barY < captionY,
|
||||
isTrue,
|
||||
reason: 'headline, then bar, then caption -- top to bottom',
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets('15.2-A: 3 recording fixtures render as 3 rows', (tester) async {
|
||||
final estado = EstadoGrabacion(
|
||||
servicio: _FakeServicioGrabacionConArchivos([
|
||||
@@ -194,6 +240,51 @@ void main() {
|
||||
expect(find.byIcon(Icons.play_circle_fill_rounded), findsNWidgets(3));
|
||||
});
|
||||
|
||||
// Item 23 / audit 12.4 (t4:616-619): flat, background-less rows with a
|
||||
// 44x44/radius-12 thumbnail placeholder -- replacing the PluriGlassSurface
|
||||
// + ListTile card, which had no artwork slot at all.
|
||||
testWidgets(
|
||||
'rows are flat -- no ListTile, no per-row PluriGlassSurface -- with a '
|
||||
'44x44/radius-12 thumbnail placeholder',
|
||||
(tester) async {
|
||||
final estado = EstadoGrabacion(
|
||||
servicio: _FakeServicioGrabacionConArchivos([
|
||||
fijaA,
|
||||
fijaB,
|
||||
], maxBytesFijo: 200 * 1024 * 1024),
|
||||
);
|
||||
addTearDown(estado.dispose);
|
||||
|
||||
await tester.pumpWidget(
|
||||
buildScreen(
|
||||
estado: estado,
|
||||
reproductor: _ReproductorGrabacionesFake(const {}),
|
||||
),
|
||||
);
|
||||
await pumpStable(tester);
|
||||
|
||||
expect(
|
||||
find.byType(ListTile),
|
||||
findsNothing,
|
||||
reason: 'audit 12.4 replaces the ListTile row with a flat Row',
|
||||
);
|
||||
expect(
|
||||
find.byType(PluriGlassSurface),
|
||||
findsOneWidget,
|
||||
reason: 'only the storage card keeps a surface -- rows do not',
|
||||
);
|
||||
|
||||
final miniaturas = find.byKey(const ValueKey('fila-grabacion-arte'));
|
||||
expect(miniaturas, findsNWidgets(2));
|
||||
expect(tester.getSize(miniaturas.first), const Size(44, 44));
|
||||
final clip = tester.widget<ClipRRect>(miniaturas.first);
|
||||
expect(
|
||||
(clip.borderRadius as BorderRadius).topLeft,
|
||||
const Radius.circular(12),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets('15.2-B: empty folder renders an empty state, not an error', (
|
||||
tester,
|
||||
) async {
|
||||
|
||||
@@ -10,6 +10,7 @@ import 'package:pluriwave/estado/estado_radio.dart';
|
||||
import 'package:pluriwave/l10n/gen/app_localizations.dart';
|
||||
import 'package:pluriwave/modelos/emisora.dart';
|
||||
import 'package:pluriwave/pantallas/pantalla_inicio.dart';
|
||||
import 'package:pluriwave/widgets/pluri_root_header.dart';
|
||||
import 'package:pluriwave/widgets/visualizador_audio.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
@@ -334,6 +335,369 @@ void main() {
|
||||
expect(find.byKey(const ValueKey('escuchar-hero-meta')), findsNothing);
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets('visual fidelity (audit 1.11): a count badge sits next to "Tus '
|
||||
'emisoras", showing the TOTAL favorite count, not the 8-capped grid '
|
||||
'size', (tester) async {
|
||||
_setLargeSurfaceSize(tester);
|
||||
final favoritos = FakeServicioFavoritos();
|
||||
final estado = EstadoRadio(
|
||||
audio: FakeServicioAudio(),
|
||||
favoritos: favoritos,
|
||||
radio: FakeServicioRadio(),
|
||||
servicioEcualizador: FakeServicioEcualizador(),
|
||||
servicioGrabacion: FakeServicioGrabacionRadio(),
|
||||
resolverArchivoCustom: _archivoCustomVacio,
|
||||
iniciarAutomaticamente: false,
|
||||
);
|
||||
addTearDown(estado.dispose);
|
||||
await tester.runAsync(estado.inicializar);
|
||||
for (var i = 0; i < 11; i++) {
|
||||
await favoritos.agregar(
|
||||
emisoraDemo(uuid: 'badge-$i', nombre: 'Badge Estacion $i'),
|
||||
);
|
||||
}
|
||||
await estado.cargarFavoritos();
|
||||
|
||||
await tester.pumpWidget(
|
||||
_conProviders(estado, _testApp(const PantallaInicio())),
|
||||
);
|
||||
await _pumpStableFrame(tester);
|
||||
|
||||
expect(
|
||||
find.text('11'),
|
||||
findsOneWidget,
|
||||
reason:
|
||||
'prototype t4 line 80: the badge reflects how many favorites '
|
||||
'exist, even though the grid below caps display at 8',
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets(
|
||||
'visual fidelity (audit 1.12): "Ver todas" renders as plain brand-teal '
|
||||
'text, not a Material TextButton',
|
||||
(tester) async {
|
||||
_setLargeSurfaceSize(tester);
|
||||
final favoritos = FakeServicioFavoritos();
|
||||
final estado = EstadoRadio(
|
||||
audio: FakeServicioAudio(),
|
||||
favoritos: favoritos,
|
||||
radio: FakeServicioRadio(),
|
||||
servicioEcualizador: FakeServicioEcualizador(),
|
||||
servicioGrabacion: FakeServicioGrabacionRadio(),
|
||||
resolverArchivoCustom: _archivoCustomVacio,
|
||||
iniciarAutomaticamente: false,
|
||||
);
|
||||
addTearDown(estado.dispose);
|
||||
await tester.runAsync(estado.inicializar);
|
||||
await favoritos.agregar(emisoraDemo(uuid: 'f1', nombre: 'Favorita Uno'));
|
||||
await estado.cargarFavoritos();
|
||||
|
||||
await tester.pumpWidget(
|
||||
_conProviders(estado, _testApp(const PantallaInicio())),
|
||||
);
|
||||
await _pumpStableFrame(tester);
|
||||
|
||||
expect(find.byType(TextButton), findsNothing);
|
||||
final texto = tester.widget<Text>(find.text('Ver todas'));
|
||||
expect(texto.style?.fontSize, 12);
|
||||
expect(texto.style?.fontWeight, FontWeight.w800);
|
||||
expect(texto.style?.color, const Color(0xFF21D4D9));
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets(
|
||||
'S7 visual fidelity: "Tus emisoras" uses the in-page section heading '
|
||||
'style (15/w800/ls-.2), not titleMedium(16)/w900',
|
||||
(tester) async {
|
||||
_setLargeSurfaceSize(tester);
|
||||
final favoritos = FakeServicioFavoritos();
|
||||
final estado = EstadoRadio(
|
||||
audio: FakeServicioAudio(),
|
||||
favoritos: favoritos,
|
||||
radio: FakeServicioRadio(),
|
||||
servicioEcualizador: FakeServicioEcualizador(),
|
||||
servicioGrabacion: FakeServicioGrabacionRadio(),
|
||||
resolverArchivoCustom: _archivoCustomVacio,
|
||||
iniciarAutomaticamente: false,
|
||||
);
|
||||
addTearDown(estado.dispose);
|
||||
await tester.runAsync(estado.inicializar);
|
||||
await favoritos.agregar(emisoraDemo(uuid: 'f1', nombre: 'Favorita Uno'));
|
||||
await estado.cargarFavoritos();
|
||||
|
||||
await tester.pumpWidget(
|
||||
_conProviders(estado, _testApp(const PantallaInicio())),
|
||||
);
|
||||
await _pumpStableFrame(tester);
|
||||
|
||||
final texto = tester.widget<Text>(find.text('Tus emisoras'));
|
||||
expect(texto.style?.fontSize, 15, reason: 'prototype t4 line 80');
|
||||
expect(texto.style?.fontWeight, FontWeight.w800);
|
||||
expect(texto.style?.letterSpacing, -0.2);
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets(
|
||||
'visual fidelity (audit 1.1): a full-bleed art/gradient/noise wash '
|
||||
'sits behind the whole screen, keyed so it can be found',
|
||||
(tester) async {
|
||||
_setLargeSurfaceSize(tester);
|
||||
final favoritos = FakeServicioFavoritos();
|
||||
final estado = EstadoRadio(
|
||||
audio: FakeServicioAudio(),
|
||||
favoritos: favoritos,
|
||||
radio: FakeServicioRadio(),
|
||||
servicioEcualizador: FakeServicioEcualizador(),
|
||||
servicioGrabacion: FakeServicioGrabacionRadio(),
|
||||
resolverArchivoCustom: _archivoCustomVacio,
|
||||
iniciarAutomaticamente: false,
|
||||
);
|
||||
addTearDown(estado.dispose);
|
||||
await tester.runAsync(estado.inicializar);
|
||||
final sonando = emisoraDemo(uuid: 'f1', nombre: 'Favorita Uno');
|
||||
await favoritos.agregar(sonando);
|
||||
await estado.cargarFavoritos();
|
||||
await tester.runAsync(() => estado.reproducir(sonando));
|
||||
|
||||
await tester.pumpWidget(
|
||||
_conProviders(estado, _testApp(const PantallaInicio())),
|
||||
);
|
||||
await _pumpBounded(tester);
|
||||
|
||||
expect(
|
||||
find.byKey(const ValueKey('escuchar-background-art')),
|
||||
findsOneWidget,
|
||||
reason:
|
||||
'prototype t4 lines 49-51: full-bleed art at .5 opacity + a '
|
||||
'gradient + a noise texture',
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets(
|
||||
'visual fidelity (audit 1.1): the background wash still renders with '
|
||||
'nothing playing (gradient/noise only, no station art to show)',
|
||||
(tester) async {
|
||||
_setLargeSurfaceSize(tester);
|
||||
final estado = EstadoRadio(
|
||||
audio: FakeServicioAudio(),
|
||||
favoritos: FakeServicioFavoritos(),
|
||||
radio: FakeServicioRadio(),
|
||||
servicioEcualizador: FakeServicioEcualizador(),
|
||||
servicioGrabacion: FakeServicioGrabacionRadio(),
|
||||
resolverArchivoCustom: _archivoCustomVacio,
|
||||
iniciarAutomaticamente: false,
|
||||
);
|
||||
addTearDown(estado.dispose);
|
||||
await tester.runAsync(estado.inicializar);
|
||||
|
||||
await tester.pumpWidget(
|
||||
_conProviders(estado, _testApp(const PantallaInicio())),
|
||||
);
|
||||
await _pumpStableFrame(tester);
|
||||
|
||||
expect(
|
||||
find.byKey(const ValueKey('escuchar-background-art')),
|
||||
findsOneWidget,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets('visual fidelity (audit 1.2): Escuchar has its own 52px "Now '
|
||||
'listening" eyebrow header, not the shared 56px PluriRootHeader', (
|
||||
tester,
|
||||
) async {
|
||||
_setLargeSurfaceSize(tester);
|
||||
final estado = EstadoRadio(
|
||||
audio: FakeServicioAudio(),
|
||||
favoritos: FakeServicioFavoritos(),
|
||||
radio: FakeServicioRadio(),
|
||||
servicioEcualizador: FakeServicioEcualizador(),
|
||||
servicioGrabacion: FakeServicioGrabacionRadio(),
|
||||
resolverArchivoCustom: _archivoCustomVacio,
|
||||
iniciarAutomaticamente: false,
|
||||
);
|
||||
addTearDown(estado.dispose);
|
||||
await tester.runAsync(estado.inicializar);
|
||||
|
||||
await tester.pumpWidget(
|
||||
_conProviders(estado, _testApp(const PantallaInicio())),
|
||||
);
|
||||
await _pumpStableFrame(tester);
|
||||
|
||||
expect(find.byType(PluriRootHeader), findsNothing);
|
||||
expect(
|
||||
find.text('Escuchando ahora'),
|
||||
findsOneWidget,
|
||||
reason: 'prototype t4 line 53: "ESCUCHANDO AHORA"',
|
||||
);
|
||||
expect(find.byIcon(Icons.bedtime_outlined), findsOneWidget);
|
||||
|
||||
final cabeceras = tester
|
||||
.widgetList<SizedBox>(find.byType(SizedBox))
|
||||
.where((s) => s.height == 52);
|
||||
expect(cabeceras, isNotEmpty, reason: 'prototype t4 line 53: height:52px');
|
||||
});
|
||||
|
||||
testWidgets(
|
||||
"visual fidelity (audit 1.2): the header's bedtime icon still opens "
|
||||
'the shared sleep-timer sheet',
|
||||
(tester) async {
|
||||
_setLargeSurfaceSize(tester);
|
||||
final estado = EstadoRadio(
|
||||
audio: FakeServicioAudio(),
|
||||
favoritos: FakeServicioFavoritos(),
|
||||
radio: FakeServicioRadio(),
|
||||
servicioEcualizador: FakeServicioEcualizador(),
|
||||
servicioGrabacion: FakeServicioGrabacionRadio(),
|
||||
resolverArchivoCustom: _archivoCustomVacio,
|
||||
iniciarAutomaticamente: false,
|
||||
);
|
||||
addTearDown(estado.dispose);
|
||||
await tester.runAsync(estado.inicializar);
|
||||
|
||||
await tester.pumpWidget(
|
||||
_conProviders(estado, _testApp(const PantallaInicio())),
|
||||
);
|
||||
await _pumpStableFrame(tester);
|
||||
|
||||
await tester.tap(find.byIcon(Icons.bedtime_outlined));
|
||||
await _pumpStableFrame(tester);
|
||||
|
||||
expect(find.byType(ActionChip), findsWidgets);
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets('visual fidelity (audit 1.5): the station name is 24/w800/ls-.5/'
|
||||
'height 1.12, not titleLarge(22) with no letter-spacing/height', (
|
||||
tester,
|
||||
) async {
|
||||
_setLargeSurfaceSize(tester);
|
||||
final favoritos = FakeServicioFavoritos();
|
||||
final estado = EstadoRadio(
|
||||
audio: FakeServicioAudio(),
|
||||
favoritos: favoritos,
|
||||
radio: FakeServicioRadio(),
|
||||
servicioEcualizador: FakeServicioEcualizador(),
|
||||
servicioGrabacion: FakeServicioGrabacionRadio(),
|
||||
resolverArchivoCustom: _archivoCustomVacio,
|
||||
iniciarAutomaticamente: false,
|
||||
);
|
||||
addTearDown(estado.dispose);
|
||||
await tester.runAsync(estado.inicializar);
|
||||
// Deliberately NOT added to favoritos: the "Tus emisoras" grid also
|
||||
// renders a station's bare name, and this assertion targets the
|
||||
// hero's OWN station-name Text specifically -- a favorited station
|
||||
// with the same name would render twice and make `find.text` throw.
|
||||
final sonando = emisoraDemo(uuid: 'f1', nombre: 'Radio Horizonte');
|
||||
await tester.runAsync(() => estado.reproducir(sonando));
|
||||
|
||||
await tester.pumpWidget(
|
||||
_conProviders(estado, _testApp(const PantallaInicio())),
|
||||
);
|
||||
await _pumpBounded(tester);
|
||||
|
||||
final texto = tester.widget<Text>(find.text('Radio Horizonte'));
|
||||
expect(texto.style?.fontSize, 24, reason: 'prototype t4 line 60');
|
||||
expect(texto.style?.fontWeight, FontWeight.w800);
|
||||
expect(texto.style?.letterSpacing, -0.5);
|
||||
expect(texto.style?.height, 1.12);
|
||||
});
|
||||
|
||||
testWidgets(
|
||||
'visual fidelity (audit 1.8): the hero play button is 72x72 with a '
|
||||
'38px icon, not 56x56 with a 28px icon',
|
||||
(tester) async {
|
||||
_setLargeSurfaceSize(tester);
|
||||
final favoritos = FakeServicioFavoritos();
|
||||
final estado = EstadoRadio(
|
||||
audio: FakeServicioAudio(),
|
||||
favoritos: favoritos,
|
||||
radio: FakeServicioRadio(),
|
||||
servicioEcualizador: FakeServicioEcualizador(),
|
||||
servicioGrabacion: FakeServicioGrabacionRadio(),
|
||||
resolverArchivoCustom: _archivoCustomVacio,
|
||||
iniciarAutomaticamente: false,
|
||||
);
|
||||
addTearDown(estado.dispose);
|
||||
await tester.runAsync(estado.inicializar);
|
||||
final sonando = emisoraDemo(uuid: 'f1', nombre: 'Favorita Uno');
|
||||
await favoritos.agregar(sonando);
|
||||
await estado.cargarFavoritos();
|
||||
await tester.runAsync(() => estado.reproducir(sonando));
|
||||
|
||||
await tester.pumpWidget(
|
||||
_conProviders(estado, _testApp(const PantallaInicio())),
|
||||
);
|
||||
await _pumpBounded(tester);
|
||||
|
||||
final botones = tester
|
||||
.widgetList<SizedBox>(find.byType(SizedBox))
|
||||
.where((s) => s.width == 72 && s.height == 72);
|
||||
expect(botones, isNotEmpty, reason: 'prototype t4 line 73');
|
||||
|
||||
final iconos = tester
|
||||
.widgetList<Icon>(find.byType(Icon))
|
||||
.where(
|
||||
(i) =>
|
||||
i.icon == Icons.play_arrow_rounded ||
|
||||
i.icon == Icons.pause_rounded,
|
||||
);
|
||||
expect(iconos, isNotEmpty);
|
||||
expect(iconos.first.size, 38);
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets(
|
||||
'visual fidelity (audit 1.9): the EQ/record/quality pill is centred, '
|
||||
'pill-shaped and uses expand_less at 17px -- not a left-aligned '
|
||||
'OutlinedButton with tune_rounded',
|
||||
(tester) async {
|
||||
_setLargeSurfaceSize(tester);
|
||||
final favoritos = FakeServicioFavoritos();
|
||||
final estado = EstadoRadio(
|
||||
audio: FakeServicioAudio(),
|
||||
favoritos: favoritos,
|
||||
radio: FakeServicioRadio(),
|
||||
servicioEcualizador: FakeServicioEcualizador(),
|
||||
servicioGrabacion: FakeServicioGrabacionRadio(),
|
||||
resolverArchivoCustom: _archivoCustomVacio,
|
||||
iniciarAutomaticamente: false,
|
||||
);
|
||||
addTearDown(estado.dispose);
|
||||
await tester.runAsync(estado.inicializar);
|
||||
final sonando = emisoraDemo(uuid: 'f1', nombre: 'Favorita Uno');
|
||||
await favoritos.agregar(sonando);
|
||||
await estado.cargarFavoritos();
|
||||
await tester.runAsync(() => estado.reproducir(sonando));
|
||||
|
||||
await tester.pumpWidget(
|
||||
_conProviders(estado, _testApp(const PantallaInicio())),
|
||||
);
|
||||
await _pumpBounded(tester);
|
||||
|
||||
expect(find.byType(OutlinedButton), findsNothing);
|
||||
expect(
|
||||
find.byIcon(Icons.expand_less_rounded),
|
||||
findsOneWidget,
|
||||
reason: 'prototype t4 line 78: expand_less at 17px',
|
||||
);
|
||||
final icono = tester.widget<Icon>(find.byIcon(Icons.expand_less_rounded));
|
||||
expect(icono.size, 17);
|
||||
|
||||
final pildoras = tester
|
||||
.widgetList<DecoratedBox>(find.byType(DecoratedBox))
|
||||
.map((d) => d.decoration)
|
||||
.whereType<BoxDecoration>()
|
||||
.where((d) => d.borderRadius == BorderRadius.circular(999));
|
||||
expect(
|
||||
pildoras,
|
||||
isNotEmpty,
|
||||
reason: 'prototype t4 line 78: border-radius:999px pill',
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Mirrors the app.dart wiring: EstadoRadio owns the domain notifiers and
|
||||
|
||||
@@ -4,6 +4,7 @@ import 'package:pluriwave/estado/estado_busqueda.dart';
|
||||
import 'package:pluriwave/l10n/gen/app_localizations.dart';
|
||||
import 'package:pluriwave/modelos/pais_radio.dart';
|
||||
import 'package:pluriwave/pantallas/pantalla_paises.dart';
|
||||
import 'package:pluriwave/widgets/pluri_glass_surface.dart';
|
||||
import 'package:pluriwave/widgets/pluri_push_scaffold.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
@@ -62,13 +63,18 @@ void main() {
|
||||
tester.element(find.byType(PantallaPaises)),
|
||||
);
|
||||
expect(find.text(l10n.countriesYourLanguagesTitle), findsOneWidget);
|
||||
expect(find.text(l10n.countriesAllTitle), findsOneWidget);
|
||||
// Audit 5.4: "Todos" now carries the total count as part of the
|
||||
// same eyebrow string ("{title} · {count}"), not a bare title.
|
||||
expect(find.textContaining(l10n.countriesAllTitle), findsOneWidget);
|
||||
|
||||
// Full alphabetical list: all 3 fetched countries render.
|
||||
expect(find.text('Argentina'), findsOneWidget);
|
||||
expect(find.text('France'), findsOneWidget);
|
||||
// 'Spain' renders twice: "Tus idiomas" (locale es -> representative
|
||||
// country ES) AND the full list below.
|
||||
// 'France' and 'Spain' each render twice: once in "Tus idiomas" (FR
|
||||
// and ES both back a supported locale) and once in the full list
|
||||
// below. Item 24's row now shows the name as its OWN Text (not
|
||||
// concatenated with the count into one Chip label), so both are
|
||||
// independently findable in each section.
|
||||
expect(find.text('France'), findsWidgets);
|
||||
expect(find.text('Spain'), findsWidgets);
|
||||
|
||||
// Each entry's parsed `stationcount` renders via the existing
|
||||
@@ -77,10 +83,187 @@ void main() {
|
||||
// as a plain number here, never throwing a cast error.
|
||||
expect(find.text(l10n.stationsCount(482)), findsWidgets);
|
||||
expect(find.text(l10n.stationsCount(120)), findsOneWidget);
|
||||
expect(find.text(l10n.stationsCount(75)), findsOneWidget);
|
||||
// Also doubled, for the same reason as 'France' above.
|
||||
expect(find.text(l10n.stationsCount(75)), findsWidgets);
|
||||
},
|
||||
);
|
||||
|
||||
// Item 24 / audit 5.1-5.3, 5.5 (t4:256-269): tappable ISO rows, not
|
||||
// non-interactive chips/plain ListTiles.
|
||||
group('Item 24 -- tappable ISO rows', () {
|
||||
testWidgets('rows show the ISO code and no longer use Chip or ListTile', (
|
||||
tester,
|
||||
) async {
|
||||
final estado = EstadoBusqueda(
|
||||
radio: FakeServicioRadio(
|
||||
paises: const [
|
||||
PaisRadio(nombre: 'Spain', codigoIso: 'ES', numeroEmisoras: 482),
|
||||
PaisRadio(
|
||||
nombre: 'Argentina',
|
||||
codigoIso: 'AR',
|
||||
numeroEmisoras: 120,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
addTearDown(estado.dispose);
|
||||
|
||||
await tester.pumpWidget(buildScreen(estado));
|
||||
await pumpEstable(tester);
|
||||
|
||||
expect(
|
||||
find.byType(Chip),
|
||||
findsNothing,
|
||||
reason: 'audit 5.1 replaces the Wrap-of-Chip with tappable rows',
|
||||
);
|
||||
expect(
|
||||
find.byType(ListTile),
|
||||
findsNothing,
|
||||
reason:
|
||||
'audit 5.3 rows need an ISO-code column a plain ListTile '
|
||||
'has no slot for',
|
||||
);
|
||||
expect(find.text('AR'), findsOneWidget);
|
||||
// 'ES' renders in BOTH the "Tus idiomas" and "Todos" rows.
|
||||
expect(find.text('ES'), findsWidgets);
|
||||
expect(find.byIcon(Icons.chevron_right_rounded), findsWidgets);
|
||||
});
|
||||
|
||||
testWidgets('tapping a row invokes onPaisSeleccionado with that country', (
|
||||
tester,
|
||||
) async {
|
||||
final estado = EstadoBusqueda(
|
||||
radio: FakeServicioRadio(
|
||||
paises: const [
|
||||
PaisRadio(
|
||||
nombre: 'Argentina',
|
||||
codigoIso: 'AR',
|
||||
numeroEmisoras: 120,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
addTearDown(estado.dispose);
|
||||
PaisRadio? seleccionado;
|
||||
|
||||
await tester.pumpWidget(
|
||||
ChangeNotifierProvider<EstadoBusqueda>.value(
|
||||
value: estado,
|
||||
child: MaterialApp(
|
||||
locale: const Locale('es'),
|
||||
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||
supportedLocales: AppLocalizations.supportedLocales,
|
||||
home: PantallaPaises(
|
||||
onPaisSeleccionado: (pais) => seleccionado = pais,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await pumpEstable(tester);
|
||||
|
||||
await tester.tap(find.text('Argentina'));
|
||||
await pumpEstable(tester);
|
||||
|
||||
expect(seleccionado?.codigoIso, 'AR');
|
||||
});
|
||||
|
||||
testWidgets(
|
||||
'the first "Tus idiomas" row is highlighted: teal-tinted background, '
|
||||
'bold name',
|
||||
(tester) async {
|
||||
final estado = EstadoBusqueda(
|
||||
radio: FakeServicioRadio(
|
||||
paises: const [
|
||||
PaisRadio(nombre: 'Spain', codigoIso: 'ES', numeroEmisoras: 482),
|
||||
],
|
||||
),
|
||||
);
|
||||
addTearDown(estado.dispose);
|
||||
|
||||
await tester.pumpWidget(buildScreen(estado));
|
||||
await pumpEstable(tester);
|
||||
|
||||
final nombre = tester.widget<Text>(find.text('Spain').first);
|
||||
expect(nombre.style?.fontWeight, FontWeight.w800);
|
||||
|
||||
final decorado = tester.widget<DecoratedBox>(
|
||||
find
|
||||
.ancestor(
|
||||
of: find.text('Spain').first,
|
||||
matching: find.byType(DecoratedBox),
|
||||
)
|
||||
.first,
|
||||
);
|
||||
final decoration = decorado.decoration as BoxDecoration;
|
||||
expect(
|
||||
decoration.color,
|
||||
isNot(Colors.transparent),
|
||||
reason: 't4:256 the first row is teal-tinted, not transparent',
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
group('visual fidelity (audit 5.4/5.7)', () {
|
||||
testWidgets(
|
||||
'the section eyebrows sit OUTSIDE any card, styled as an eyebrow '
|
||||
'label, and "Todos" carries the total country count (t4:254/260)',
|
||||
(tester) async {
|
||||
final estado = EstadoBusqueda(
|
||||
radio: FakeServicioRadio(
|
||||
paises: const [
|
||||
PaisRadio(nombre: 'Spain', codigoIso: 'ES', numeroEmisoras: 482),
|
||||
PaisRadio(
|
||||
nombre: 'Argentina',
|
||||
codigoIso: 'AR',
|
||||
numeroEmisoras: 120,
|
||||
),
|
||||
PaisRadio(nombre: 'France', codigoIso: 'FR', numeroEmisoras: 75),
|
||||
],
|
||||
),
|
||||
);
|
||||
addTearDown(estado.dispose);
|
||||
|
||||
await tester.pumpWidget(buildScreen(estado));
|
||||
await pumpEstable(tester);
|
||||
|
||||
final l10n = AppLocalizations.of(
|
||||
tester.element(find.byType(PantallaPaises)),
|
||||
);
|
||||
|
||||
final eyebrow = tester.widget<Text>(
|
||||
find.text(l10n.countriesYourLanguagesTitle),
|
||||
);
|
||||
expect(eyebrow.style?.fontSize, 11);
|
||||
expect(eyebrow.style?.fontWeight, FontWeight.w800);
|
||||
|
||||
expect(
|
||||
find.ancestor(
|
||||
of: find.text(l10n.countriesYourLanguagesTitle),
|
||||
matching: find.byType(PluriGlassSurface),
|
||||
),
|
||||
findsNothing,
|
||||
reason: 'the eyebrow must not be a descendant of any card',
|
||||
);
|
||||
|
||||
expect(
|
||||
find.textContaining('${l10n.countriesAllTitle} · 3'),
|
||||
findsOneWidget,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets('the header carries a search action (t4:252)', (tester) async {
|
||||
final estado = EstadoBusqueda(radio: FakeServicioRadio(paises: const []));
|
||||
addTearDown(estado.dispose);
|
||||
|
||||
await tester.pumpWidget(buildScreen(estado));
|
||||
await pumpEstable(tester);
|
||||
|
||||
expect(find.byIcon(Icons.search_rounded), findsOneWidget);
|
||||
});
|
||||
});
|
||||
|
||||
testWidgets(
|
||||
'cargarPaises no se re-dispara si ya hay datos en caché al reconstruir',
|
||||
(tester) async {
|
||||
|
||||
@@ -9,7 +9,10 @@ import 'package:pluriwave/l10n/gen/app_localizations.dart';
|
||||
import 'package:pluriwave/modelos/emisora.dart';
|
||||
import 'package:pluriwave/modelos/preset_ecualizador.dart';
|
||||
import 'package:pluriwave/pantallas/pantalla_reproductor.dart';
|
||||
import 'package:pluriwave/tema/pluriwave_tokens.dart';
|
||||
import 'package:pluriwave/widgets/ecualizador_widget.dart';
|
||||
import 'package:pluriwave/widgets/pluri_glass_surface.dart';
|
||||
import 'package:pluriwave/widgets/visualizador_audio.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
@@ -548,4 +551,157 @@ void main() {
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
group('Visual fidelity (screens 1-3 closing pass)', () {
|
||||
testWidgets('audit 2.1: the hero art is a fixed 246x246, not width*0.62', (
|
||||
tester,
|
||||
) async {
|
||||
final estado = crearEstado();
|
||||
addTearDown(estado.dispose);
|
||||
|
||||
await montarPantalla(tester, estado);
|
||||
|
||||
final sizedBox = tester.widget<SizedBox>(
|
||||
find.byKey(const Key('player-hero-art')),
|
||||
);
|
||||
expect(sizedBox.width, 246, reason: 'prototype t4 line 114');
|
||||
expect(sizedBox.height, 246);
|
||||
});
|
||||
|
||||
testWidgets(
|
||||
'audit 2.2: a full-bleed backdrop sits behind the whole screen',
|
||||
(tester) async {
|
||||
const estacion = Emisora(
|
||||
uuid: 'demo-uuid',
|
||||
nombre: 'Radio Demo',
|
||||
url: 'https://stream.demo/radio',
|
||||
favicon: 'https://example.com/art.png',
|
||||
);
|
||||
final estado = crearEstado();
|
||||
addTearDown(estado.dispose);
|
||||
|
||||
await montarPantalla(tester, estado, estacion: estacion);
|
||||
|
||||
expect(
|
||||
find.byKey(const Key('player-background-art')),
|
||||
findsOneWidget,
|
||||
reason: 'prototype t4 lines 108-109: full-bleed blurred backdrop',
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets('audit 2.2: the backdrop still renders (gradient only) with no '
|
||||
'station art available', (tester) async {
|
||||
final estado = crearEstado();
|
||||
addTearDown(estado.dispose);
|
||||
|
||||
await montarPantalla(tester, estado);
|
||||
|
||||
expect(find.byKey(const Key('player-background-art')), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('audit 2.3: the title is 26/w800/ls-.5/height 1.15, not '
|
||||
'headlineSmall(24)/w700', (tester) async {
|
||||
final estado = crearEstado();
|
||||
addTearDown(estado.dispose);
|
||||
|
||||
await montarPantalla(tester, estado);
|
||||
|
||||
final texto = tester.widget<Text>(find.text(emisora.nombre));
|
||||
expect(texto.style?.fontSize, 26, reason: 'prototype t4 line 116');
|
||||
expect(texto.style?.fontWeight, FontWeight.w800);
|
||||
expect(texto.style?.letterSpacing, -0.5);
|
||||
expect(texto.style?.height, 1.15);
|
||||
});
|
||||
|
||||
testWidgets(
|
||||
'audit 2.8: only the EQ propio tile is accented -- tinted brand '
|
||||
'background/border, the other three share a neutral opaque surface',
|
||||
(tester) async {
|
||||
final estado = crearEstado();
|
||||
addTearDown(estado.dispose);
|
||||
|
||||
await montarPantalla(tester, estado);
|
||||
|
||||
final eqDecoration =
|
||||
tester
|
||||
.widget<Container>(
|
||||
find
|
||||
.descendant(
|
||||
of: find.byKey(const Key('player-tool-eq')),
|
||||
matching: find.byType(Container),
|
||||
)
|
||||
.first,
|
||||
)
|
||||
.decoration
|
||||
as BoxDecoration;
|
||||
expect(
|
||||
eqDecoration.color,
|
||||
PluriWaveTokens.brand.withValues(alpha: 0.16),
|
||||
reason: 'prototype t4 line 133',
|
||||
);
|
||||
|
||||
final recordDecoration =
|
||||
tester
|
||||
.widget<Container>(
|
||||
find
|
||||
.descendant(
|
||||
of: find.byKey(const Key('player-tool-record')),
|
||||
matching: find.byType(Container),
|
||||
)
|
||||
.first,
|
||||
)
|
||||
.decoration
|
||||
as BoxDecoration;
|
||||
expect(
|
||||
recordDecoration.color,
|
||||
isNot(PluriWaveTokens.brand.withValues(alpha: 0.16)),
|
||||
reason: 'prototype t4 line 134: no explicit accent colour',
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets(
|
||||
'audit 2.9: the transport row is not wrapped in a glass surface '
|
||||
'card',
|
||||
(tester) async {
|
||||
final estado = crearEstado();
|
||||
addTearDown(estado.dispose);
|
||||
|
||||
await montarPantalla(tester, estado);
|
||||
|
||||
expect(
|
||||
find.ancestor(
|
||||
of: find.byIcon(Icons.favorite_outline_rounded),
|
||||
matching: find.byType(PluriGlassSurface),
|
||||
),
|
||||
findsNothing,
|
||||
reason: 'prototype t4 lines 124-131: bare row, no glass card',
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets(
|
||||
'audit 2.10: the visualizer is not wrapped in its own glass card',
|
||||
(tester) async {
|
||||
final estado = crearEstado();
|
||||
addTearDown(estado.dispose);
|
||||
|
||||
await montarPantalla(tester, estado);
|
||||
|
||||
final visualizador = find.byKey(const Key('player-visualizer'));
|
||||
expect(tester.widget(visualizador), isA<VisualizadorAudio>());
|
||||
expect(
|
||||
find.ancestor(
|
||||
of: visualizador,
|
||||
matching: find.byType(PluriGlassSurface),
|
||||
),
|
||||
findsNothing,
|
||||
reason:
|
||||
'prototype t4 lines 120-122: bars sit on the bare '
|
||||
'background',
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -180,7 +180,8 @@ void main() {
|
||||
});
|
||||
|
||||
testWidgets(
|
||||
'los rangos pasados aparecen bajo el encabezado "Rangos pasados"',
|
||||
'los rangos pasados aparecen bajo el encabezado "Rangos pasados" tras '
|
||||
'expandir la fila (audit 9b.7: colapsada por defecto, t4:489)',
|
||||
(tester) async {
|
||||
final estado = await _crearEstado(
|
||||
vacaciones: [
|
||||
@@ -201,6 +202,10 @@ void main() {
|
||||
tester.element(find.byType(PantallaVacaciones)),
|
||||
);
|
||||
expect(find.text(l10n.vacationPastSectionTitle), findsOneWidget);
|
||||
|
||||
await tester.tap(find.text(l10n.vacationPastSectionTitle));
|
||||
await _pumpEstable(tester);
|
||||
|
||||
expect(find.text('Rango viejo'), findsOneWidget);
|
||||
},
|
||||
);
|
||||
@@ -343,4 +348,101 @@ void main() {
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
group('visual fidelity (audit 9b.1/9b.2/9b.6/9b.7)', () {
|
||||
testWidgets(
|
||||
'9b.1: a header "Add" action is reachable and opens the same form '
|
||||
'as the CTA (t4:446)',
|
||||
(tester) async {
|
||||
final estado = await _crearEstado();
|
||||
addTearDown(estado.dispose);
|
||||
|
||||
await tester.pumpWidget(_buildScreen(estado));
|
||||
await _pumpEstable(tester);
|
||||
|
||||
final l10n = AppLocalizations.of(
|
||||
tester.element(find.byType(PantallaVacaciones)),
|
||||
);
|
||||
final appBar = tester.widget<AppBar>(find.byType(AppBar));
|
||||
expect(appBar.actions, isNotNull);
|
||||
expect(appBar.actions, isNotEmpty);
|
||||
|
||||
await tester.tap(find.byKey(const ValueKey('vacation-add-header')));
|
||||
await _pumpEstable(tester);
|
||||
|
||||
expect(find.text(l10n.newVacationRangeTitle), findsOneWidget);
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets(
|
||||
'9b.2: the explanatory info banner is always visible (t4:448)',
|
||||
(tester) async {
|
||||
final estado = await _crearEstado();
|
||||
addTearDown(estado.dispose);
|
||||
|
||||
await tester.pumpWidget(_buildScreen(estado));
|
||||
await _pumpEstable(tester);
|
||||
|
||||
final l10n = AppLocalizations.of(
|
||||
tester.element(find.byType(PantallaVacaciones)),
|
||||
);
|
||||
expect(find.text(l10n.vacationExplainerBanner), findsOneWidget);
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets(
|
||||
'9b.6: the bottom CTA has a dashed border and a date_range icon '
|
||||
'(t4:487)',
|
||||
(tester) async {
|
||||
final estado = await _crearEstado();
|
||||
addTearDown(estado.dispose);
|
||||
|
||||
await tester.pumpWidget(_buildScreen(estado));
|
||||
await _pumpEstable(tester);
|
||||
|
||||
expect(find.byIcon(Icons.date_range_rounded), findsOneWidget);
|
||||
// Exactly ONE add_rounded on the whole screen -- the header's OWN
|
||||
// "Add" action (audit 9b.1). The bottom CTA no longer uses it.
|
||||
expect(find.byIcon(Icons.add_rounded), findsOneWidget);
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets(
|
||||
'9b.7: "Rangos pasados" is collapsed by default, showing a count, '
|
||||
'and expands on tap (t4:489)',
|
||||
(tester) async {
|
||||
final estado = await _crearEstado(
|
||||
vacaciones: [
|
||||
RangoVacaciones(
|
||||
id: 'pa1',
|
||||
nombre: 'Rango viejo',
|
||||
inicio: _hoyDia.subtract(const Duration(days: 30)),
|
||||
fin: _hoyDia.subtract(const Duration(days: 20)),
|
||||
),
|
||||
],
|
||||
);
|
||||
addTearDown(estado.dispose);
|
||||
|
||||
await tester.pumpWidget(_buildScreen(estado));
|
||||
await _pumpEstable(tester);
|
||||
|
||||
final l10n = AppLocalizations.of(
|
||||
tester.element(find.byType(PantallaVacaciones)),
|
||||
);
|
||||
expect(find.text(l10n.vacationPastSectionTitle), findsOneWidget);
|
||||
expect(find.text('1'), findsOneWidget);
|
||||
expect(
|
||||
find.text('Rango viejo'),
|
||||
findsNothing,
|
||||
reason:
|
||||
'collapsed by default -- matches the prototype count-only row',
|
||||
);
|
||||
|
||||
await tester.tap(find.text(l10n.vacationPastSectionTitle));
|
||||
await _pumpEstable(tester);
|
||||
|
||||
expect(find.text('Rango viejo'), findsOneWidget);
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -102,8 +102,19 @@ void main() {
|
||||
);
|
||||
|
||||
testWidgets(
|
||||
'Escuchar draws its own PluriRootHeader with the tab title, no AppBar',
|
||||
'Escuchar is the documented header exception: a 52px "now listening" '
|
||||
'eyebrow instead of a PluriRootHeader title row, and still no AppBar',
|
||||
(tester) async {
|
||||
// The prototype gives Escuchar a STRUCTURALLY different header from
|
||||
// every other root (t4 line 53 vs line 325): 52px rather than 56, an
|
||||
// eyebrow at 15px/w800/55% opacity rather than a 23px title, and
|
||||
// trailing actions rather than a CTA. This test originally asserted
|
||||
// `PluriRootHeader` on all five roots — an over-broad premise, since
|
||||
// Escuchar's header was never a title row in the design.
|
||||
//
|
||||
// What still holds unconditionally, and is what this guards: no
|
||||
// AppBar anywhere (systemic item S1), and the sleep-timer action that
|
||||
// used to live in the removed global chrome is still reachable here.
|
||||
_suppressListTileInkAssertion();
|
||||
setLargeSurface(tester);
|
||||
final estado = crearEstadoRadio();
|
||||
@@ -112,9 +123,18 @@ void main() {
|
||||
await tester.pumpWidget(testApp(estado, const PantallaInicio()));
|
||||
await pumpStable(tester);
|
||||
|
||||
expect(find.byType(PluriRootHeader), findsOneWidget);
|
||||
expect(titleInHeader('Listen'), findsOneWidget);
|
||||
expect(find.byType(AppBar), findsNothing);
|
||||
expect(
|
||||
find.byType(PluriRootHeader),
|
||||
findsNothing,
|
||||
reason: 'Escuchar uses its own eyebrow header, not the shared one',
|
||||
);
|
||||
expect(find.text('Now listening'), findsOneWidget);
|
||||
expect(
|
||||
find.byIcon(Icons.bedtime_outlined),
|
||||
findsOneWidget,
|
||||
reason: 'the sleep-timer action survived the global AppBar removal',
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -65,6 +65,20 @@ void main() {
|
||||
expect(type.eyebrowLabel.letterSpacing, 0.8);
|
||||
});
|
||||
|
||||
test(
|
||||
'S7 (visual fidelity): inPageSectionHeading is 15 / w800 / '
|
||||
'letter-spacing -0.2 -- the ADR-1 amendment adding a seventh style',
|
||||
() {
|
||||
expect(
|
||||
type.inPageSectionHeading.fontSize,
|
||||
15,
|
||||
reason: 'prototype t4 lines 80, 153, 164, 173',
|
||||
);
|
||||
expect(type.inPageSectionHeading.fontWeight, FontWeight.w800);
|
||||
expect(type.inPageSectionHeading.letterSpacing, -0.2);
|
||||
},
|
||||
);
|
||||
|
||||
test('S9 (Tier 1 visual fidelity): eyebrowLabel bakes in the prototype '
|
||||
"colour rgba(242,247,250,.42) — it used to carry no colour at all, "
|
||||
'so it rendered at full onSurface opacity', () {
|
||||
|
||||
@@ -136,4 +136,30 @@ void main() {
|
||||
'EQ bands with brand teal #21D4D9 (t4 line 585)',
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets(
|
||||
'visual fidelity (audit 11.5): the thumb is a 20x20 custom glow shape, '
|
||||
'not the Material default (t4 line 585)',
|
||||
(tester) async {
|
||||
await tester.pumpWidget(buildWidget());
|
||||
|
||||
final tema = SliderTheme.of(tester.element(find.byType(Slider).first));
|
||||
final size = tema.thumbShape?.getPreferredSize(true, false);
|
||||
|
||||
expect(
|
||||
size,
|
||||
const Size(20, 20),
|
||||
reason: 't4 line 585: a 20x20 thumb with a 14px brand-teal glow',
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets('visual fidelity (audit 11.7): the dB label is brand teal, not '
|
||||
'liveGreen (t4 line 584)', (tester) async {
|
||||
await tester.pumpWidget(buildWidget());
|
||||
|
||||
final label = tester.widget<Text>(find.text('0.0dB').first);
|
||||
expect(label.style?.color, PluriWaveTokens.brand.withValues(alpha: 0.9));
|
||||
expect(label.style?.color, isNot(PluriWaveTokens.dark.liveGreen));
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
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/tema/pluriwave_tokens.dart';
|
||||
import 'package:pluriwave/widgets/fila_emisora_plana.dart';
|
||||
import 'package:pluriwave/widgets/tarjeta_emisora.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../helpers/fakes.dart';
|
||||
import '../helpers/fakes_alarmas.dart';
|
||||
|
||||
EstadoRadio _estado() => EstadoRadio(
|
||||
audio: FakeServicioAudio(),
|
||||
favoritos: FakeServicioFavoritos(),
|
||||
radio: FakeServicioRadio(),
|
||||
servicioEcualizador: FakeServicioEcualizador(),
|
||||
servicioGrabacion: FakeServicioGrabacionRadioInactiva(),
|
||||
iniciarAutomaticamente: false,
|
||||
);
|
||||
|
||||
Widget _host(EstadoRadio estado, Widget child) {
|
||||
return ChangeNotifierProvider<EstadoRadio>.value(
|
||||
value: estado,
|
||||
child: MaterialApp(
|
||||
locale: const Locale('en'),
|
||||
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||
supportedLocales: AppLocalizations.supportedLocales,
|
||||
home: Scaffold(body: child),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Item 23 / audit 4.3 + 6.5 (t4:226-232, 302-306): the shared flat row
|
||||
/// backing Favoritos and search results.
|
||||
void main() {
|
||||
testWidgets(
|
||||
'renders a flat, background-less row -- no TarjetaEmisora underneath',
|
||||
(tester) async {
|
||||
final estado = _estado();
|
||||
addTearDown(estado.dispose);
|
||||
final emisora = emisoraDemo(uuid: 'a', nombre: 'Station A');
|
||||
|
||||
await tester.pumpWidget(
|
||||
_host(estado, FilaEmisoraPlana(emisora: emisora, meta: 'Rock - Spain')),
|
||||
);
|
||||
await tester.pump();
|
||||
|
||||
expect(find.text('Station A'), findsOneWidget);
|
||||
expect(find.text('Rock - Spain'), findsOneWidget);
|
||||
expect(
|
||||
find.byType(TarjetaEmisora),
|
||||
findsNothing,
|
||||
reason: 'a flat row does not reuse the full glass card',
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets('the thumbnail is 48x48 with a 12 radius (t4:226, t4:302)', (
|
||||
tester,
|
||||
) async {
|
||||
final estado = _estado();
|
||||
addTearDown(estado.dispose);
|
||||
final emisora = emisoraDemo(uuid: 'a', nombre: 'Station A');
|
||||
|
||||
await tester.pumpWidget(
|
||||
_host(estado, FilaEmisoraPlana(emisora: emisora, meta: '')),
|
||||
);
|
||||
await tester.pump();
|
||||
|
||||
final arte = find.byKey(const ValueKey('fila-emisora-plana-arte'));
|
||||
expect(tester.getSize(arte), const Size(48, 48));
|
||||
final clip = tester.widget<ClipRRect>(arte);
|
||||
expect(
|
||||
(clip.borderRadius as BorderRadius).topLeft,
|
||||
const Radius.circular(12),
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('omits the meta line entirely when empty (no stray gap)', (
|
||||
tester,
|
||||
) async {
|
||||
final estado = _estado();
|
||||
addTearDown(estado.dispose);
|
||||
final emisora = emisoraDemo(uuid: 'a', nombre: 'Station A');
|
||||
|
||||
await tester.pumpWidget(
|
||||
_host(estado, FilaEmisoraPlana(emisora: emisora, meta: '')),
|
||||
);
|
||||
await tester.pump();
|
||||
|
||||
expect(find.text('Station A'), findsOneWidget);
|
||||
// Only the title Text should exist in the info column -- no empty
|
||||
// second Text node.
|
||||
expect(find.text(''), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('BotonReproducirCircular is 44x44, brand-teal-tinted background, '
|
||||
'22px play_arrow icon (t4:230, t4:305)', (tester) async {
|
||||
var tapped = false;
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: Scaffold(
|
||||
body: BotonReproducirCircular(onPressed: () => tapped = true),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pump();
|
||||
|
||||
final boton = find.byKey(const ValueKey('boton-reproducir-circular'));
|
||||
final material = tester.widget<Material>(boton);
|
||||
expect(material.color, PluriWaveTokens.brand.withValues(alpha: 0.14));
|
||||
expect(material.shape, const CircleBorder());
|
||||
|
||||
final icon = tester.widget<Icon>(find.byIcon(Icons.play_arrow_rounded));
|
||||
expect(icon.size, 22);
|
||||
expect(icon.color, PluriWaveTokens.brand);
|
||||
|
||||
await tester.tap(find.byIcon(Icons.play_arrow_rounded));
|
||||
expect(tapped, isTrue);
|
||||
});
|
||||
|
||||
testWidgets(
|
||||
'BotonFavoritoEmisora toggles favourite status and shows a snackbar '
|
||||
'(t4:304)',
|
||||
(tester) async {
|
||||
final estado = _estado();
|
||||
addTearDown(estado.dispose);
|
||||
final emisora = emisoraDemo(uuid: 'a', nombre: 'Station A');
|
||||
|
||||
await tester.pumpWidget(
|
||||
_host(estado, BotonFavoritoEmisora(emisora: emisora)),
|
||||
);
|
||||
await tester.pump();
|
||||
|
||||
expect(find.byIcon(Icons.favorite_outline_rounded), findsOneWidget);
|
||||
|
||||
await tester.tap(find.byIcon(Icons.favorite_outline_rounded));
|
||||
await tester.pump();
|
||||
await tester.pump();
|
||||
|
||||
expect(find.byIcon(Icons.favorite_rounded), findsOneWidget);
|
||||
expect(await estado.esFavorito('a'), isTrue);
|
||||
},
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user