Compare commits
20
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fc866d7ec9 | ||
|
|
727e18737a | ||
|
|
c6ab295c54 | ||
|
|
e75f010b98 | ||
|
|
c7e1a212ca | ||
|
|
d1a911e587 | ||
|
|
4be2156e58 | ||
|
|
f24be19e4f | ||
|
|
7343071fca | ||
|
|
42d35a2541 | ||
|
|
f61b0b9163 | ||
|
|
36d7d5f692 | ||
|
|
dcb415b3f8 | ||
|
|
c7d137c82e | ||
|
|
dcd8488874 | ||
|
|
94b1e901d1 | ||
|
|
533896b9fa | ||
|
|
b5b9829faa | ||
|
|
e036f99a61 | ||
|
|
34388d364b |
@@ -354,6 +354,19 @@ class EstadoAlarmas extends ChangeNotifier {
|
||||
await guardarVacaciones(nuevos);
|
||||
}
|
||||
|
||||
/// Issue 1 (feedback-pruebas): replaces the range with the same [id] in
|
||||
/// place -- the counterpart `crearRangoVacaciones`/`eliminarRangoVacaciones`
|
||||
/// were missing before this fix, leaving no way to fix a mistake in an
|
||||
/// already-saved range (including the currently ACTIVE one, since a
|
||||
/// freshly created range starts active immediately).
|
||||
Future<void> editarRangoVacaciones(RangoVacaciones rango) async {
|
||||
final nuevos = [
|
||||
for (final actual in _vacaciones)
|
||||
if (actual.id == rango.id) rango else actual,
|
||||
];
|
||||
await guardarVacaciones(nuevos);
|
||||
}
|
||||
|
||||
// ── Vacation queries (design ADR-6, WU9) ──────────────────────────────
|
||||
// Four PURE queries: none writes, none reschedules, none touches the
|
||||
// native bridge. Read-only over `_alarmas`/`_vacaciones`. Every method
|
||||
|
||||
@@ -173,6 +173,10 @@ class EstadoRadio extends ChangeNotifier {
|
||||
static const _keyEmisoraPreferida = 'emisora_preferida_uuid_v1';
|
||||
static const _keyOrdenListas = 'orden_listas_emisoras_v1';
|
||||
static const _keyTimerSuenoPresets = 'timer_sueno_presets_segundos_v1';
|
||||
// Issue 4 (feedback-pruebas): last-played station, so the Escuchar hero
|
||||
// keeps showing "what I was listening to" (stopped, not playing) after a
|
||||
// full app restart instead of going empty.
|
||||
static const _keyUltimaEmisora = 'ultima_emisora_v1';
|
||||
static const _timerSuenoPresetsDefecto = <int>[
|
||||
180,
|
||||
300,
|
||||
@@ -300,6 +304,50 @@ class EstadoRadio extends ChangeNotifier {
|
||||
_cargarEmisorasCustom(),
|
||||
]);
|
||||
await _normalizarEmisoraPreferida();
|
||||
await _restaurarUltimaEmisora();
|
||||
}
|
||||
|
||||
/// Issue 4 (feedback-pruebas): restores the last-played station as a
|
||||
/// STOPPED `emisoraActual` on a cold start. Only fills the gap — if
|
||||
/// something is ALREADY selected (a real play already ran concurrently),
|
||||
/// this is a no-op. Never touches `audio`: no playback starts, no network
|
||||
/// request is made, `estadoStream`/`estaSonando` stay at their fresh
|
||||
/// "detenido" default, exactly like every other consumer of
|
||||
/// `emisoraActual` already expects (they gate "is it playing" on the
|
||||
/// separate playback-status stream, never on `emisoraActual != null`).
|
||||
Future<void> _restaurarUltimaEmisora() async {
|
||||
if (_emisoraSeleccionada != null || audio.emisoraActual != null) return;
|
||||
try {
|
||||
final prefs = await _resolverPrefs();
|
||||
final raw = prefs.getString(_keyUltimaEmisora);
|
||||
if (raw == null) return;
|
||||
final mapa = jsonDecode(raw) as Map<String, dynamic>;
|
||||
_emisoraSeleccionada = Emisora.fromMap(mapa);
|
||||
} catch (e) {
|
||||
registrarSaltoPersistencia(
|
||||
subsistema: 'ultima_emisora',
|
||||
detalle: 'restaurar',
|
||||
razon: e.toString(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Best-effort remembers [emisora] as the last used station (issue 4) so
|
||||
/// [_restaurarUltimaEmisora] can bring it back after a restart. Fire-and-
|
||||
/// forget, same treatment [reproducir] already gives other non-critical
|
||||
/// side effects (e.g. `radio.registrarClick`) — a failed write here must
|
||||
/// never block or fail actual playback.
|
||||
Future<void> _persistirUltimaEmisora(Emisora emisora) async {
|
||||
try {
|
||||
final prefs = await _resolverPrefs();
|
||||
await prefs.setString(_keyUltimaEmisora, jsonEncode(emisora.toMap()));
|
||||
} catch (e) {
|
||||
registrarSaltoPersistencia(
|
||||
subsistema: 'ultima_emisora',
|
||||
detalle: 'persistir ${emisora.uuid}',
|
||||
razon: e.toString(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Escucha el stream de estado del audio y gestiona errores de reproducción.
|
||||
@@ -321,6 +369,9 @@ class EstadoRadio extends ChangeNotifier {
|
||||
final actual = audio.emisoraActual;
|
||||
if (actual != null && actual.uuid != _emisoraSeleccionada?.uuid) {
|
||||
_emisoraSeleccionada = actual;
|
||||
// Issue 4: an Android-Auto-initiated selection is a real station
|
||||
// change too — remember it the same way `reproducir` does.
|
||||
unawaited(_persistirUltimaEmisora(actual));
|
||||
}
|
||||
notifyListeners();
|
||||
});
|
||||
@@ -508,6 +559,10 @@ class EstadoRadio extends ChangeNotifier {
|
||||
}
|
||||
_emisoraSeleccionada = emisora;
|
||||
notifyListeners();
|
||||
// Issue 4: remembers the station the user just picked so it survives a
|
||||
// restart — fire-and-forget, same treatment as `radio.registrarClick`
|
||||
// below (a persistence failure here must never block playback).
|
||||
unawaited(_persistirUltimaEmisora(emisora));
|
||||
try {
|
||||
await audio.reproducir(emisora);
|
||||
if (revision != _revisionReproduccion) return;
|
||||
|
||||
@@ -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": "الرنين أثناء الإجازات",
|
||||
@@ -464,6 +468,9 @@
|
||||
"deleteRangeTooltip": "حذف النطاق",
|
||||
"vacationsDefaultName": "إجازات",
|
||||
"newVacationRangeTitle": "نطاق إجازة جديد",
|
||||
"editVacationRangeTitle": "تعديل نطاق الإجازة",
|
||||
"vacationDeleteConfirmTitle": "هل تريد حذف نطاق الإجازة؟",
|
||||
"vacationDeleteConfirmMessage": "لا يمكن التراجع عن هذا الإجراء.",
|
||||
"startField": "البداية",
|
||||
"endField": "النهاية",
|
||||
"saveRangeAction": "حفظ النطاق",
|
||||
@@ -589,6 +596,8 @@
|
||||
"days": {}
|
||||
}
|
||||
},
|
||||
"alarmRepeatSectionLabel": "تكرار",
|
||||
"alarmVolumeLabel": "مستوى الصوت",
|
||||
"androidReliabilityTitle": "مراجعة موثوقية Android",
|
||||
"closeAction": "إغلاق",
|
||||
"customOption": "مخصص",
|
||||
@@ -705,6 +714,7 @@
|
||||
"alarmVolumeRisingStatus": "رفع مستوى الصوت تدريجيًا",
|
||||
"countriesAllTitle": "كل الدول",
|
||||
"countriesScreenTitle": "الدول",
|
||||
"countriesSearchHint": "الدولة أو الرمز...",
|
||||
"countriesYourLanguagesTitle": "لغاتك",
|
||||
"customStationsAddCta": "إضافة محطة مخصصة",
|
||||
"equalizerActiveOutputDefault": "مكبر صوت هذا الجهاز",
|
||||
@@ -746,6 +756,7 @@
|
||||
"recordingRenameEmptyError": "أدخل اسمًا",
|
||||
"recordingRenameLabel": "الاسم",
|
||||
"recordingsLibraryEmptySubtitle": "التسجيلات التي تحفظها ستظهر هنا.",
|
||||
"recordingsLibraryStorageFolderCaption": "Music/PluriWave · يتم حذف الأقدم عند بلوغ الحد",
|
||||
"recordingsLibraryEmptyTitle": "لا توجد تسجيلات بعد",
|
||||
"recordingsLibrarySettingsTooltip": "إعدادات التسجيل",
|
||||
"recordingsLibraryStorageCaption": "تم استخدام {usedMb} ميغابايت من أصل {totalMb} ميغابايت",
|
||||
@@ -780,6 +791,7 @@
|
||||
"times": {}
|
||||
}
|
||||
},
|
||||
"vacationExplainerBanner": "خلال هذه الفترات، لن تُصدر المنبهات المُعلَّمة بـ «إيقاف أثناء الإجازة» صوتًا. أما المُعلَّمة بـ «تشغيل دائمًا» فلن تتأثر.",
|
||||
"vacationNoActiveRangeHint": "لا توجد فترة إجازة نشطة الآن.",
|
||||
"vacationPastSectionTitle": "الفترات السابقة",
|
||||
"vacationRangesCount": "{count} فترة",
|
||||
|
||||
@@ -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": "ছুটিতে বাজান",
|
||||
@@ -464,6 +468,9 @@
|
||||
"deleteRangeTooltip": "পরিসর মুছুন",
|
||||
"vacationsDefaultName": "ছুটি",
|
||||
"newVacationRangeTitle": "নতুন ছুটির পরিসর",
|
||||
"editVacationRangeTitle": "ছুটির পরিসর সম্পাদনা করুন",
|
||||
"vacationDeleteConfirmTitle": "ছুটির পরিসর মুছবেন?",
|
||||
"vacationDeleteConfirmMessage": "এই পদক্ষেপ ফিরিয়ে নেওয়া যাবে না।",
|
||||
"startField": "শুরু",
|
||||
"endField": "শেষ",
|
||||
"saveRangeAction": "পরিসর সংরক্ষণ করুন",
|
||||
@@ -589,6 +596,8 @@
|
||||
"days": {}
|
||||
}
|
||||
},
|
||||
"alarmRepeatSectionLabel": "পুনরাবৃত্তি",
|
||||
"alarmVolumeLabel": "ভলিউম",
|
||||
"androidReliabilityTitle": "Android নির্ভরযোগ্যতা দেখুন",
|
||||
"closeAction": "বন্ধ করুন",
|
||||
"customOption": "কাস্টম",
|
||||
@@ -705,6 +714,7 @@
|
||||
"alarmVolumeRisingStatus": "ভলিউম বাড়ছে",
|
||||
"countriesAllTitle": "সব দেশ",
|
||||
"countriesScreenTitle": "দেশসমূহ",
|
||||
"countriesSearchHint": "দেশ বা কোড...",
|
||||
"countriesYourLanguagesTitle": "আপনার ভাষাসমূহ",
|
||||
"customStationsAddCta": "নিজস্ব স্টেশন যোগ করুন",
|
||||
"equalizerActiveOutputDefault": "এই ডিভাইসের স্পিকার",
|
||||
@@ -746,6 +756,7 @@
|
||||
"recordingRenameEmptyError": "একটি নাম লিখুন",
|
||||
"recordingRenameLabel": "নাম",
|
||||
"recordingsLibraryEmptySubtitle": "আপনি যেসব রেকর্ডিং সংরক্ষণ করবেন তা এখানে দেখা যাবে।",
|
||||
"recordingsLibraryStorageFolderCaption": "Music/PluriWave · সীমা পৌঁছালে সবচেয়ে পুরনোগুলো মুছে যায়",
|
||||
"recordingsLibraryEmptyTitle": "এখনও কোনো রেকর্ডিং নেই",
|
||||
"recordingsLibrarySettingsTooltip": "রেকর্ডিং সেটিংস",
|
||||
"recordingsLibraryStorageCaption": "{totalMb} MB-এর মধ্যে {usedMb} MB ব্যবহৃত",
|
||||
@@ -780,6 +791,7 @@
|
||||
"times": {}
|
||||
}
|
||||
},
|
||||
"vacationExplainerBanner": "এই সময়কালে \"ছুটিতে বিরতি\" হিসেবে চিহ্নিত অ্যালার্মগুলো বাজবে না। \"সবসময় বাজবে\" হিসেবে চিহ্নিত অ্যালার্মগুলো প্রভাবিত হবে না।",
|
||||
"vacationNoActiveRangeHint": "এই মুহূর্তে কোনো সক্রিয় ছুটির সময়সীমা নেই।",
|
||||
"vacationPastSectionTitle": "অতীত সময়সীমা",
|
||||
"vacationRangesCount": "{count}টি সময়সীমা",
|
||||
|
||||
@@ -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",
|
||||
@@ -464,6 +468,9 @@
|
||||
"deleteRangeTooltip": "Zeitraum löschen",
|
||||
"vacationsDefaultName": "Ferien",
|
||||
"newVacationRangeTitle": "Neuer Ferienzeitraum",
|
||||
"editVacationRangeTitle": "Ferienzeitraum bearbeiten",
|
||||
"vacationDeleteConfirmTitle": "Ferienzeitraum löschen?",
|
||||
"vacationDeleteConfirmMessage": "Dies kann nicht rückgängig gemacht werden.",
|
||||
"startField": "Beginn",
|
||||
"endField": "Ende",
|
||||
"saveRangeAction": "Zeitraum speichern",
|
||||
@@ -589,6 +596,8 @@
|
||||
"days": {}
|
||||
}
|
||||
},
|
||||
"alarmRepeatSectionLabel": "WIEDERHOLEN",
|
||||
"alarmVolumeLabel": "Lautstärke",
|
||||
"androidReliabilityTitle": "Android-Zuverlässigkeit prüfen",
|
||||
"closeAction": "Schließen",
|
||||
"customOption": "Benutzerdefiniert",
|
||||
@@ -705,6 +714,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 +756,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 +791,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",
|
||||
|
||||
@@ -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.",
|
||||
@@ -509,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",
|
||||
@@ -570,9 +576,13 @@
|
||||
"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",
|
||||
"editVacationRangeTitle": "Edit vacation range",
|
||||
"vacationDeleteConfirmTitle": "Delete vacation range?",
|
||||
"vacationDeleteConfirmMessage": "This can't be undone.",
|
||||
"startField": "Start",
|
||||
"endField": "End",
|
||||
"saveRangeAction": "Save range",
|
||||
@@ -698,6 +708,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.",
|
||||
@@ -509,6 +514,7 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"alarmSnoozeUsualLabel": "habitual",
|
||||
"saveFavoritesAlarmHint": "Guardá emisoras en Favoritos para usarlas como alarma musical.",
|
||||
"useCurrentStationAction": "Usar emisora actual",
|
||||
"playDuringVacations": "Sonar durante vacaciones",
|
||||
@@ -570,9 +576,13 @@
|
||||
"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",
|
||||
"editVacationRangeTitle": "Editar rango de vacaciones",
|
||||
"vacationDeleteConfirmTitle": "¿Eliminar rango de vacaciones?",
|
||||
"vacationDeleteConfirmMessage": "Esta acción no se puede deshacer.",
|
||||
"startField": "Inicio",
|
||||
"endField": "Fin",
|
||||
"saveRangeAction": "Guardar rango",
|
||||
@@ -654,6 +664,8 @@
|
||||
"@alarmScheduleOnce": {"placeholders": {"date": {}}},
|
||||
"alarmScheduleWeekdays": "Días: {days}",
|
||||
"@alarmScheduleWeekdays": {"placeholders": {"days": {}}},
|
||||
"alarmRepeatSectionLabel": "REPETIR",
|
||||
"alarmVolumeLabel": "Volumen",
|
||||
"androidReliabilityTitle": "Revisar fiabilidad Android",
|
||||
"closeAction": "Cerrar",
|
||||
"customOption": "Personalizada",
|
||||
|
||||
@@ -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",
|
||||
@@ -464,6 +468,9 @@
|
||||
"deleteRangeTooltip": "Supprimer la période",
|
||||
"vacationsDefaultName": "Vacances",
|
||||
"newVacationRangeTitle": "Nouvelle période de vacances",
|
||||
"editVacationRangeTitle": "Modifier la période de vacances",
|
||||
"vacationDeleteConfirmTitle": "Supprimer la période de vacances ?",
|
||||
"vacationDeleteConfirmMessage": "Cette action est irréversible.",
|
||||
"startField": "Début",
|
||||
"endField": "Fin",
|
||||
"saveRangeAction": "Enregistrer la période",
|
||||
@@ -589,6 +596,8 @@
|
||||
"days": {}
|
||||
}
|
||||
},
|
||||
"alarmRepeatSectionLabel": "RÉPÉTER",
|
||||
"alarmVolumeLabel": "Volume",
|
||||
"androidReliabilityTitle": "Vérifier la fiabilité Android",
|
||||
"closeAction": "Fermer",
|
||||
"customOption": "Personnalisée",
|
||||
@@ -705,6 +714,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 +756,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 +791,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",
|
||||
|
||||
@@ -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": "छुट्टियों में बजाएँ",
|
||||
@@ -464,6 +468,9 @@
|
||||
"deleteRangeTooltip": "अवधि हटाएँ",
|
||||
"vacationsDefaultName": "छुट्टियाँ",
|
||||
"newVacationRangeTitle": "नई छुट्टी अवधि",
|
||||
"editVacationRangeTitle": "छुट्टी अवधि संपादित करें",
|
||||
"vacationDeleteConfirmTitle": "छुट्टी अवधि हटाएं?",
|
||||
"vacationDeleteConfirmMessage": "इसे वापस नहीं लिया जा सकता।",
|
||||
"startField": "शुरुआत",
|
||||
"endField": "समाप्ति",
|
||||
"saveRangeAction": "अवधि सहेजें",
|
||||
@@ -589,6 +596,8 @@
|
||||
"days": {}
|
||||
}
|
||||
},
|
||||
"alarmRepeatSectionLabel": "दोहराएं",
|
||||
"alarmVolumeLabel": "आवाज़",
|
||||
"androidReliabilityTitle": "Android विश्वसनीयता जाँचें",
|
||||
"closeAction": "बंद करें",
|
||||
"customOption": "कस्टम",
|
||||
@@ -705,6 +714,7 @@
|
||||
"alarmVolumeRisingStatus": "आवाज़ बढ़ाई जा रही है",
|
||||
"countriesAllTitle": "सभी देश",
|
||||
"countriesScreenTitle": "देश",
|
||||
"countriesSearchHint": "देश या कोड...",
|
||||
"countriesYourLanguagesTitle": "आपकी भाषाएं",
|
||||
"customStationsAddCta": "मनचाहा स्टेशन जोड़ें",
|
||||
"equalizerActiveOutputDefault": "इस डिवाइस का स्पीकर",
|
||||
@@ -746,6 +756,7 @@
|
||||
"recordingRenameEmptyError": "एक नाम डालें",
|
||||
"recordingRenameLabel": "नाम",
|
||||
"recordingsLibraryEmptySubtitle": "आप जो रिकॉर्डिंग सहेजेंगे, वे यहां दिखाई देंगी।",
|
||||
"recordingsLibraryStorageFolderCaption": "Music/PluriWave · सीमा पूरी होने पर सबसे पुरानी रिकॉर्डिंग हटा दी जाती हैं",
|
||||
"recordingsLibraryEmptyTitle": "अभी तक कोई रिकॉर्डिंग नहीं",
|
||||
"recordingsLibrarySettingsTooltip": "रिकॉर्डिंग सेटिंग्स",
|
||||
"recordingsLibraryStorageCaption": "{totalMb} MB में से {usedMb} MB इस्तेमाल हुआ",
|
||||
@@ -780,6 +791,7 @@
|
||||
"times": {}
|
||||
}
|
||||
},
|
||||
"vacationExplainerBanner": "इन अवधियों के दौरान \"छुट्टी में रोकें\" के रूप में चिह्नित अलार्म नहीं बजेंगे। \"हमेशा बजाएं\" के रूप में चिह्नित अलार्म पर कोई असर नहीं पड़ता।",
|
||||
"vacationNoActiveRangeHint": "अभी कोई सक्रिय छुट्टी अवधि नहीं है।",
|
||||
"vacationPastSectionTitle": "पिछली अवधियां",
|
||||
"vacationRangesCount": "{count} अवधियां",
|
||||
|
||||
@@ -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",
|
||||
@@ -464,6 +468,9 @@
|
||||
"deleteRangeTooltip": "Hapus rentang",
|
||||
"vacationsDefaultName": "Liburan",
|
||||
"newVacationRangeTitle": "Rentang liburan baru",
|
||||
"editVacationRangeTitle": "Edit rentang liburan",
|
||||
"vacationDeleteConfirmTitle": "Hapus rentang liburan?",
|
||||
"vacationDeleteConfirmMessage": "Tindakan ini tidak dapat dibatalkan.",
|
||||
"startField": "Mulai",
|
||||
"endField": "Akhir",
|
||||
"saveRangeAction": "Simpan rentang",
|
||||
@@ -589,6 +596,8 @@
|
||||
"days": {}
|
||||
}
|
||||
},
|
||||
"alarmRepeatSectionLabel": "ULANGI",
|
||||
"alarmVolumeLabel": "Volume",
|
||||
"androidReliabilityTitle": "Tinjau keandalan Android",
|
||||
"closeAction": "Tutup",
|
||||
"customOption": "Kustom",
|
||||
@@ -705,6 +714,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 +756,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 +791,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",
|
||||
|
||||
@@ -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",
|
||||
@@ -464,6 +468,9 @@
|
||||
"deleteRangeTooltip": "Elimina periodo",
|
||||
"vacationsDefaultName": "Vacanze",
|
||||
"newVacationRangeTitle": "Nuovo periodo di vacanza",
|
||||
"editVacationRangeTitle": "Modifica periodo di vacanza",
|
||||
"vacationDeleteConfirmTitle": "Eliminare il periodo di vacanza?",
|
||||
"vacationDeleteConfirmMessage": "Questa azione non può essere annullata.",
|
||||
"startField": "Inizio",
|
||||
"endField": "Fine",
|
||||
"saveRangeAction": "Salva periodo",
|
||||
@@ -589,6 +596,8 @@
|
||||
"days": {}
|
||||
}
|
||||
},
|
||||
"alarmRepeatSectionLabel": "RIPETI",
|
||||
"alarmVolumeLabel": "Volume",
|
||||
"androidReliabilityTitle": "Controlla affidabilità Android",
|
||||
"closeAction": "Chiudi",
|
||||
"customOption": "Personalizzata",
|
||||
@@ -705,6 +714,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 +756,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 +791,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",
|
||||
|
||||
@@ -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": "休暇中も鳴らす",
|
||||
@@ -464,6 +468,9 @@
|
||||
"deleteRangeTooltip": "期間を削除",
|
||||
"vacationsDefaultName": "休暇",
|
||||
"newVacationRangeTitle": "新しい休暇期間",
|
||||
"editVacationRangeTitle": "休暇期間を編集",
|
||||
"vacationDeleteConfirmTitle": "休暇期間を削除しますか?",
|
||||
"vacationDeleteConfirmMessage": "この操作は元に戻せません。",
|
||||
"startField": "開始",
|
||||
"endField": "終了",
|
||||
"saveRangeAction": "期間を保存",
|
||||
@@ -589,6 +596,8 @@
|
||||
"days": {}
|
||||
}
|
||||
},
|
||||
"alarmRepeatSectionLabel": "繰り返し",
|
||||
"alarmVolumeLabel": "音量",
|
||||
"androidReliabilityTitle": "Androidの信頼性を確認",
|
||||
"closeAction": "閉じる",
|
||||
"customOption": "カスタム",
|
||||
@@ -705,6 +714,7 @@
|
||||
"alarmVolumeRisingStatus": "音量を上げています",
|
||||
"countriesAllTitle": "すべての国",
|
||||
"countriesScreenTitle": "国",
|
||||
"countriesSearchHint": "国名またはコード...",
|
||||
"countriesYourLanguagesTitle": "あなたの言語",
|
||||
"customStationsAddCta": "カスタム局を追加",
|
||||
"equalizerActiveOutputDefault": "この端末のスピーカー",
|
||||
@@ -746,6 +756,7 @@
|
||||
"recordingRenameEmptyError": "名前を入力してください",
|
||||
"recordingRenameLabel": "名前",
|
||||
"recordingsLibraryEmptySubtitle": "保存した録音はここに表示されます。",
|
||||
"recordingsLibraryStorageFolderCaption": "Music/PluriWave・上限に達すると古い順に削除されます",
|
||||
"recordingsLibraryEmptyTitle": "まだ録音がありません",
|
||||
"recordingsLibrarySettingsTooltip": "録音設定",
|
||||
"recordingsLibraryStorageCaption": "{totalMb} MB 中 {usedMb} MB 使用済み",
|
||||
@@ -780,6 +791,7 @@
|
||||
"times": {}
|
||||
}
|
||||
},
|
||||
"vacationExplainerBanner": "この期間中は「休暇中は一時停止」に設定したアラームは鳴りません。「常に鳴らす」に設定したアラームには影響しません。",
|
||||
"vacationNoActiveRangeHint": "現在アクティブな休暇期間はありません。",
|
||||
"vacationPastSectionTitle": "過去の期間",
|
||||
"vacationRangesCount": "{count} 件の期間",
|
||||
|
||||
@@ -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",
|
||||
@@ -464,6 +468,9 @@
|
||||
"deleteRangeTooltip": "Excluir período",
|
||||
"vacationsDefaultName": "Férias",
|
||||
"newVacationRangeTitle": "Novo período de férias",
|
||||
"editVacationRangeTitle": "Editar período de férias",
|
||||
"vacationDeleteConfirmTitle": "Excluir período de férias?",
|
||||
"vacationDeleteConfirmMessage": "Esta ação não pode ser desfeita.",
|
||||
"startField": "Início",
|
||||
"endField": "Fim",
|
||||
"saveRangeAction": "Salvar período",
|
||||
@@ -589,6 +596,8 @@
|
||||
"days": {}
|
||||
}
|
||||
},
|
||||
"alarmRepeatSectionLabel": "REPETIÇÃO",
|
||||
"alarmVolumeLabel": "Volume",
|
||||
"androidReliabilityTitle": "Revisar confiabilidade Android",
|
||||
"closeAction": "Fechar",
|
||||
"customOption": "Personalizada",
|
||||
@@ -705,6 +714,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 +756,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 +791,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",
|
||||
|
||||
@@ -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": "Звонить во время отпусков",
|
||||
@@ -464,6 +468,9 @@
|
||||
"deleteRangeTooltip": "Удалить период",
|
||||
"vacationsDefaultName": "Отпуск",
|
||||
"newVacationRangeTitle": "Новый период отпуска",
|
||||
"editVacationRangeTitle": "Изменить период отпуска",
|
||||
"vacationDeleteConfirmTitle": "Удалить период отпуска?",
|
||||
"vacationDeleteConfirmMessage": "Это действие нельзя отменить.",
|
||||
"startField": "Начало",
|
||||
"endField": "Конец",
|
||||
"saveRangeAction": "Сохранить период",
|
||||
@@ -589,6 +596,8 @@
|
||||
"days": {}
|
||||
}
|
||||
},
|
||||
"alarmRepeatSectionLabel": "ПОВТОР",
|
||||
"alarmVolumeLabel": "Громкость",
|
||||
"androidReliabilityTitle": "Проверить надёжность Android",
|
||||
"closeAction": "Закрыть",
|
||||
"customOption": "Своя",
|
||||
@@ -705,6 +714,7 @@
|
||||
"alarmVolumeRisingStatus": "Громкость нарастает",
|
||||
"countriesAllTitle": "Все страны",
|
||||
"countriesScreenTitle": "Страны",
|
||||
"countriesSearchHint": "Страна или код...",
|
||||
"countriesYourLanguagesTitle": "Ваши языки",
|
||||
"customStationsAddCta": "Добавить пользовательскую станцию",
|
||||
"equalizerActiveOutputDefault": "Динамик этого устройства",
|
||||
@@ -746,6 +756,7 @@
|
||||
"recordingRenameEmptyError": "Введите название",
|
||||
"recordingRenameLabel": "Название",
|
||||
"recordingsLibraryEmptySubtitle": "Записи, которые вы сохраните, появятся здесь.",
|
||||
"recordingsLibraryStorageFolderCaption": "Music/PluriWave · при достижении лимита удаляются старые записи",
|
||||
"recordingsLibraryEmptyTitle": "Записей пока нет",
|
||||
"recordingsLibrarySettingsTooltip": "Настройки записи",
|
||||
"recordingsLibraryStorageCaption": "Использовано {usedMb} МБ из {totalMb} МБ",
|
||||
@@ -780,6 +791,7 @@
|
||||
"times": {}
|
||||
}
|
||||
},
|
||||
"vacationExplainerBanner": "В эти периоды будильники с пометкой «пауза на время отпуска» не звонят. Будильники с пометкой «звонить всегда» это не затрагивает.",
|
||||
"vacationNoActiveRangeHint": "Сейчас нет активного периода отпуска.",
|
||||
"vacationPastSectionTitle": "Прошедшие периоды",
|
||||
"vacationRangesCount": "{count} периодов",
|
||||
|
||||
@@ -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": "假期期间响铃",
|
||||
@@ -464,6 +468,9 @@
|
||||
"deleteRangeTooltip": "删除范围",
|
||||
"vacationsDefaultName": "假期",
|
||||
"newVacationRangeTitle": "新的假期范围",
|
||||
"editVacationRangeTitle": "编辑假期范围",
|
||||
"vacationDeleteConfirmTitle": "删除假期范围?",
|
||||
"vacationDeleteConfirmMessage": "此操作无法撤销。",
|
||||
"startField": "开始",
|
||||
"endField": "结束",
|
||||
"saveRangeAction": "保存范围",
|
||||
@@ -589,6 +596,8 @@
|
||||
"days": {}
|
||||
}
|
||||
},
|
||||
"alarmRepeatSectionLabel": "重复",
|
||||
"alarmVolumeLabel": "音量",
|
||||
"androidReliabilityTitle": "检查 Android 可靠性",
|
||||
"closeAction": "关闭",
|
||||
"customOption": "自定义",
|
||||
@@ -705,6 +714,7 @@
|
||||
"alarmVolumeRisingStatus": "音量渐强中",
|
||||
"countriesAllTitle": "所有国家",
|
||||
"countriesScreenTitle": "国家",
|
||||
"countriesSearchHint": "国家或代码...",
|
||||
"countriesYourLanguagesTitle": "你的语言",
|
||||
"customStationsAddCta": "添加自定义电台",
|
||||
"equalizerActiveOutputDefault": "此设备的扬声器",
|
||||
@@ -746,6 +756,7 @@
|
||||
"recordingRenameEmptyError": "请输入名称",
|
||||
"recordingRenameLabel": "名称",
|
||||
"recordingsLibraryEmptySubtitle": "你保存的录音会显示在这里。",
|
||||
"recordingsLibraryStorageFolderCaption": "Music/PluriWave · 达到上限时会删除最旧的文件",
|
||||
"recordingsLibraryEmptyTitle": "暂无录音",
|
||||
"recordingsLibrarySettingsTooltip": "录音设置",
|
||||
"recordingsLibraryStorageCaption": "已使用 {usedMb} MB / 共 {totalMb} MB",
|
||||
@@ -780,6 +791,7 @@
|
||||
"times": {}
|
||||
}
|
||||
},
|
||||
"vacationExplainerBanner": "在这些时间段内,标记为“假期暂停”的闹钟不会响铃;标记为“始终响铃”的闹钟不受影响。",
|
||||
"vacationNoActiveRangeHint": "目前没有正在生效的假期时间段。",
|
||||
"vacationPastSectionTitle": "过往时间段",
|
||||
"vacationRangesCount": "{count} 个时间段",
|
||||
|
||||
@@ -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:
|
||||
@@ -1882,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:
|
||||
@@ -2024,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:
|
||||
@@ -2042,6 +2078,24 @@ abstract class AppLocalizations {
|
||||
/// **'Nuevo rango de vacaciones'**
|
||||
String get newVacationRangeTitle;
|
||||
|
||||
/// No description provided for @editVacationRangeTitle.
|
||||
///
|
||||
/// In es, this message translates to:
|
||||
/// **'Editar rango de vacaciones'**
|
||||
String get editVacationRangeTitle;
|
||||
|
||||
/// No description provided for @vacationDeleteConfirmTitle.
|
||||
///
|
||||
/// In es, this message translates to:
|
||||
/// **'¿Eliminar rango de vacaciones?'**
|
||||
String get vacationDeleteConfirmTitle;
|
||||
|
||||
/// No description provided for @vacationDeleteConfirmMessage.
|
||||
///
|
||||
/// In es, this message translates to:
|
||||
/// **'Esta acción no se puede deshacer.'**
|
||||
String get vacationDeleteConfirmMessage;
|
||||
|
||||
/// No description provided for @startField.
|
||||
///
|
||||
/// In es, this message translates to:
|
||||
@@ -2462,6 +2516,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 => 'لغاتك';
|
||||
|
||||
@@ -1014,6 +1029,9 @@ class AppLocalizationsAr extends AppLocalizations {
|
||||
return '$minutes د';
|
||||
}
|
||||
|
||||
@override
|
||||
String get alarmSnoozeUsualLabel => 'المعتاد';
|
||||
|
||||
@override
|
||||
String get saveFavoritesAlarmHint =>
|
||||
'احفظ محطات في المفضلة لاستخدامها كمنبه موسيقي.';
|
||||
@@ -1102,6 +1120,10 @@ class AppLocalizationsAr extends AppLocalizations {
|
||||
@override
|
||||
String get addVacationRangeCta => 'إضافة فترة';
|
||||
|
||||
@override
|
||||
String get vacationExplainerBanner =>
|
||||
'خلال هذه الفترات، لن تُصدر المنبهات المُعلَّمة بـ «إيقاف أثناء الإجازة» صوتًا. أما المُعلَّمة بـ «تشغيل دائمًا» فلن تتأثر.';
|
||||
|
||||
@override
|
||||
String get vacationNoActiveRangeHint => 'لا توجد فترة إجازة نشطة الآن.';
|
||||
|
||||
@@ -1111,6 +1133,15 @@ class AppLocalizationsAr extends AppLocalizations {
|
||||
@override
|
||||
String get newVacationRangeTitle => 'نطاق إجازة جديد';
|
||||
|
||||
@override
|
||||
String get editVacationRangeTitle => 'تعديل نطاق الإجازة';
|
||||
|
||||
@override
|
||||
String get vacationDeleteConfirmTitle => 'هل تريد حذف نطاق الإجازة؟';
|
||||
|
||||
@override
|
||||
String get vacationDeleteConfirmMessage => 'لا يمكن التراجع عن هذا الإجراء.';
|
||||
|
||||
@override
|
||||
String get startField => 'البداية';
|
||||
|
||||
@@ -1351,6 +1382,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 => 'আপনার ভাষাসমূহ';
|
||||
|
||||
@@ -1019,6 +1034,9 @@ class AppLocalizationsBn extends AppLocalizations {
|
||||
return '$minutes মিনিট';
|
||||
}
|
||||
|
||||
@override
|
||||
String get alarmSnoozeUsualLabel => 'সাধারণ';
|
||||
|
||||
@override
|
||||
String get saveFavoritesAlarmHint =>
|
||||
'সুরেলা অ্যালার্ম হিসেবে ব্যবহার করতে প্রিয়তে স্টেশন সংরক্ষণ করুন।';
|
||||
@@ -1108,6 +1126,10 @@ class AppLocalizationsBn extends AppLocalizations {
|
||||
@override
|
||||
String get addVacationRangeCta => 'সময়সীমা যোগ করুন';
|
||||
|
||||
@override
|
||||
String get vacationExplainerBanner =>
|
||||
'এই সময়কালে \"ছুটিতে বিরতি\" হিসেবে চিহ্নিত অ্যালার্মগুলো বাজবে না। \"সবসময় বাজবে\" হিসেবে চিহ্নিত অ্যালার্মগুলো প্রভাবিত হবে না।';
|
||||
|
||||
@override
|
||||
String get vacationNoActiveRangeHint =>
|
||||
'এই মুহূর্তে কোনো সক্রিয় ছুটির সময়সীমা নেই।';
|
||||
@@ -1118,6 +1140,16 @@ class AppLocalizationsBn extends AppLocalizations {
|
||||
@override
|
||||
String get newVacationRangeTitle => 'নতুন ছুটির পরিসর';
|
||||
|
||||
@override
|
||||
String get editVacationRangeTitle => 'ছুটির পরিসর সম্পাদনা করুন';
|
||||
|
||||
@override
|
||||
String get vacationDeleteConfirmTitle => 'ছুটির পরিসর মুছবেন?';
|
||||
|
||||
@override
|
||||
String get vacationDeleteConfirmMessage =>
|
||||
'এই পদক্ষেপ ফিরিয়ে নেওয়া যাবে না।';
|
||||
|
||||
@override
|
||||
String get startField => 'শুরু';
|
||||
|
||||
@@ -1360,6 +1392,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';
|
||||
|
||||
@@ -1022,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.';
|
||||
@@ -1110,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.';
|
||||
@@ -1120,6 +1142,16 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
@override
|
||||
String get newVacationRangeTitle => 'Neuer Ferienzeitraum';
|
||||
|
||||
@override
|
||||
String get editVacationRangeTitle => 'Ferienzeitraum bearbeiten';
|
||||
|
||||
@override
|
||||
String get vacationDeleteConfirmTitle => 'Ferienzeitraum löschen?';
|
||||
|
||||
@override
|
||||
String get vacationDeleteConfirmMessage =>
|
||||
'Dies kann nicht rückgängig gemacht werden.';
|
||||
|
||||
@override
|
||||
String get startField => 'Beginn';
|
||||
|
||||
@@ -1368,6 +1400,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';
|
||||
|
||||
@@ -1014,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.';
|
||||
@@ -1102,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.';
|
||||
|
||||
@@ -1111,6 +1133,15 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
@override
|
||||
String get newVacationRangeTitle => 'New vacation range';
|
||||
|
||||
@override
|
||||
String get editVacationRangeTitle => 'Edit vacation range';
|
||||
|
||||
@override
|
||||
String get vacationDeleteConfirmTitle => 'Delete vacation range?';
|
||||
|
||||
@override
|
||||
String get vacationDeleteConfirmMessage => 'This can\'t be undone.';
|
||||
|
||||
@override
|
||||
String get startField => 'Start';
|
||||
|
||||
@@ -1352,6 +1383,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';
|
||||
|
||||
@@ -1019,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.';
|
||||
@@ -1107,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.';
|
||||
@@ -1117,6 +1139,16 @@ class AppLocalizationsEs extends AppLocalizations {
|
||||
@override
|
||||
String get newVacationRangeTitle => 'Nuevo rango de vacaciones';
|
||||
|
||||
@override
|
||||
String get editVacationRangeTitle => 'Editar rango de vacaciones';
|
||||
|
||||
@override
|
||||
String get vacationDeleteConfirmTitle => '¿Eliminar rango de vacaciones?';
|
||||
|
||||
@override
|
||||
String get vacationDeleteConfirmMessage =>
|
||||
'Esta acción no se puede deshacer.';
|
||||
|
||||
@override
|
||||
String get startField => 'Inicio';
|
||||
|
||||
@@ -1362,6 +1394,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';
|
||||
|
||||
@@ -1024,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.';
|
||||
@@ -1113,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.';
|
||||
@@ -1123,6 +1145,15 @@ class AppLocalizationsFr extends AppLocalizations {
|
||||
@override
|
||||
String get newVacationRangeTitle => 'Nouvelle période de vacances';
|
||||
|
||||
@override
|
||||
String get editVacationRangeTitle => 'Modifier la période de vacances';
|
||||
|
||||
@override
|
||||
String get vacationDeleteConfirmTitle => 'Supprimer la période de vacances ?';
|
||||
|
||||
@override
|
||||
String get vacationDeleteConfirmMessage => 'Cette action est irréversible.';
|
||||
|
||||
@override
|
||||
String get startField => 'Début';
|
||||
|
||||
@@ -1372,6 +1403,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 => 'आपकी भाषाएं';
|
||||
|
||||
@@ -1015,6 +1030,9 @@ class AppLocalizationsHi extends AppLocalizations {
|
||||
return '$minutes मिनट';
|
||||
}
|
||||
|
||||
@override
|
||||
String get alarmSnoozeUsualLabel => 'सामान्य';
|
||||
|
||||
@override
|
||||
String get saveFavoritesAlarmHint =>
|
||||
'उन्हें संगीतमय अलार्म के रूप में इस्तेमाल करने के लिए स्टेशन पसंदीदा में सहेजें।';
|
||||
@@ -1103,6 +1121,10 @@ class AppLocalizationsHi extends AppLocalizations {
|
||||
@override
|
||||
String get addVacationRangeCta => 'अवधि जोड़ें';
|
||||
|
||||
@override
|
||||
String get vacationExplainerBanner =>
|
||||
'इन अवधियों के दौरान \"छुट्टी में रोकें\" के रूप में चिह्नित अलार्म नहीं बजेंगे। \"हमेशा बजाएं\" के रूप में चिह्नित अलार्म पर कोई असर नहीं पड़ता।';
|
||||
|
||||
@override
|
||||
String get vacationNoActiveRangeHint => 'अभी कोई सक्रिय छुट्टी अवधि नहीं है।';
|
||||
|
||||
@@ -1112,6 +1134,15 @@ class AppLocalizationsHi extends AppLocalizations {
|
||||
@override
|
||||
String get newVacationRangeTitle => 'नई छुट्टी अवधि';
|
||||
|
||||
@override
|
||||
String get editVacationRangeTitle => 'छुट्टी अवधि संपादित करें';
|
||||
|
||||
@override
|
||||
String get vacationDeleteConfirmTitle => 'छुट्टी अवधि हटाएं?';
|
||||
|
||||
@override
|
||||
String get vacationDeleteConfirmMessage => 'इसे वापस नहीं लिया जा सकता।';
|
||||
|
||||
@override
|
||||
String get startField => 'शुरुआत';
|
||||
|
||||
@@ -1355,6 +1386,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';
|
||||
|
||||
@@ -1018,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.';
|
||||
@@ -1107,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.';
|
||||
@@ -1117,6 +1139,16 @@ class AppLocalizationsId extends AppLocalizations {
|
||||
@override
|
||||
String get newVacationRangeTitle => 'Rentang liburan baru';
|
||||
|
||||
@override
|
||||
String get editVacationRangeTitle => 'Edit rentang liburan';
|
||||
|
||||
@override
|
||||
String get vacationDeleteConfirmTitle => 'Hapus rentang liburan?';
|
||||
|
||||
@override
|
||||
String get vacationDeleteConfirmMessage =>
|
||||
'Tindakan ini tidak dapat dibatalkan.';
|
||||
|
||||
@override
|
||||
String get startField => 'Mulai';
|
||||
|
||||
@@ -1363,6 +1395,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';
|
||||
|
||||
@@ -1023,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.';
|
||||
@@ -1112,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.';
|
||||
@@ -1122,6 +1144,16 @@ class AppLocalizationsIt extends AppLocalizations {
|
||||
@override
|
||||
String get newVacationRangeTitle => 'Nuovo periodo di vacanza';
|
||||
|
||||
@override
|
||||
String get editVacationRangeTitle => 'Modifica periodo di vacanza';
|
||||
|
||||
@override
|
||||
String get vacationDeleteConfirmTitle => 'Eliminare il periodo di vacanza?';
|
||||
|
||||
@override
|
||||
String get vacationDeleteConfirmMessage =>
|
||||
'Questa azione non può essere annullata.';
|
||||
|
||||
@override
|
||||
String get startField => 'Inizio';
|
||||
|
||||
@@ -1371,6 +1403,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 => 'あなたの言語';
|
||||
|
||||
@@ -985,6 +1000,9 @@ class AppLocalizationsJa extends AppLocalizations {
|
||||
return '$minutes分';
|
||||
}
|
||||
|
||||
@override
|
||||
String get alarmSnoozeUsualLabel => '通常';
|
||||
|
||||
@override
|
||||
String get saveFavoritesAlarmHint => '音楽アラームとして使うには、局をお気に入りに保存してください。';
|
||||
|
||||
@@ -1070,6 +1088,10 @@ class AppLocalizationsJa extends AppLocalizations {
|
||||
@override
|
||||
String get addVacationRangeCta => '期間を追加';
|
||||
|
||||
@override
|
||||
String get vacationExplainerBanner =>
|
||||
'この期間中は「休暇中は一時停止」に設定したアラームは鳴りません。「常に鳴らす」に設定したアラームには影響しません。';
|
||||
|
||||
@override
|
||||
String get vacationNoActiveRangeHint => '現在アクティブな休暇期間はありません。';
|
||||
|
||||
@@ -1079,6 +1101,15 @@ class AppLocalizationsJa extends AppLocalizations {
|
||||
@override
|
||||
String get newVacationRangeTitle => '新しい休暇期間';
|
||||
|
||||
@override
|
||||
String get editVacationRangeTitle => '休暇期間を編集';
|
||||
|
||||
@override
|
||||
String get vacationDeleteConfirmTitle => '休暇期間を削除しますか?';
|
||||
|
||||
@override
|
||||
String get vacationDeleteConfirmMessage => 'この操作は元に戻せません。';
|
||||
|
||||
@override
|
||||
String get startField => '開始';
|
||||
|
||||
@@ -1312,6 +1343,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';
|
||||
|
||||
@@ -1018,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.';
|
||||
@@ -1106,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.';
|
||||
@@ -1116,6 +1138,15 @@ class AppLocalizationsPt extends AppLocalizations {
|
||||
@override
|
||||
String get newVacationRangeTitle => 'Novo período de férias';
|
||||
|
||||
@override
|
||||
String get editVacationRangeTitle => 'Editar período de férias';
|
||||
|
||||
@override
|
||||
String get vacationDeleteConfirmTitle => 'Excluir período de férias?';
|
||||
|
||||
@override
|
||||
String get vacationDeleteConfirmMessage => 'Esta ação não pode ser desfeita.';
|
||||
|
||||
@override
|
||||
String get startField => 'Início';
|
||||
|
||||
@@ -1360,6 +1391,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 => 'Ваши языки';
|
||||
|
||||
@@ -1020,6 +1035,9 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
return '$minutes мин';
|
||||
}
|
||||
|
||||
@override
|
||||
String get alarmSnoozeUsualLabel => 'обычно';
|
||||
|
||||
@override
|
||||
String get saveFavoritesAlarmHint =>
|
||||
'Сохраните станции в избранное, чтобы использовать их как музыкальный будильник.';
|
||||
@@ -1108,6 +1126,10 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
@override
|
||||
String get addVacationRangeCta => 'Добавить период';
|
||||
|
||||
@override
|
||||
String get vacationExplainerBanner =>
|
||||
'В эти периоды будильники с пометкой «пауза на время отпуска» не звонят. Будильники с пометкой «звонить всегда» это не затрагивает.';
|
||||
|
||||
@override
|
||||
String get vacationNoActiveRangeHint =>
|
||||
'Сейчас нет активного периода отпуска.';
|
||||
@@ -1118,6 +1140,15 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
@override
|
||||
String get newVacationRangeTitle => 'Новый период отпуска';
|
||||
|
||||
@override
|
||||
String get editVacationRangeTitle => 'Изменить период отпуска';
|
||||
|
||||
@override
|
||||
String get vacationDeleteConfirmTitle => 'Удалить период отпуска?';
|
||||
|
||||
@override
|
||||
String get vacationDeleteConfirmMessage => 'Это действие нельзя отменить.';
|
||||
|
||||
@override
|
||||
String get startField => 'Начало';
|
||||
|
||||
@@ -1364,6 +1395,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 => '你的语言';
|
||||
|
||||
@@ -981,6 +996,9 @@ class AppLocalizationsZh extends AppLocalizations {
|
||||
return '$minutes 分钟';
|
||||
}
|
||||
|
||||
@override
|
||||
String get alarmSnoozeUsualLabel => '常用';
|
||||
|
||||
@override
|
||||
String get saveFavoritesAlarmHint => '将电台保存到收藏,即可把它们用作音乐闹钟。';
|
||||
|
||||
@@ -1066,6 +1084,10 @@ class AppLocalizationsZh extends AppLocalizations {
|
||||
@override
|
||||
String get addVacationRangeCta => '添加时间段';
|
||||
|
||||
@override
|
||||
String get vacationExplainerBanner =>
|
||||
'在这些时间段内,标记为“假期暂停”的闹钟不会响铃;标记为“始终响铃”的闹钟不受影响。';
|
||||
|
||||
@override
|
||||
String get vacationNoActiveRangeHint => '目前没有正在生效的假期时间段。';
|
||||
|
||||
@@ -1075,6 +1097,15 @@ class AppLocalizationsZh extends AppLocalizations {
|
||||
@override
|
||||
String get newVacationRangeTitle => '新的假期范围';
|
||||
|
||||
@override
|
||||
String get editVacationRangeTitle => '编辑假期范围';
|
||||
|
||||
@override
|
||||
String get vacationDeleteConfirmTitle => '删除假期范围?';
|
||||
|
||||
@override
|
||||
String get vacationDeleteConfirmMessage => '此操作无法撤销。';
|
||||
|
||||
@override
|
||||
String get startField => '开始';
|
||||
|
||||
@@ -1307,6 +1338,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,20 +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++) ...[
|
||||
// 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],
|
||||
],
|
||||
],
|
||||
),
|
||||
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],
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -56,6 +73,7 @@ class FilaAjuste extends StatelessWidget {
|
||||
required this.titulo,
|
||||
required this.onTap,
|
||||
this.valor,
|
||||
this.iconColor,
|
||||
});
|
||||
|
||||
final IconData icon;
|
||||
@@ -68,6 +86,21 @@ 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;
|
||||
|
||||
/// Issue 5 (feedback-pruebas): caps how much width the trailing current
|
||||
/// value can claim. `ListTile` gives `trailing` as much width as it wants
|
||||
/// before handing the title whatever is left — an unbounded value (e.g. a
|
||||
/// real, arbitrarily long station name for "Emisora preferida") could
|
||||
/// squeeze the title down to almost nothing, forcing it to wrap across
|
||||
/// several lines that then get cut short by the row's fixed height.
|
||||
static const _anchoMaximoValor = 108.0;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final type = context.pluriType;
|
||||
@@ -76,22 +109,37 @@ class FilaAjuste extends StatelessWidget {
|
||||
contentPadding: EdgeInsets.zero,
|
||||
// 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),
|
||||
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)),
|
||||
// Issue 5: constrained to one line, ellipsizing instead of wrapping —
|
||||
// labels must wrap as little as possible and never render visibly
|
||||
// truncated (a multi-line wrap inside this fixed-height row cuts the
|
||||
// last line short, which reads as broken, not as intentional).
|
||||
title: Text(
|
||||
titulo,
|
||||
style: type.cardTitle.copyWith(fontSize: 14),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
trailing: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (valorActual != null) ...[
|
||||
Text(
|
||||
valorActual,
|
||||
// bodyStrong is already 13/w600, matching the prototype's row
|
||||
// value spec exactly — only the colour needs overriding.
|
||||
style: type.bodyStrong.copyWith(
|
||||
color: const Color(0xFFF2F7FA).withValues(alpha: 0.55),
|
||||
ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: _anchoMaximoValor),
|
||||
child: Text(
|
||||
valorActual,
|
||||
// bodyStrong is already 13/w600, matching the prototype's row
|
||||
// value spec exactly — only the colour needs overriding.
|
||||
style: type.bodyStrong.copyWith(
|
||||
color: const Color(0xFFF2F7FA).withValues(alpha: 0.55),
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
textAlign: TextAlign.end,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -225,6 +225,25 @@ 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(
|
||||
@@ -689,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,
|
||||
@@ -703,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),
|
||||
|
||||
+476
-170
@@ -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),
|
||||
),
|
||||
],
|
||||
@@ -105,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
|
||||
@@ -112,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),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -243,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),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -532,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
|
||||
@@ -573,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),
|
||||
@@ -663,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,
|
||||
@@ -918,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),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -1089,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,
|
||||
@@ -1122,6 +1397,10 @@ class _PanelVacaciones extends StatelessWidget {
|
||||
],
|
||||
),
|
||||
),
|
||||
if (rangoRelevante != null) ...[
|
||||
const SizedBox(width: 8),
|
||||
_PildoraFechasVacaciones(rango: rangoRelevante),
|
||||
],
|
||||
const Icon(Icons.chevron_right_rounded),
|
||||
],
|
||||
),
|
||||
@@ -1168,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
|
||||
@@ -1287,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});
|
||||
|
||||
@@ -1375,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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -92,10 +92,16 @@ class PantallaBienvenida extends StatelessWidget {
|
||||
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),
|
||||
@@ -143,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(
|
||||
|
||||
@@ -253,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,
|
||||
@@ -267,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>(
|
||||
@@ -306,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,
|
||||
);
|
||||
}
|
||||
@@ -518,13 +543,33 @@ class _PantallaBuscarState extends State<PantallaBuscar> {
|
||||
if (estado.cargando) {
|
||||
// S5-R6: shimmer placeholders instead of a bare spinner, consistent
|
||||
// with the loading pattern used by the home grid.
|
||||
// Issue 3 (feedback-pruebas): row tier (12), not card tier (16) --
|
||||
// these are background-less row placeholders, same tier as the real
|
||||
// results below; the top inset is the standard section gap rather
|
||||
// than the horizontal constant reused for a vertical axis.
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(PluriLayout.horizontal),
|
||||
padding: const EdgeInsets.fromLTRB(
|
||||
PluriLayout.rowHorizontal,
|
||||
PluriLayout.sectionGap,
|
||||
PluriLayout.rowHorizontal,
|
||||
PluriLayout.rowHorizontal,
|
||||
),
|
||||
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),
|
||||
],
|
||||
],
|
||||
),
|
||||
@@ -535,36 +580,49 @@ 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();
|
||||
// Issue 3 (feedback-pruebas): this card-tier state had NO top gap at
|
||||
// all against the filter row above it -- the standard section gap
|
||||
// now matches the other two mutually-exclusive results-area states
|
||||
// (loading, populated) above.
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(
|
||||
PluriLayout.horizontal,
|
||||
PluriLayout.sectionGap,
|
||||
PluriLayout.horizontal,
|
||||
0,
|
||||
),
|
||||
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,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -576,7 +634,17 @@ class _PantallaBuscarState extends State<PantallaBuscar> {
|
||||
return ListView.builder(
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
padding: const EdgeInsets.all(PluriLayout.horizontal),
|
||||
// Issue 3 (feedback-pruebas): row tier (12), not card tier (16) --
|
||||
// `FilaEmisoraPlana` rows are documented as "flat, background-less"
|
||||
// (audit 6.5) but this padding never got updated to match when Tier 1
|
||||
// introduced the 3-tier scale. The top inset is the standard section
|
||||
// gap, not the horizontal constant reused for a vertical axis.
|
||||
padding: const EdgeInsets.fromLTRB(
|
||||
PluriLayout.rowHorizontal,
|
||||
PluriLayout.sectionGap,
|
||||
PluriLayout.rowHorizontal,
|
||||
PluriLayout.rowHorizontal,
|
||||
),
|
||||
itemCount: total,
|
||||
itemBuilder: (context, i) {
|
||||
if (i >= resultados.length) {
|
||||
@@ -1213,6 +1281,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.
|
||||
|
||||
@@ -6,6 +6,7 @@ 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';
|
||||
@@ -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,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
@@ -440,7 +465,13 @@ class _FilaFavorito extends StatelessWidget {
|
||||
context,
|
||||
).colorScheme.onSurface.withValues(alpha: 0.45),
|
||||
),
|
||||
constraints: const BoxConstraints.tightFor(width: 38, height: 42),
|
||||
// NO `constraints:` here. That property sizes the POPUP MENU, not
|
||||
// the button — a tightFor(38x42) clipped every menu item down to
|
||||
// its first letter ("M" for "Mover a lista", "E" for "Eliminar de
|
||||
// favoritos"), which is what users actually saw. Constrain the
|
||||
// tap target instead.
|
||||
padding: EdgeInsets.zero,
|
||||
iconSize: 20,
|
||||
onSelected: (accion) {
|
||||
if (accion == 'assign') _asignar(context);
|
||||
if (accion == 'remove') _eliminar(context);
|
||||
|
||||
@@ -308,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,
|
||||
),
|
||||
],
|
||||
|
||||
@@ -15,6 +15,7 @@ import '../widgets/pluri_glass_surface.dart';
|
||||
import '../widgets/pluri_layout.dart';
|
||||
import '../widgets/pluri_premium_widgets.dart';
|
||||
import '../widgets/pluri_sleep_timer_sheet.dart';
|
||||
import '../widgets/pluri_station_art_fallback.dart';
|
||||
import '../widgets/visualizador_audio.dart';
|
||||
|
||||
import 'pantalla_reproductor.dart';
|
||||
@@ -568,9 +569,9 @@ class _ArteEscuchar extends StatelessWidget {
|
||||
imageUrl: emisora.favicon!,
|
||||
fit: BoxFit.cover,
|
||||
placeholder: (_, __) => _shimmerCuadrado(theme),
|
||||
errorWidget: (_, __, ___) => _iconoFallback(theme),
|
||||
errorWidget: (_, __, ___) => _iconoFallback(),
|
||||
)
|
||||
: _iconoFallback(theme),
|
||||
: _iconoFallback(),
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -582,14 +583,11 @@ class _ArteEscuchar extends StatelessWidget {
|
||||
child: Container(color: theme.colorScheme.surfaceContainerHighest),
|
||||
);
|
||||
|
||||
Widget _iconoFallback(ThemeData theme) => Container(
|
||||
color: theme.colorScheme.primaryContainer,
|
||||
child: Icon(
|
||||
Icons.radio_rounded,
|
||||
size: 36,
|
||||
color: theme.colorScheme.onPrimaryContainer,
|
||||
),
|
||||
);
|
||||
// Issue 6 (feedback-pruebas): was a flat `primaryContainer` square with a
|
||||
// bare `radio_rounded` icon — now the same shared fallback every other
|
||||
// surface uses.
|
||||
Widget _iconoFallback() =>
|
||||
PluriStationArtFallback(seed: emisora.uuid, iconSize: 36);
|
||||
}
|
||||
|
||||
/// The hero's transport row — favorite / EQ toggle / stop / play-pause
|
||||
@@ -909,10 +907,10 @@ class _CeldaTusEmisoras extends StatelessWidget {
|
||||
imageUrl: emisora.favicon!,
|
||||
fit: BoxFit.cover,
|
||||
placeholder: (_, __) => _shimmer(theme),
|
||||
errorWidget: (_, __, ___) => _iconoFallback(theme),
|
||||
errorWidget: (_, __, ___) => _iconoFallback(),
|
||||
);
|
||||
}
|
||||
return _iconoFallback(theme);
|
||||
return _iconoFallback();
|
||||
}
|
||||
|
||||
Widget _shimmer(ThemeData theme) => shimmer.Shimmer.fromColors(
|
||||
@@ -921,12 +919,9 @@ class _CeldaTusEmisoras extends StatelessWidget {
|
||||
child: Container(color: theme.colorScheme.surfaceContainerHighest),
|
||||
);
|
||||
|
||||
Widget _iconoFallback(ThemeData theme) => Container(
|
||||
color: theme.colorScheme.primaryContainer,
|
||||
child: Icon(
|
||||
Icons.radio_rounded,
|
||||
size: 20,
|
||||
color: theme.colorScheme.onPrimaryContainer,
|
||||
),
|
||||
);
|
||||
// Issue 6 (feedback-pruebas): was a flat `primaryContainer` square with a
|
||||
// bare `radio_rounded` icon — now the same shared fallback every other
|
||||
// surface uses.
|
||||
Widget _iconoFallback() =>
|
||||
PluriStationArtFallback(seed: emisora.uuid, iconSize: 20);
|
||||
}
|
||||
|
||||
@@ -4,8 +4,8 @@ import 'package:provider/provider.dart';
|
||||
import '../estado/estado_busqueda.dart';
|
||||
import '../l10n/gen/app_localizations.dart';
|
||||
import '../modelos/pais_radio.dart';
|
||||
import '../tema/pluriwave_theme.dart';
|
||||
import '../tema/pluriwave_tokens.dart';
|
||||
import '../widgets/pluri_glass_surface.dart';
|
||||
import '../widgets/pluri_layout.dart';
|
||||
import '../widgets/pluri_push_scaffold.dart';
|
||||
|
||||
@@ -30,6 +30,12 @@ class PantallaPaises extends StatefulWidget {
|
||||
}
|
||||
|
||||
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
|
||||
@@ -65,22 +71,73 @@ 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),
|
||||
],
|
||||
),
|
||||
);
|
||||
@@ -104,31 +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),
|
||||
// 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)
|
||||
_FilaPais(
|
||||
pais: pais,
|
||||
l10n: l10n,
|
||||
// Item 24 / audit 5.5 (t4:256): the first row is highlighted.
|
||||
destacado: pais == destacados.first,
|
||||
onTap: () => _seleccionar(pais),
|
||||
),
|
||||
],
|
||||
),
|
||||
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)
|
||||
_FilaPais(
|
||||
pais: pais,
|
||||
l10n: l10n,
|
||||
// Item 24 / audit 5.5 (t4:256): the first row is
|
||||
// highlighted.
|
||||
destacado: pais == destacados.first,
|
||||
onTap: () => _seleccionar(pais),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -137,26 +208,44 @@ 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,
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
// 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)),
|
||||
],
|
||||
),
|
||||
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),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ import '../widgets/ecualizador_widget.dart';
|
||||
import '../widgets/pluri_glass_surface.dart';
|
||||
import '../widgets/pluri_premium_widgets.dart';
|
||||
import '../widgets/pluri_push_scaffold.dart';
|
||||
import '../widgets/pluri_station_art_fallback.dart';
|
||||
import '../widgets/visualizador_audio.dart';
|
||||
|
||||
/// WU14: restructured onto [PluriPushScaffold] (design ADR-2) — this screen
|
||||
@@ -291,10 +292,10 @@ class _ArteReproductor extends StatelessWidget {
|
||||
imageUrl: emisora.favicon!,
|
||||
fit: BoxFit.cover,
|
||||
placeholder: (_, __) => _shimmer(theme),
|
||||
errorWidget: (_, __, ___) => _iconoFallback(theme),
|
||||
errorWidget: (_, __, ___) => _iconoFallback(),
|
||||
)
|
||||
else
|
||||
_iconoFallback(theme),
|
||||
_iconoFallback(),
|
||||
if (cargando)
|
||||
Container(
|
||||
color: Colors.black45,
|
||||
@@ -333,14 +334,11 @@ class _ArteReproductor extends StatelessWidget {
|
||||
child: Container(color: theme.colorScheme.surfaceContainerHighest),
|
||||
);
|
||||
|
||||
Widget _iconoFallback(ThemeData theme) => Container(
|
||||
color: theme.colorScheme.primaryContainer,
|
||||
child: Icon(
|
||||
Icons.radio_rounded,
|
||||
size: 80,
|
||||
color: theme.colorScheme.onPrimaryContainer,
|
||||
),
|
||||
);
|
||||
// Issue 6 (feedback-pruebas): was a flat `primaryContainer` square with a
|
||||
// bare `radio_rounded` icon — now the same shared fallback every other
|
||||
// surface uses.
|
||||
Widget _iconoFallback() =>
|
||||
PluriStationArtFallback(seed: emisora.uuid, iconSize: 80);
|
||||
}
|
||||
|
||||
/// Audit 2.2 (t4 lines 108-109): a full-bleed blurred backdrop of the
|
||||
|
||||
@@ -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,32 +72,222 @@ 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),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _abrirAlta(BuildContext context) async {
|
||||
await showModalBottomSheet<void>(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
useSafeArea: true,
|
||||
backgroundColor: Colors.transparent,
|
||||
builder: (_) => const _EditorVacacionesSheet(),
|
||||
Future<void> _abrirAlta(BuildContext context) =>
|
||||
_abrirEditorVacaciones(context);
|
||||
}
|
||||
|
||||
/// Issue 1 (feedback-pruebas): the ONE sheet-opener both the header/CTA
|
||||
/// "create" entry points and every range's own "tap to edit" affordance call
|
||||
/// -- passing [rango] switches the sheet from create to edit mode (mirrors
|
||||
/// `pantalla_alarmas.dart`'s `_abrirEditor`/`_EditorAlarmaSheet` split).
|
||||
Future<void> _abrirEditorVacaciones(
|
||||
BuildContext context, {
|
||||
RangoVacaciones? rango,
|
||||
}) async {
|
||||
await showModalBottomSheet<void>(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
useSafeArea: true,
|
||||
backgroundColor: Colors.transparent,
|
||||
builder: (_) => _EditorVacacionesSheet(rango: rango),
|
||||
);
|
||||
}
|
||||
|
||||
/// Issue 1 (feedback-pruebas): mirrors `pantalla_alarmas.dart`'s
|
||||
/// `_confirmarEliminarAlarma` exactly -- same AlertDialog shape, same
|
||||
/// generic delete/cancel actions, only the copy is vacation-specific.
|
||||
Future<bool> _confirmarEliminarRango(
|
||||
BuildContext context,
|
||||
AppLocalizations l10n,
|
||||
) async {
|
||||
final confirmado = await showDialog<bool>(
|
||||
context: context,
|
||||
builder:
|
||||
(ctx) => AlertDialog(
|
||||
title: Text(l10n.vacationDeleteConfirmTitle),
|
||||
content: Text(l10n.vacationDeleteConfirmMessage),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx, false),
|
||||
child: Text(l10n.cancelAction),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.pop(ctx, true),
|
||||
child: Text(l10n.deleteAction),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
return confirmado ?? false;
|
||||
}
|
||||
|
||||
/// Swipe-to-delete reveal shown on both sides, mirroring
|
||||
/// `pantalla_alarmas.dart`'s `_FondoSwipeEliminarAlarma` -- duplicated
|
||||
/// rather than shared, matching this codebase's own precedent for tiny
|
||||
/// per-screen chrome (see this file's `_DashedBorderPainter` doc comment).
|
||||
class _FondoSwipeEliminarRango extends StatelessWidget {
|
||||
const _FondoSwipeEliminarRango({required this.alignment});
|
||||
|
||||
final Alignment alignment;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tokens = context.pluriTokens;
|
||||
return Container(
|
||||
alignment: alignment,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.error,
|
||||
borderRadius: BorderRadius.circular(tokens.radiusMd),
|
||||
),
|
||||
child: Icon(
|
||||
Icons.delete_outline_rounded,
|
||||
color: Theme.of(context).colorScheme.onError,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 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
|
||||
@@ -86,46 +307,81 @@ class _HeroRangoActivo extends StatelessWidget {
|
||||
final diasRestantes = rango.finDia.difference(hoyDia).inDays;
|
||||
final impacto = estado.impactoDeRango(rango);
|
||||
final type = context.pluriType;
|
||||
final tokens = context.pluriTokens;
|
||||
|
||||
return PluriGlassSurface(
|
||||
glowColor: context.pluriTokens.electricMagenta.withValues(alpha: 0.24),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
localizedVacationName(l10n, rango.nombre),
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.w900),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
// Item 21 / audit 9b.4 (t4:454): the "active now" caption is a
|
||||
// teal eyebrow, not default body text.
|
||||
Text(
|
||||
l10n.vacationSummaryActiveCountdown(diasRestantes),
|
||||
style: type.eyebrowLabel.copyWith(color: PluriWaveTokens.brand),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
// Item 21 / audit 9b.4 (t4:451-462): the screen's signature
|
||||
// element is a start/end date pair joined by a gradient rule —
|
||||
// the prototype never draws a determinate progress bar here.
|
||||
_ParFechasVacaciones(
|
||||
inicio: rango.inicioDia,
|
||||
fin: rango.finDia,
|
||||
destacado: true,
|
||||
reglaKey: const ValueKey('vacaciones-regla-activo'),
|
||||
),
|
||||
if (impacto.pausadas.isNotEmpty) ...[
|
||||
const SizedBox(height: 12),
|
||||
Text(l10n.vacationImpactPausedLabel(_horas(impacto.pausadas))),
|
||||
],
|
||||
if (impacto.noAfectadas.isNotEmpty) ...[
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
l10n.vacationImpactContinuesLabel(_horas(impacto.noAfectadas)),
|
||||
// Issue 1 (feedback-pruebas): a range starts ACTIVE the instant it's
|
||||
// created (today .. today+2), so this hero is the ONLY place a
|
||||
// brand-new range ever renders until it either becomes "programado" in
|
||||
// the future or "pasado" once it ends. Without tap/swipe here, the
|
||||
// very first range a user creates could never be fixed or removed.
|
||||
return Dismissible(
|
||||
key: ValueKey('vacaciones-tarjeta-${rango.id}'),
|
||||
direction: DismissDirection.horizontal,
|
||||
background: const _FondoSwipeEliminarRango(
|
||||
alignment: Alignment.centerLeft,
|
||||
),
|
||||
secondaryBackground: const _FondoSwipeEliminarRango(
|
||||
alignment: Alignment.centerRight,
|
||||
),
|
||||
confirmDismiss: (_) => _confirmarEliminarRango(context, l10n),
|
||||
onDismissed: (_) => estado.eliminarRangoVacaciones(rango.id),
|
||||
child: PluriGlassSurface(
|
||||
glowColor: tokens.electricMagenta.withValues(alpha: 0.24),
|
||||
padding: EdgeInsets.zero,
|
||||
child: Material(
|
||||
type: MaterialType.transparency,
|
||||
child: InkWell(
|
||||
borderRadius: BorderRadius.circular(tokens.radiusMd),
|
||||
onTap: () => _abrirEditorVacaciones(context, rango: rango),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
localizedVacationName(l10n, rango.nombre),
|
||||
style: Theme.of(context).textTheme.titleLarge?.copyWith(
|
||||
fontWeight: FontWeight.w900,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
// Item 21 / audit 9b.4 (t4:454): the "active now" caption is a
|
||||
// teal eyebrow, not default body text.
|
||||
Text(
|
||||
l10n.vacationSummaryActiveCountdown(diasRestantes),
|
||||
style: type.eyebrowLabel.copyWith(
|
||||
color: PluriWaveTokens.brand,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
// Item 21 / audit 9b.4 (t4:451-462): the screen's signature
|
||||
// element is a start/end date pair joined by a gradient rule —
|
||||
// the prototype never draws a determinate progress bar here.
|
||||
_ParFechasVacaciones(
|
||||
inicio: rango.inicioDia,
|
||||
fin: rango.finDia,
|
||||
destacado: true,
|
||||
reglaKey: const ValueKey('vacaciones-regla-activo'),
|
||||
),
|
||||
if (impacto.pausadas.isNotEmpty) ...[
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
l10n.vacationImpactPausedLabel(_horas(impacto.pausadas)),
|
||||
),
|
||||
],
|
||||
if (impacto.noAfectadas.isNotEmpty) ...[
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
l10n.vacationImpactContinuesLabel(
|
||||
_horas(impacto.noAfectadas),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -184,31 +440,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),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
@@ -235,61 +552,87 @@ class _TarjetaRangoVacaciones extends StatelessWidget {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final t = context.pluriTokens;
|
||||
final type = context.pluriType;
|
||||
return DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
color: t.listSurface,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
border: Border.all(color: Colors.white.withValues(alpha: 0.08)),
|
||||
final estado = context.read<EstadoAlarmas>();
|
||||
// Issue 1 (feedback-pruebas): tap = edit, swipe = delete (with
|
||||
// confirmation) — same interaction `pantalla_alarmas.dart`'s
|
||||
// `_TarjetaAlarma` already uses for the same concept, applied here to
|
||||
// BOTH the "programados" and "pasados" sections (this card backs both).
|
||||
return Dismissible(
|
||||
key: ValueKey('vacaciones-tarjeta-${rango.id}'),
|
||||
direction: DismissDirection.horizontal,
|
||||
background: const _FondoSwipeEliminarRango(
|
||||
alignment: Alignment.centerLeft,
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 15),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (encabezado != null) ...[
|
||||
Text(
|
||||
encabezado!,
|
||||
style: type.eyebrowLabel.copyWith(
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.onSurface.withValues(alpha: 0.5),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 11),
|
||||
],
|
||||
_ParFechasVacaciones(
|
||||
inicio: rango.inicioDia,
|
||||
fin: rango.finDia,
|
||||
destacado: false,
|
||||
reglaKey: ValueKey('vacaciones-regla-${rango.id}'),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.label_outline_rounded,
|
||||
size: 17,
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.onSurface.withValues(alpha: 0.6),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
localizedVacationName(l10n, rango.nombre),
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.onSurface.withValues(alpha: 0.6),
|
||||
secondaryBackground: const _FondoSwipeEliminarRango(
|
||||
alignment: Alignment.centerRight,
|
||||
),
|
||||
confirmDismiss: (_) => _confirmarEliminarRango(context, l10n),
|
||||
onDismissed: (_) => estado.eliminarRangoVacaciones(rango.id),
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
color: t.listSurface,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
border: Border.all(color: Colors.white.withValues(alpha: 0.08)),
|
||||
),
|
||||
child: Material(
|
||||
type: MaterialType.transparency,
|
||||
child: InkWell(
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
onTap: () => _abrirEditorVacaciones(context, rango: rango),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 15),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (encabezado != null) ...[
|
||||
Text(
|
||||
encabezado!,
|
||||
style: type.eyebrowLabel.copyWith(
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.onSurface.withValues(alpha: 0.5),
|
||||
),
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
const SizedBox(height: 11),
|
||||
],
|
||||
_ParFechasVacaciones(
|
||||
inicio: rango.inicioDia,
|
||||
fin: rango.finDia,
|
||||
destacado: false,
|
||||
reglaKey: ValueKey('vacaciones-regla-${rango.id}'),
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.label_outline_rounded,
|
||||
size: 17,
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.onSurface.withValues(alpha: 0.6),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
localizedVacationName(l10n, rango.nombre),
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.bodyMedium?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.onSurface.withValues(alpha: 0.6),
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -392,11 +735,17 @@ class _BloqueFecha extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
/// Add-range form. Moved verbatim from `pantalla_alarmas.dart` (WU8's
|
||||
/// Add/edit-range form. Moved verbatim from `pantalla_alarmas.dart` (WU8's
|
||||
/// `_PantallaVacacionesTemporal` used it as a placeholder push target; now
|
||||
/// this screen is the one real consumer). Behaviour unchanged.
|
||||
/// this screen is the one real consumer). Create behaviour unchanged; issue
|
||||
/// 1 (feedback-pruebas) adds the edit half via the optional [rango] — the
|
||||
/// SAME sheet, mirroring `pantalla_alarmas.dart`'s `_EditorAlarmaSheet`
|
||||
/// (`alarma == null` -> create, non-null -> edit; one shared save button
|
||||
/// either way).
|
||||
class _EditorVacacionesSheet extends StatefulWidget {
|
||||
const _EditorVacacionesSheet();
|
||||
const _EditorVacacionesSheet({this.rango});
|
||||
|
||||
final RangoVacaciones? rango;
|
||||
|
||||
@override
|
||||
State<_EditorVacacionesSheet> createState() => _EditorVacacionesSheetState();
|
||||
@@ -412,16 +761,29 @@ class _EditorVacacionesSheetState extends State<_EditorVacacionesSheet> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
final hoy = DateTime.now();
|
||||
_inicio = DateTime(hoy.year, hoy.month, hoy.day);
|
||||
_fin = _inicio.add(const Duration(days: 2));
|
||||
final rango = widget.rango;
|
||||
if (rango != null) {
|
||||
_inicio = rango.inicioDia;
|
||||
_fin = rango.finDia;
|
||||
} else {
|
||||
final hoy = DateTime.now();
|
||||
_inicio = DateTime(hoy.year, hoy.month, hoy.day);
|
||||
_fin = _inicio.add(const Duration(days: 2));
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
final rango = widget.rango;
|
||||
_nombreController ??= TextEditingController(
|
||||
text: AppLocalizations.of(context).vacationsDefaultName,
|
||||
text:
|
||||
rango != null
|
||||
? localizedVacationName(
|
||||
AppLocalizations.of(context),
|
||||
rango.nombre,
|
||||
)
|
||||
: AppLocalizations.of(context).vacationsDefaultName,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -445,7 +807,9 @@ class _EditorVacacionesSheetState extends State<_EditorVacacionesSheet> {
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
l10n.newVacationRangeTitle,
|
||||
widget.rango != null
|
||||
? l10n.editVacationRangeTitle
|
||||
: l10n.newVacationRangeTitle,
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.w900),
|
||||
@@ -492,10 +856,16 @@ class _EditorVacacionesSheetState extends State<_EditorVacacionesSheet> {
|
||||
Future<void> _elegirFecha({required bool esInicio}) async {
|
||||
final actual = esInicio ? _inicio : _fin;
|
||||
final hoy = DateTime.now();
|
||||
final hoyDia = DateTime(hoy.year, hoy.month, hoy.day);
|
||||
// Issue 1 (feedback-pruebas): editing a PAST range (reachable from the
|
||||
// "Rangos pasados" section) must not force its dates into the future —
|
||||
// `firstDate` only floors at today for a range that starts there or
|
||||
// later; an already-past range keeps its own start as the floor.
|
||||
final primerDiaPermitido = _inicio.isBefore(hoyDia) ? _inicio : hoyDia;
|
||||
final seleccion = await showDatePicker(
|
||||
context: context,
|
||||
initialDate: actual,
|
||||
firstDate: DateTime(hoy.year, hoy.month, hoy.day),
|
||||
firstDate: primerDiaPermitido,
|
||||
lastDate: hoy.add(const Duration(days: 1460)),
|
||||
);
|
||||
if (seleccion == null) return;
|
||||
@@ -511,12 +881,26 @@ class _EditorVacacionesSheetState extends State<_EditorVacacionesSheet> {
|
||||
|
||||
Future<void> _guardar() async {
|
||||
final estado = context.read<EstadoAlarmas>();
|
||||
final rango = estado.servicio.crearRangoVacaciones(
|
||||
inicio: _inicio,
|
||||
fin: _fin,
|
||||
nombre: _nombreController?.text.trim() ?? '',
|
||||
);
|
||||
await estado.crearRangoVacaciones(rango);
|
||||
final nombre = _nombreController?.text.trim() ?? '';
|
||||
final existente = widget.rango;
|
||||
if (existente != null) {
|
||||
await estado.editarRangoVacaciones(
|
||||
RangoVacaciones(
|
||||
id: existente.id,
|
||||
nombre: nombre,
|
||||
inicio: _inicio,
|
||||
fin: _fin,
|
||||
activo: existente.activo,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
final rango = estado.servicio.crearRangoVacaciones(
|
||||
inicio: _inicio,
|
||||
fin: _fin,
|
||||
nombre: nombre,
|
||||
);
|
||||
await estado.crearRangoVacaciones(rango);
|
||||
}
|
||||
if (mounted) Navigator.pop(context);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -8,6 +8,7 @@ import '../l10n/display_names.dart';
|
||||
import '../l10n/gen/app_localizations.dart';
|
||||
import '../modelos/emisora.dart';
|
||||
import '../tema/pluriwave_tokens.dart';
|
||||
import 'pluri_station_art_fallback.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
|
||||
@@ -234,10 +235,10 @@ class _ArteFilaEmisora extends StatelessWidget {
|
||||
imageUrl: emisora.favicon!,
|
||||
fit: BoxFit.cover,
|
||||
placeholder: (_, __) => _shimmer(theme),
|
||||
errorWidget: (_, __, ___) => _iconoFallback(theme),
|
||||
errorWidget: (_, __, ___) => _iconoFallback(),
|
||||
);
|
||||
}
|
||||
return _iconoFallback(theme);
|
||||
return _iconoFallback();
|
||||
}
|
||||
|
||||
Widget _shimmer(ThemeData theme) => shimmer.Shimmer.fromColors(
|
||||
@@ -246,12 +247,9 @@ class _ArteFilaEmisora extends StatelessWidget {
|
||||
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,
|
||||
),
|
||||
);
|
||||
// Issue 6 (feedback-pruebas): was a flat `primaryContainer` square with a
|
||||
// bare `radio_rounded` icon — now the same shared fallback every other
|
||||
// surface uses.
|
||||
Widget _iconoFallback() =>
|
||||
PluriStationArtFallback(seed: emisora.uuid, iconSize: 22);
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import '../modelos/emisora.dart';
|
||||
import '../pantallas/pantalla_reproductor.dart';
|
||||
import '../servicios/servicio_audio.dart';
|
||||
import '../tema/pluriwave_theme.dart';
|
||||
import 'pluri_station_art_fallback.dart';
|
||||
|
||||
/// Barra inferior persistente con controles básicos de reproducción.
|
||||
/// Toca la barra para abrir PantallaReproductor completa.
|
||||
@@ -318,9 +319,9 @@ class _ArteMiniReproductor extends StatelessWidget {
|
||||
imageUrl: emisora.favicon!,
|
||||
fit: BoxFit.cover,
|
||||
placeholder: (_, __) => _shimmer(theme),
|
||||
errorWidget: (_, __, ___) => _iconoFallback(theme),
|
||||
errorWidget: (_, __, ___) => _iconoFallback(),
|
||||
)
|
||||
: _iconoFallback(theme),
|
||||
: _iconoFallback(),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -331,12 +332,9 @@ class _ArteMiniReproductor extends StatelessWidget {
|
||||
child: Container(color: theme.colorScheme.surfaceContainerHighest),
|
||||
);
|
||||
|
||||
Widget _iconoFallback(ThemeData theme) => Container(
|
||||
color: theme.colorScheme.primaryContainer,
|
||||
child: Icon(
|
||||
Icons.radio_rounded,
|
||||
size: 20,
|
||||
color: theme.colorScheme.onPrimaryContainer,
|
||||
),
|
||||
);
|
||||
// Issue 6 (feedback-pruebas): was a flat `primaryContainer` square with a
|
||||
// bare `radio_rounded` icon — now the same shared fallback every other
|
||||
// surface uses.
|
||||
Widget _iconoFallback() =>
|
||||
PluriStationArtFallback(seed: emisora.uuid, iconSize: 20);
|
||||
}
|
||||
|
||||
@@ -192,7 +192,10 @@ class PluriBottomNavigation extends StatelessWidget {
|
||||
/// the balloon's opaque fill already covers the seam where the bar's own
|
||||
/// shadow would otherwise show through.
|
||||
List<BoxShadow> get _shellShadows => [
|
||||
BoxShadow(color: Colors.white.withValues(alpha: 0.17), offset: const Offset(0, -1.5)),
|
||||
BoxShadow(
|
||||
color: Colors.white.withValues(alpha: 0.17),
|
||||
offset: const Offset(0, -1.5),
|
||||
),
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: 0.5),
|
||||
offset: const Offset(0, 14),
|
||||
@@ -214,6 +217,7 @@ class _PluriNavButton extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final t = context.pluriTokens;
|
||||
final motion = context.pluriMotion;
|
||||
return Semantics(
|
||||
button: true,
|
||||
selected: selected,
|
||||
@@ -222,6 +226,11 @@ class _PluriNavButton extends StatelessWidget {
|
||||
type: MaterialType.transparency,
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
// Without a shape the ink splash and hover highlight paint as a
|
||||
// full-bleed RECTANGLE over the cell — a hard square sitting on
|
||||
// top of the icon, which is what users reported. The whole bar is
|
||||
// built on 999-radius pills; the splash has to follow.
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
child: Align(
|
||||
alignment: Alignment.bottomCenter,
|
||||
// t4/4a spec: the items row itself is 52px tall — this inner
|
||||
@@ -238,54 +247,75 @@ class _PluriNavButton extends StatelessWidget {
|
||||
// Flutter joins merged labels with `\n`, so screen readers
|
||||
// would announce "Alarmas\nAlarmas" instead of "Alarmas".
|
||||
child: ExcludeSemantics(
|
||||
child: Transform.translate(
|
||||
// t4/4a spec: active item lift `translateY(-15px)`.
|
||||
offset: Offset(0, selected ? -15 : 0),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Opacity(
|
||||
// t4/4a spec: active icon full colour; inactive
|
||||
// `rgba(242,247,250,.46)` — .46 applied here as
|
||||
// uniform opacity dims both the fallback Icon
|
||||
// (already `onSurface` from
|
||||
// PluriIconVariant.filled) and the real raster
|
||||
// badge asset identically.
|
||||
opacity: selected ? 1 : 0.46,
|
||||
child: PluriIcon(
|
||||
glyph: item.glyph,
|
||||
variant: PluriIconVariant.filled,
|
||||
// t4/4a spec: icon `font-size:25px`/`23px`.
|
||||
size: selected ? 25 : 23,
|
||||
color: selected ? t.electricMagenta : null,
|
||||
// Same ARB string the outer Semantics already
|
||||
// uses — passing it explicitly skips
|
||||
// PluriIcon's own AppLocalizations.of lookup
|
||||
// (excluded from the tree above regardless).
|
||||
semanticLabel: item.label,
|
||||
),
|
||||
),
|
||||
if (selected) ...[
|
||||
const SizedBox(height: 3),
|
||||
Text(
|
||||
item.label,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
// t4/4a spec: label `font-size:11px;
|
||||
// font-weight:800; line-height:1.25`, brand
|
||||
// colour.
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.labelSmall?.copyWith(
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w800,
|
||||
height: 1.25,
|
||||
letterSpacing: 0,
|
||||
color: t.electricMagenta,
|
||||
// t4/4a spec: active item lift `translateY(-15px)`. Every
|
||||
// property here used to change INSTANTLY while the balloon
|
||||
// behind it slid with AnimatedPositioned — the balloon
|
||||
// glided and its contents teleported, which read as a
|
||||
// broken transition. All four now share the balloon's own
|
||||
// duration and curve so the whole tab moves as one.
|
||||
child: AnimatedSlide(
|
||||
duration: motion.normal,
|
||||
curve: Curves.easeOutCubic,
|
||||
// Slide is expressed in fractions of the child's size;
|
||||
// the icon column is ~40 tall, so -15px is about -0.375.
|
||||
offset: Offset(0, selected ? -0.375 : 0),
|
||||
child: AnimatedSize(
|
||||
duration: motion.normal,
|
||||
curve: Curves.easeOutCubic,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
AnimatedOpacity(
|
||||
duration: motion.normal,
|
||||
curve: Curves.easeOutCubic,
|
||||
// t4/4a spec: active icon full colour; inactive
|
||||
// `rgba(242,247,250,.46)` — .46 applied here as
|
||||
// uniform opacity dims both the fallback Icon
|
||||
// (already `onSurface` from
|
||||
// PluriIconVariant.filled) and the real raster
|
||||
// badge asset identically.
|
||||
opacity: selected ? 1 : 0.46,
|
||||
child: TweenAnimationBuilder<double>(
|
||||
duration: motion.normal,
|
||||
curve: Curves.easeOutCubic,
|
||||
tween: Tween<double>(end: selected ? 25 : 23),
|
||||
builder:
|
||||
(context, size, _) => PluriIcon(
|
||||
glyph: item.glyph,
|
||||
variant: PluriIconVariant.filled,
|
||||
// t4/4a spec: `font-size:25px`/`23px`.
|
||||
size: size,
|
||||
color: selected ? t.electricMagenta : null,
|
||||
// Same ARB string the outer Semantics
|
||||
// already uses — passing it explicitly
|
||||
// skips PluriIcon's own
|
||||
// AppLocalizations.of lookup.
|
||||
semanticLabel: item.label,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (selected) ...[
|
||||
const SizedBox(height: 3),
|
||||
Text(
|
||||
item.label,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
// t4/4a spec: label `font-size:11px;
|
||||
// font-weight:800; line-height:1.25`, brand
|
||||
// colour.
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.labelSmall?.copyWith(
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w800,
|
||||
height: 1.25,
|
||||
letterSpacing: 0,
|
||||
color: t.electricMagenta,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -14,6 +14,13 @@ import 'pluri_layout.dart';
|
||||
void showPluriSleepTimerSheet(BuildContext context) {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
// Issue 2 (feedback-pruebas): without this, the sheet is capped to a
|
||||
// FRACTION of the screen height. That never mattered while the sheet
|
||||
// always closed immediately after picking a duration (see the removed
|
||||
// `Navigator.pop` calls below) -- now that the countdown view actually
|
||||
// stays open, its title + description + headline-sized remaining-time
|
||||
// text can overflow that capped height on a real phone width.
|
||||
isScrollControlled: true,
|
||||
showDragHandle: true,
|
||||
builder:
|
||||
(ctx) => Consumer<EstadoRadio>(
|
||||
@@ -79,12 +86,17 @@ void showPluriSleepTimerSheet(BuildContext context) {
|
||||
Duration(seconds: segundos),
|
||||
),
|
||||
),
|
||||
onPressed: () {
|
||||
estado.iniciarTimerDuracion(
|
||||
Duration(seconds: segundos),
|
||||
);
|
||||
Navigator.pop(ctx);
|
||||
},
|
||||
// Issue 2 (feedback-pruebas): no longer pops
|
||||
// the sheet -- `estado.iniciarTimerDuracion`
|
||||
// notifies this `Consumer<EstadoRadio>`,
|
||||
// which swaps straight to the countdown
|
||||
// view above so the user actually SEES the
|
||||
// remaining time instead of the sheet just
|
||||
// closing with no feedback.
|
||||
onPressed:
|
||||
() => estado.iniciarTimerDuracion(
|
||||
Duration(seconds: segundos),
|
||||
),
|
||||
),
|
||||
ActionChip(
|
||||
avatar: const Icon(Icons.tune_rounded, size: 18),
|
||||
@@ -93,8 +105,9 @@ void showPluriSleepTimerSheet(BuildContext context) {
|
||||
final duracion =
|
||||
await _pedirDuracionPersonalizada(ctx);
|
||||
if (duracion == null || !ctx.mounted) return;
|
||||
// Issue 2: same as above -- stays open on
|
||||
// the countdown view rather than closing.
|
||||
estado.iniciarTimerDuracion(duracion);
|
||||
Navigator.pop(ctx);
|
||||
},
|
||||
),
|
||||
],
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../l10n/gen/app_localizations.dart';
|
||||
import '../tema/pluriwave_theme.dart';
|
||||
import 'pluri_icon.dart';
|
||||
|
||||
/// Shared station-art fallback (feedback-pruebas issue 6).
|
||||
///
|
||||
/// Before this widget existed, `TarjetaEmisora` had the only good fallback —
|
||||
/// a deterministic pick from 4 bundled illustrations (`_fallbackArtFor`),
|
||||
/// with a gradient + player glyph as a last resort if the asset itself ever
|
||||
/// fails to decode. Every OTHER surface that can render a station without
|
||||
/// artwork (`FilaEmisoraPlana`'s flat rows, the Escuchar hero, the "Tus
|
||||
/// emisoras" grid cell, the mini player, the full player) had its own,
|
||||
/// separate, much poorer copy: a flat `primaryContainer`-coloured square
|
||||
/// with a bare `radio_rounded` icon. This widget is the ONE shared
|
||||
/// implementation every one of those call sites now uses instead.
|
||||
///
|
||||
/// The selection formula is pinned EXACTLY as `TarjetaEmisora` originally
|
||||
/// had it (`seed.codeUnits.fold<int>(0, (a, b) => a + b) % 4`, asset order
|
||||
/// aurora/cosmic/pulse/nova) — `lib/servicios/navegacion_auto.dart` (a
|
||||
/// protected, empty-diff file) independently mirrors this SAME formula for
|
||||
/// Android Auto's own drawable-resource rotation
|
||||
/// (`test/servicios/navegacion_auto_test.dart`'s `indiceArtePara`/
|
||||
/// `artUriPara` tests assert it inline, not by importing this widget) —
|
||||
/// changing the order or the modulo here would silently desync the two
|
||||
/// without either test suite noticing until an actual device compared them
|
||||
/// side by side.
|
||||
class PluriStationArtFallback extends StatelessWidget {
|
||||
const PluriStationArtFallback({
|
||||
super.key,
|
||||
required this.seed,
|
||||
this.iconSize = 22,
|
||||
});
|
||||
|
||||
/// Typically the station's `uuid` — the seed that deterministically picks
|
||||
/// one of the 4 bundled arts below.
|
||||
final String seed;
|
||||
|
||||
/// Size of the centred player glyph drawn over the art.
|
||||
final double iconSize;
|
||||
|
||||
static const _arts = [
|
||||
'assets/images/station_art_aurora.png',
|
||||
'assets/images/station_art_cosmic.png',
|
||||
'assets/images/station_art_pulse.png',
|
||||
'assets/images/station_art_nova.png',
|
||||
];
|
||||
|
||||
/// Exposed so other call sites (and tests) can assert which asset a given
|
||||
/// seed resolves to without needing to render the widget.
|
||||
static String artFor(String seed) {
|
||||
final index = seed.codeUnits.fold<int>(0, (a, b) => a + b) % _arts.length;
|
||||
return _arts[index];
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
Image.asset(
|
||||
artFor(seed),
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder:
|
||||
(_, __, ___) => DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
colors: [
|
||||
context.pluriTokens.deepViolet,
|
||||
context.pluriTokens.electricMagenta.withValues(
|
||||
alpha: 0.8,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Center(
|
||||
child: PluriIcon(
|
||||
glyph: PluriIconGlyph.player,
|
||||
variant: PluriIconVariant.activeGlow,
|
||||
size: iconSize,
|
||||
semanticLabel: AppLocalizations.of(context).stationIconLabel,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import '../modelos/emisora.dart';
|
||||
import '../tema/pluriwave_theme.dart';
|
||||
import 'pluri_glass_surface.dart';
|
||||
import 'pluri_icon.dart';
|
||||
import 'pluri_station_art_fallback.dart';
|
||||
|
||||
/// Tarjeta compacta para mostrar una emisora en listas y grids.
|
||||
/// Incluye botón de favorito visible en ambos modos.
|
||||
@@ -302,51 +303,12 @@ class _TarjetaEmisoraState extends State<TarjetaEmisora> {
|
||||
);
|
||||
}
|
||||
|
||||
// Issue 6 (feedback-pruebas): extracted into the shared
|
||||
// `PluriStationArtFallback` so every surface that can render a station
|
||||
// without artwork shows the SAME fallback — see that widget's own doc
|
||||
// comment for why the selection formula must never drift.
|
||||
Widget _iconoFallback(double size) {
|
||||
final art = _fallbackArtFor(widget.emisora.uuid);
|
||||
return Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
Image.asset(
|
||||
art,
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder:
|
||||
(_, __, ___) => DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
colors: [
|
||||
context.pluriTokens.deepViolet,
|
||||
context.pluriTokens.electricMagenta.withValues(
|
||||
alpha: 0.8,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Center(
|
||||
child: PluriIcon(
|
||||
glyph: PluriIconGlyph.player,
|
||||
variant: PluriIconVariant.activeGlow,
|
||||
size: size,
|
||||
semanticLabel: AppLocalizations.of(context).stationIconLabel,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
String _fallbackArtFor(String seed) {
|
||||
const arts = [
|
||||
'assets/images/station_art_aurora.png',
|
||||
'assets/images/station_art_cosmic.png',
|
||||
'assets/images/station_art_pulse.png',
|
||||
'assets/images/station_art_nova.png',
|
||||
];
|
||||
final index = seed.codeUnits.fold<int>(0, (a, b) => a + b) % arts.length;
|
||||
return arts[index];
|
||||
return PluriStationArtFallback(seed: widget.emisora.uuid, iconSize: size);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
name: pluriwave
|
||||
description: "Radio mundial con ecualizador, reconocimiento de canciones y UI premium"
|
||||
publish_to: 'none'
|
||||
version: 1.2.0+122
|
||||
version: 1.2.1+123
|
||||
|
||||
environment:
|
||||
sdk: ^3.7.0
|
||||
|
||||
+284
-229
@@ -412,259 +412,314 @@ void main() {
|
||||
);
|
||||
});
|
||||
|
||||
group(
|
||||
'EstadoRadio — emisoras custom: lectura tolerante y guardia de '
|
||||
'degradacion (persistence-resilience)',
|
||||
() {
|
||||
test(
|
||||
'entradas invalidas se omiten sin perder las validas ni fabricar '
|
||||
'uuid (D5 parcial)',
|
||||
() async {
|
||||
final archivo = await _crearArchivoCustomRaw(
|
||||
jsonEncode([
|
||||
{'uuid': 'custom-1', 'nombre': 'Valida Uno', 'url': 'http://a'},
|
||||
{'uuid': 'custom-2', 'nombre': 'Valida Dos', 'url': 'http://b'},
|
||||
// falta 'url' (campo requerido) -> Emisora.fromMap lanza.
|
||||
{'uuid': 'custom-3', 'nombre': 'Sin url'},
|
||||
// falta 'uuid' -> Emisora.fromMap lanza.
|
||||
{'nombre': 'Sin uuid', 'url': 'http://d'},
|
||||
]),
|
||||
);
|
||||
final estado = EstadoRadio(
|
||||
audio: FakeServicioAudio(),
|
||||
favoritos: FakeServicioFavoritos(),
|
||||
radio: FakeServicioRadio(),
|
||||
servicioEcualizador: FakeServicioEcualizador(),
|
||||
resolverArchivoCustom: () async => archivo,
|
||||
iniciarAutomaticamente: false,
|
||||
);
|
||||
|
||||
await estado.inicializar();
|
||||
|
||||
expect(estado.emisorasCustom, hasLength(2));
|
||||
expect(estado.emisorasCustom.map((e) => e.uuid).toSet(), {
|
||||
'custom-1',
|
||||
'custom-2',
|
||||
});
|
||||
},
|
||||
group('EstadoRadio — emisoras custom: lectura tolerante y guardia de '
|
||||
'degradacion (persistence-resilience)', () {
|
||||
test('entradas invalidas se omiten sin perder las validas ni fabricar '
|
||||
'uuid (D5 parcial)', () async {
|
||||
final archivo = await _crearArchivoCustomRaw(
|
||||
jsonEncode([
|
||||
{'uuid': 'custom-1', 'nombre': 'Valida Uno', 'url': 'http://a'},
|
||||
{'uuid': 'custom-2', 'nombre': 'Valida Dos', 'url': 'http://b'},
|
||||
// falta 'url' (campo requerido) -> Emisora.fromMap lanza.
|
||||
{'uuid': 'custom-3', 'nombre': 'Sin url'},
|
||||
// falta 'uuid' -> Emisora.fromMap lanza.
|
||||
{'nombre': 'Sin uuid', 'url': 'http://d'},
|
||||
]),
|
||||
);
|
||||
final estado = EstadoRadio(
|
||||
audio: FakeServicioAudio(),
|
||||
favoritos: FakeServicioFavoritos(),
|
||||
radio: FakeServicioRadio(),
|
||||
servicioEcualizador: FakeServicioEcualizador(),
|
||||
resolverArchivoCustom: () async => archivo,
|
||||
iniciarAutomaticamente: false,
|
||||
);
|
||||
|
||||
test(
|
||||
'si resolver la ruta del archivo custom falla, la inicializacion '
|
||||
'sobrevive y las cargas hermanas no se pierden (D5 IO-fail)',
|
||||
() async {
|
||||
final estado = EstadoRadio(
|
||||
audio: FakeServicioAudio(),
|
||||
favoritos: FakeServicioFavoritos(),
|
||||
radio: FakeServicioRadio(),
|
||||
servicioEcualizador: FakeServicioEcualizador(),
|
||||
resolverArchivoCustom:
|
||||
() async => throw const FileSystemException('sin storage'),
|
||||
iniciarAutomaticamente: false,
|
||||
);
|
||||
await estado.inicializar();
|
||||
|
||||
// Path resolution failing must be treated as an IO-fail, not
|
||||
// escape _cargarEmisorasCustom: it runs inside _init()'s
|
||||
// Future.wait, so an uncaught throw would also reject the
|
||||
// sibling loads (populares/favoritos/grupos).
|
||||
await estado.inicializar();
|
||||
expect(estado.emisorasCustom, hasLength(2));
|
||||
expect(estado.emisorasCustom.map((e) => e.uuid).toSet(), {
|
||||
'custom-1',
|
||||
'custom-2',
|
||||
});
|
||||
});
|
||||
|
||||
expect(estado.emisorasCustom, isEmpty);
|
||||
},
|
||||
test('si resolver la ruta del archivo custom falla, la inicializacion '
|
||||
'sobrevive y las cargas hermanas no se pierden (D5 IO-fail)', () async {
|
||||
final estado = EstadoRadio(
|
||||
audio: FakeServicioAudio(),
|
||||
favoritos: FakeServicioFavoritos(),
|
||||
radio: FakeServicioRadio(),
|
||||
servicioEcualizador: FakeServicioEcualizador(),
|
||||
resolverArchivoCustom:
|
||||
() async => throw const FileSystemException('sin storage'),
|
||||
iniciarAutomaticamente: false,
|
||||
);
|
||||
|
||||
test(
|
||||
'JSON invalido al nivel superior pone en cuarentena el archivo '
|
||||
'original (D5 parse-fail)',
|
||||
() async {
|
||||
final archivo = await _crearArchivoCustomRaw('{bad');
|
||||
final estado = EstadoRadio(
|
||||
audio: FakeServicioAudio(),
|
||||
favoritos: FakeServicioFavoritos(),
|
||||
radio: FakeServicioRadio(),
|
||||
servicioEcualizador: FakeServicioEcualizador(),
|
||||
resolverArchivoCustom: () async => archivo,
|
||||
iniciarAutomaticamente: false,
|
||||
);
|
||||
// Path resolution failing must be treated as an IO-fail, not
|
||||
// escape _cargarEmisorasCustom: it runs inside _init()'s
|
||||
// Future.wait, so an uncaught throw would also reject the
|
||||
// sibling loads (populares/favoritos/grupos).
|
||||
await estado.inicializar();
|
||||
|
||||
await estado.inicializar();
|
||||
expect(estado.emisorasCustom, isEmpty);
|
||||
});
|
||||
|
||||
expect(estado.emisorasCustom, isEmpty);
|
||||
final sidecar = File('${archivo.path}.corrupt');
|
||||
expect(await sidecar.exists(), isTrue);
|
||||
expect(await sidecar.readAsString(), '{bad');
|
||||
expect(await archivo.exists(), isFalse);
|
||||
},
|
||||
test('JSON invalido al nivel superior pone en cuarentena el archivo '
|
||||
'original (D5 parse-fail)', () async {
|
||||
final archivo = await _crearArchivoCustomRaw('{bad');
|
||||
final estado = EstadoRadio(
|
||||
audio: FakeServicioAudio(),
|
||||
favoritos: FakeServicioFavoritos(),
|
||||
radio: FakeServicioRadio(),
|
||||
servicioEcualizador: FakeServicioEcualizador(),
|
||||
resolverArchivoCustom: () async => archivo,
|
||||
iniciarAutomaticamente: false,
|
||||
);
|
||||
|
||||
test(
|
||||
'agregar tras la cuarentena escribe solo la nueva emisora y no '
|
||||
'toca el sidecar (D5, autoridad de escritura restaurada)',
|
||||
() async {
|
||||
final archivo = await _crearArchivoCustomRaw('{bad');
|
||||
final estado = EstadoRadio(
|
||||
audio: FakeServicioAudio(),
|
||||
favoritos: FakeServicioFavoritos(),
|
||||
radio: FakeServicioRadio(),
|
||||
servicioEcualizador: FakeServicioEcualizador(),
|
||||
resolverArchivoCustom: () async => archivo,
|
||||
iniciarAutomaticamente: false,
|
||||
);
|
||||
await estado.inicializar();
|
||||
final sidecar = File('${archivo.path}.corrupt');
|
||||
final sidecarPrevio = await sidecar.readAsString();
|
||||
await estado.inicializar();
|
||||
|
||||
final nueva = emisoraDemo(uuid: 'nueva-1', nombre: 'Nueva');
|
||||
await estado.agregarEmisoraCustom(nueva);
|
||||
expect(estado.emisorasCustom, isEmpty);
|
||||
final sidecar = File('${archivo.path}.corrupt');
|
||||
expect(await sidecar.exists(), isTrue);
|
||||
expect(await sidecar.readAsString(), '{bad');
|
||||
expect(await archivo.exists(), isFalse);
|
||||
});
|
||||
|
||||
expect(estado.emisorasCustom.map((e) => e.uuid), ['nueva-1']);
|
||||
final contenidoVivo =
|
||||
jsonDecode(await archivo.readAsString()) as List;
|
||||
expect(contenidoVivo, hasLength(1));
|
||||
expect((contenidoVivo.single as Map)['uuid'], 'nueva-1');
|
||||
expect(await sidecar.readAsString(), sidecarPrevio);
|
||||
},
|
||||
test('agregar tras la cuarentena escribe solo la nueva emisora y no '
|
||||
'toca el sidecar (D5, autoridad de escritura restaurada)', () async {
|
||||
final archivo = await _crearArchivoCustomRaw('{bad');
|
||||
final estado = EstadoRadio(
|
||||
audio: FakeServicioAudio(),
|
||||
favoritos: FakeServicioFavoritos(),
|
||||
radio: FakeServicioRadio(),
|
||||
servicioEcualizador: FakeServicioEcualizador(),
|
||||
resolverArchivoCustom: () async => archivo,
|
||||
iniciarAutomaticamente: false,
|
||||
);
|
||||
await estado.inicializar();
|
||||
final sidecar = File('${archivo.path}.corrupt');
|
||||
final sidecarPrevio = await sidecar.readAsString();
|
||||
|
||||
final nueva = emisoraDemo(uuid: 'nueva-1', nombre: 'Nueva');
|
||||
await estado.agregarEmisoraCustom(nueva);
|
||||
|
||||
expect(estado.emisorasCustom.map((e) => e.uuid), ['nueva-1']);
|
||||
final contenidoVivo = jsonDecode(await archivo.readAsString()) as List;
|
||||
expect(contenidoVivo, hasLength(1));
|
||||
expect((contenidoVivo.single as Map)['uuid'], 'nueva-1');
|
||||
expect(await sidecar.readAsString(), sidecarPrevio);
|
||||
});
|
||||
|
||||
test('fallo de IO al leer suprime la escritura y no se restaura con un '
|
||||
'alta explicita (D5 IO-fail)', () async {
|
||||
final espia = _ArchivoEspia(
|
||||
path: '/fake/emisoras_custom.json',
|
||||
exists: () async => true,
|
||||
readAsString:
|
||||
() async =>
|
||||
throw const FileSystemException('fallo simulado de lectura'),
|
||||
);
|
||||
final estado = EstadoRadio(
|
||||
audio: FakeServicioAudio(),
|
||||
favoritos: FakeServicioFavoritos(),
|
||||
radio: FakeServicioRadio(),
|
||||
servicioEcualizador: FakeServicioEcualizador(),
|
||||
resolverArchivoCustom: () async => espia,
|
||||
iniciarAutomaticamente: false,
|
||||
);
|
||||
|
||||
test(
|
||||
'fallo de IO al leer suprime la escritura y no se restaura con un '
|
||||
'alta explicita (D5 IO-fail)',
|
||||
() async {
|
||||
final espia = _ArchivoEspia(
|
||||
path: '/fake/emisoras_custom.json',
|
||||
exists: () async => true,
|
||||
readAsString:
|
||||
() async => throw const FileSystemException(
|
||||
'fallo simulado de lectura',
|
||||
),
|
||||
);
|
||||
final estado = EstadoRadio(
|
||||
audio: FakeServicioAudio(),
|
||||
favoritos: FakeServicioFavoritos(),
|
||||
radio: FakeServicioRadio(),
|
||||
servicioEcualizador: FakeServicioEcualizador(),
|
||||
resolverArchivoCustom: () async => espia,
|
||||
iniciarAutomaticamente: false,
|
||||
);
|
||||
await estado.inicializar();
|
||||
expect(estado.emisorasCustom, isEmpty);
|
||||
|
||||
await estado.inicializar();
|
||||
expect(estado.emisorasCustom, isEmpty);
|
||||
|
||||
await estado.agregarEmisoraCustom(
|
||||
emisoraDemo(uuid: 'nueva-x', nombre: 'X'),
|
||||
);
|
||||
|
||||
expect(
|
||||
estado.emisorasCustom.map((e) => e.uuid),
|
||||
contains('nueva-x'),
|
||||
);
|
||||
expect(espia.writeAsStringCalls, 0);
|
||||
},
|
||||
await estado.agregarEmisoraCustom(
|
||||
emisoraDemo(uuid: 'nueva-x', nombre: 'X'),
|
||||
);
|
||||
|
||||
test(
|
||||
'si ya existe un sidecar .corrupt no lo pisa, solo limpia el '
|
||||
'archivo vivo (D5)',
|
||||
() async {
|
||||
final archivo = await _crearArchivoCustomRaw('{bad-nuevo');
|
||||
final sidecar = File('${archivo.path}.corrupt');
|
||||
await sidecar.writeAsString('contenido-previo-X');
|
||||
expect(estado.emisorasCustom.map((e) => e.uuid), contains('nueva-x'));
|
||||
expect(espia.writeAsStringCalls, 0);
|
||||
});
|
||||
|
||||
final estado = EstadoRadio(
|
||||
audio: FakeServicioAudio(),
|
||||
favoritos: FakeServicioFavoritos(),
|
||||
radio: FakeServicioRadio(),
|
||||
servicioEcualizador: FakeServicioEcualizador(),
|
||||
resolverArchivoCustom: () async => archivo,
|
||||
iniciarAutomaticamente: false,
|
||||
);
|
||||
test('si ya existe un sidecar .corrupt no lo pisa, solo limpia el '
|
||||
'archivo vivo (D5)', () async {
|
||||
final archivo = await _crearArchivoCustomRaw('{bad-nuevo');
|
||||
final sidecar = File('${archivo.path}.corrupt');
|
||||
await sidecar.writeAsString('contenido-previo-X');
|
||||
|
||||
await estado.inicializar();
|
||||
|
||||
expect(await sidecar.readAsString(), 'contenido-previo-X');
|
||||
expect(await archivo.exists(), isFalse);
|
||||
expect(estado.emisorasCustom, isEmpty);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
group(
|
||||
'EstadoRadio — Android Auto: snapshot en vivo y reconciliación '
|
||||
'(android-auto-media)',
|
||||
() {
|
||||
test(
|
||||
'empuja un snapshot actualizado a la fuente registrada cuando '
|
||||
'cambian favoritos/custom/populares',
|
||||
() async {
|
||||
final fuenteAuto = _FuenteEmisorasAutoEspia();
|
||||
final archivo = await _crearArchivoCustom([
|
||||
emisoraDemo(uuid: 'custom-auto-1', nombre: 'Custom Auto'),
|
||||
]);
|
||||
final emisoraFav = emisoraDemo(uuid: 'fav-auto-1', nombre: 'Fav Auto');
|
||||
final estado = EstadoRadio(
|
||||
audio: FakeServicioAudio(),
|
||||
favoritos: FakeServicioFavoritos(),
|
||||
radio: FakeServicioRadio(
|
||||
populares: [emisoraDemo(uuid: 'pop-auto-1', nombre: 'Pop Auto')],
|
||||
),
|
||||
servicioEcualizador: FakeServicioEcualizador(),
|
||||
resolverArchivoCustom: () async => archivo,
|
||||
fuenteAuto: fuenteAuto,
|
||||
iniciarAutomaticamente: false,
|
||||
);
|
||||
|
||||
await estado.inicializar();
|
||||
|
||||
expect(
|
||||
fuenteAuto.ultimoMisEmisoras?.map((e) => e.uuid),
|
||||
contains('custom-auto-1'),
|
||||
);
|
||||
expect(
|
||||
fuenteAuto.ultimoTodas?.map((e) => e.uuid),
|
||||
contains('pop-auto-1'),
|
||||
);
|
||||
expect(
|
||||
fuenteAuto.ultimoGrupos?.map((g) => g.id),
|
||||
contains(GrupoFavoritos.sinAsignarId),
|
||||
);
|
||||
|
||||
await estado.toggleFavorito(emisoraFav);
|
||||
|
||||
expect(
|
||||
fuenteAuto.ultimoFavoritos?.map((e) => e.uuid),
|
||||
contains('fav-auto-1'),
|
||||
);
|
||||
},
|
||||
final estado = EstadoRadio(
|
||||
audio: FakeServicioAudio(),
|
||||
favoritos: FakeServicioFavoritos(),
|
||||
radio: FakeServicioRadio(),
|
||||
servicioEcualizador: FakeServicioEcualizador(),
|
||||
resolverArchivoCustom: () async => archivo,
|
||||
iniciarAutomaticamente: false,
|
||||
);
|
||||
|
||||
test(
|
||||
'reconcilia _emisoraSeleccionada cuando la selección viene desde '
|
||||
'el auto (no via reproducir())',
|
||||
() async {
|
||||
final audio = _AudioControlado();
|
||||
final estado = EstadoRadio(
|
||||
audio: audio,
|
||||
favoritos: FakeServicioFavoritos(),
|
||||
radio: FakeServicioRadio(),
|
||||
servicioEcualizador: FakeServicioEcualizador(),
|
||||
resolverArchivoCustom: _archivoCustomVacio,
|
||||
iniciarAutomaticamente: false,
|
||||
);
|
||||
await estado.inicializar();
|
||||
final desdeCoche = emisoraDemo(
|
||||
uuid: 'auto-selected',
|
||||
nombre: 'Desde el auto',
|
||||
);
|
||||
await estado.inicializar();
|
||||
|
||||
audio.seleccionarDesdeAuto(desdeCoche);
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
expect(await sidecar.readAsString(), 'contenido-previo-X');
|
||||
expect(await archivo.exists(), isFalse);
|
||||
expect(estado.emisorasCustom, isEmpty);
|
||||
});
|
||||
});
|
||||
|
||||
expect(estado.emisoraActual?.uuid, desdeCoche.uuid);
|
||||
},
|
||||
group('EstadoRadio — Android Auto: snapshot en vivo y reconciliación '
|
||||
'(android-auto-media)', () {
|
||||
test('empuja un snapshot actualizado a la fuente registrada cuando '
|
||||
'cambian favoritos/custom/populares', () async {
|
||||
final fuenteAuto = _FuenteEmisorasAutoEspia();
|
||||
final archivo = await _crearArchivoCustom([
|
||||
emisoraDemo(uuid: 'custom-auto-1', nombre: 'Custom Auto'),
|
||||
]);
|
||||
final emisoraFav = emisoraDemo(uuid: 'fav-auto-1', nombre: 'Fav Auto');
|
||||
final estado = EstadoRadio(
|
||||
audio: FakeServicioAudio(),
|
||||
favoritos: FakeServicioFavoritos(),
|
||||
radio: FakeServicioRadio(
|
||||
populares: [emisoraDemo(uuid: 'pop-auto-1', nombre: 'Pop Auto')],
|
||||
),
|
||||
servicioEcualizador: FakeServicioEcualizador(),
|
||||
resolverArchivoCustom: () async => archivo,
|
||||
fuenteAuto: fuenteAuto,
|
||||
iniciarAutomaticamente: false,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
await estado.inicializar();
|
||||
|
||||
expect(
|
||||
fuenteAuto.ultimoMisEmisoras?.map((e) => e.uuid),
|
||||
contains('custom-auto-1'),
|
||||
);
|
||||
expect(
|
||||
fuenteAuto.ultimoTodas?.map((e) => e.uuid),
|
||||
contains('pop-auto-1'),
|
||||
);
|
||||
expect(
|
||||
fuenteAuto.ultimoGrupos?.map((g) => g.id),
|
||||
contains(GrupoFavoritos.sinAsignarId),
|
||||
);
|
||||
|
||||
await estado.toggleFavorito(emisoraFav);
|
||||
|
||||
expect(
|
||||
fuenteAuto.ultimoFavoritos?.map((e) => e.uuid),
|
||||
contains('fav-auto-1'),
|
||||
);
|
||||
});
|
||||
|
||||
test('reconcilia _emisoraSeleccionada cuando la selección viene desde '
|
||||
'el auto (no via reproducir())', () async {
|
||||
final audio = _AudioControlado();
|
||||
final estado = EstadoRadio(
|
||||
audio: audio,
|
||||
favoritos: FakeServicioFavoritos(),
|
||||
radio: FakeServicioRadio(),
|
||||
servicioEcualizador: FakeServicioEcualizador(),
|
||||
resolverArchivoCustom: _archivoCustomVacio,
|
||||
iniciarAutomaticamente: false,
|
||||
);
|
||||
await estado.inicializar();
|
||||
final desdeCoche = emisoraDemo(
|
||||
uuid: 'auto-selected',
|
||||
nombre: 'Desde el auto',
|
||||
);
|
||||
|
||||
audio.seleccionarDesdeAuto(desdeCoche);
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
|
||||
expect(estado.emisoraActual?.uuid, desdeCoche.uuid);
|
||||
});
|
||||
});
|
||||
|
||||
group('EstadoRadio — última emisora reproducida (feedback-pruebas #4)', () {
|
||||
test('la última emisora reproducida sobrevive a una nueva instancia '
|
||||
'(reinicio) como emisoraActual DETENIDA -- no arranca audio, no '
|
||||
'reproduce, sólo queda seleccionada', () async {
|
||||
final emisora = emisoraDemo(uuid: 'last-1', nombre: 'Ultima FM');
|
||||
final estadoUno = EstadoRadio(
|
||||
audio: FakeServicioAudio(),
|
||||
favoritos: FakeServicioFavoritos(),
|
||||
radio: FakeServicioRadio(),
|
||||
servicioEcualizador: FakeServicioEcualizador(),
|
||||
resolverArchivoCustom: _archivoCustomVacio,
|
||||
iniciarAutomaticamente: false,
|
||||
);
|
||||
await estadoUno.inicializar();
|
||||
await estadoUno.reproducir(emisora);
|
||||
await estadoUno.detenerReproduccion();
|
||||
// Lets the fire-and-forget persistence write settle before
|
||||
// spinning up the "restart" instance.
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
|
||||
final audioDos = FakeServicioAudio();
|
||||
final estadoDos = EstadoRadio(
|
||||
audio: audioDos,
|
||||
favoritos: FakeServicioFavoritos(),
|
||||
radio: FakeServicioRadio(),
|
||||
servicioEcualizador: FakeServicioEcualizador(),
|
||||
resolverArchivoCustom: _archivoCustomVacio,
|
||||
iniciarAutomaticamente: false,
|
||||
);
|
||||
|
||||
await estadoDos.inicializar();
|
||||
|
||||
expect(estadoDos.emisoraActual?.uuid, emisora.uuid);
|
||||
expect(estadoDos.emisoraActual?.nombre, emisora.nombre);
|
||||
expect(
|
||||
audioDos.estaSonando,
|
||||
isFalse,
|
||||
reason: 'restoring the last station must never auto-start audio',
|
||||
);
|
||||
});
|
||||
|
||||
test('sin ninguna emisora previamente reproducida, emisoraActual sigue '
|
||||
'siendo null tras inicializar (instalación nueva)', () async {
|
||||
final estado = EstadoRadio(
|
||||
audio: FakeServicioAudio(),
|
||||
favoritos: FakeServicioFavoritos(),
|
||||
radio: FakeServicioRadio(),
|
||||
servicioEcualizador: FakeServicioEcualizador(),
|
||||
resolverArchivoCustom: _archivoCustomVacio,
|
||||
iniciarAutomaticamente: false,
|
||||
);
|
||||
|
||||
await estado.inicializar();
|
||||
|
||||
expect(estado.emisoraActual, isNull);
|
||||
});
|
||||
|
||||
test('una emisora seleccionada desde el auto (fuera de reproducir()) '
|
||||
'también se recuerda para la próxima instancia', () async {
|
||||
final audio = _AudioControlado();
|
||||
final estadoUno = EstadoRadio(
|
||||
audio: audio,
|
||||
favoritos: FakeServicioFavoritos(),
|
||||
radio: FakeServicioRadio(),
|
||||
servicioEcualizador: FakeServicioEcualizador(),
|
||||
resolverArchivoCustom: _archivoCustomVacio,
|
||||
iniciarAutomaticamente: false,
|
||||
);
|
||||
await estadoUno.inicializar();
|
||||
final desdeCoche = emisoraDemo(
|
||||
uuid: 'auto-remembered',
|
||||
nombre: 'Recordada desde el auto',
|
||||
);
|
||||
audio.seleccionarDesdeAuto(desdeCoche);
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
|
||||
final estadoDos = EstadoRadio(
|
||||
audio: FakeServicioAudio(),
|
||||
favoritos: FakeServicioFavoritos(),
|
||||
radio: FakeServicioRadio(),
|
||||
servicioEcualizador: FakeServicioEcualizador(),
|
||||
resolverArchivoCustom: _archivoCustomVacio,
|
||||
iniciarAutomaticamente: false,
|
||||
);
|
||||
await estadoDos.inicializar();
|
||||
|
||||
expect(estadoDos.emisoraActual?.uuid, desdeCoche.uuid);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/// Spy [FuenteEmisorasAuto] that only records the last snapshot pushed by
|
||||
|
||||
@@ -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,6 +2,7 @@ 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
|
||||
@@ -97,6 +98,75 @@ void main() {
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets(
|
||||
'issue 5 (feedback-pruebas): a long trailing value does not squeeze the '
|
||||
'title into wrapping across multiple lines -- the title stays on ONE '
|
||||
'line, ellipsizing instead',
|
||||
(tester) async {
|
||||
const titulo = 'Emisora preferida';
|
||||
const valorLargo = 'Radio Nacional Clasica Internacional FM Stereo HD';
|
||||
|
||||
await tester.pumpWidget(
|
||||
host(
|
||||
SizedBox(
|
||||
width: 360,
|
||||
child: FilaAjuste(
|
||||
icon: Icons.radio_rounded,
|
||||
titulo: titulo,
|
||||
valor: valorLargo,
|
||||
onTap: () {},
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pump();
|
||||
|
||||
expect(
|
||||
tester.takeException(),
|
||||
isNull,
|
||||
reason: 'a squeezed row must not overflow either',
|
||||
);
|
||||
|
||||
final tituloWidget = tester.widget<Text>(find.text(titulo));
|
||||
expect(
|
||||
tituloWidget.maxLines,
|
||||
1,
|
||||
reason: 'issue 5: the title must be constrained to a single line',
|
||||
);
|
||||
expect(tituloWidget.overflow, TextOverflow.ellipsis);
|
||||
|
||||
// Measured, not just `find.text` (a wrapped-but-still-present Text
|
||||
// would still satisfy a bare `find.text` match, per the known
|
||||
// "find.text can't catch visual wrap" trap) -- compare the rendered
|
||||
// height against the SAME style/width rendered with a title that is
|
||||
// guaranteed to fit on one line.
|
||||
final alturaConValorLargo = tester.getSize(find.text(titulo)).height;
|
||||
|
||||
await tester.pumpWidget(
|
||||
host(
|
||||
SizedBox(
|
||||
width: 360,
|
||||
child: FilaAjuste(
|
||||
icon: Icons.radio_rounded,
|
||||
titulo: titulo,
|
||||
onTap: () {},
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pump();
|
||||
final alturaReferencia = tester.getSize(find.text(titulo)).height;
|
||||
|
||||
expect(
|
||||
alturaConValorLargo,
|
||||
closeTo(alturaReferencia, 1.0),
|
||||
reason:
|
||||
'the title rendered taller with a long value present -- it '
|
||||
'wrapped instead of staying on a single ellipsized line',
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets(
|
||||
'visual fidelity (audit S10): GrupoAjustes insets its row divider by '
|
||||
'47px, not full-bleed (t4 line 516)',
|
||||
@@ -122,4 +192,81 @@ void main() {
|
||||
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', (
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -133,6 +133,42 @@ void main() {
|
||||
},
|
||||
);
|
||||
|
||||
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);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ 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/pluri_layout.dart';
|
||||
import 'package:pluriwave/widgets/tarjeta_emisora.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
@@ -181,6 +182,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 '
|
||||
@@ -740,6 +836,78 @@ void main() {
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('issue 3 (feedback-pruebas): the results list uses row-tier '
|
||||
'horizontal padding (12), not the card-tier constant this "flat, '
|
||||
'background-less row" was documented as needing but never got', (
|
||||
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 fila = find.byType(FilaEmisoraPlana);
|
||||
expect(
|
||||
tester.getTopLeft(fila).dx,
|
||||
PluriLayout.rowHorizontal,
|
||||
reason:
|
||||
'issue 3: background-less rows are row tier (12), not card '
|
||||
'tier (16)',
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('issue 3 (feedback-pruebas): the results list is topped by the '
|
||||
'standard section gap, not the horizontal-inset constant reused for '
|
||||
'a vertical axis', (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);
|
||||
|
||||
// Reads the structural padding directly, rather than measuring a
|
||||
// gap between two rendered widgets — the count row's own height is
|
||||
// dictated by its taller PopupMenuButton (48dp touch target), so a
|
||||
// position-based gap measurement against the count TEXT specifically
|
||||
// would be thrown off by that unrelated vertical centring.
|
||||
//
|
||||
// Scoped to `shrinkWrap: true` — the OUTER page ListView is ALSO an
|
||||
// ancestor of every `FilaEmisoraPlana`, but only `_resultados`'s OWN
|
||||
// inner `ListView.builder` sets `shrinkWrap`.
|
||||
final listaResultados = tester.widget<ListView>(
|
||||
find.byWidgetPredicate((w) => w is ListView && w.shrinkWrap),
|
||||
);
|
||||
final padding = listaResultados.padding as EdgeInsets;
|
||||
|
||||
expect(
|
||||
padding.top,
|
||||
PluriLayout.sectionGap,
|
||||
reason:
|
||||
'issue 3: the results list must use the dedicated vertical '
|
||||
'section gap above its first row, not the horizontal (16) '
|
||||
'constant reused for a vertical axis',
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets(
|
||||
'tapping the favourite toggle on a search result adds it to favorites',
|
||||
(tester) async {
|
||||
@@ -774,6 +942,44 @@ void main() {
|
||||
// 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 '
|
||||
|
||||
@@ -357,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
|
||||
@@ -385,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);
|
||||
@@ -464,6 +472,24 @@ void main() {
|
||||
expect(find.text('Move to list'), findsOneWidget);
|
||||
expect(find.text('Remove from favorites'), findsOneWidget);
|
||||
expect(find.byType(PopupMenuItem<String>), findsNWidgets(2));
|
||||
|
||||
// Regression guard for a real user-reported bug: the button carried
|
||||
// `constraints: BoxConstraints.tightFor(width: 38, height: 42)`,
|
||||
// which sizes the POPUP MENU rather than the button. Every item was
|
||||
// clipped to its first letter — users saw "M" and "E", not the
|
||||
// labels. The three assertions above all PASSED throughout, because
|
||||
// find.text matches a Text widget in the tree whether or not it is
|
||||
// visually clipped. Only measuring the laid-out width catches it.
|
||||
final anchoItem = tester.getSize(
|
||||
find.byType(PopupMenuItem<String>).first,
|
||||
);
|
||||
expect(
|
||||
anchoItem.width,
|
||||
greaterThan(100),
|
||||
reason:
|
||||
'a menu item narrower than its label means the popup is being '
|
||||
'constrained and the text is clipped',
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -494,6 +520,32 @@ void main() {
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
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) {
|
||||
|
||||
@@ -173,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([
|
||||
|
||||
@@ -11,6 +11,7 @@ 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/pluri_station_art_fallback.dart';
|
||||
import 'package:pluriwave/widgets/visualizador_audio.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
@@ -698,6 +699,76 @@ void main() {
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets('issue 6 (feedback-pruebas): the Escuchar hero shows the shared '
|
||||
'PluriStationArtFallback for a station with no favicon, not a flat '
|
||||
'coloured square', (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: 'sin-arte', nombre: 'Sin Arte');
|
||||
await tester.runAsync(() => estado.reproducir(sonando));
|
||||
|
||||
await tester.pumpWidget(
|
||||
_conProviders(estado, _testApp(const PantallaInicio())),
|
||||
);
|
||||
await _pumpBounded(tester);
|
||||
|
||||
expect(
|
||||
find.byType(PluriStationArtFallback),
|
||||
findsWidgets,
|
||||
reason:
|
||||
'issue 6: the hero must reach the shared fallback, not its own '
|
||||
'flat primaryContainer square',
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets(
|
||||
'issue 6 (feedback-pruebas): a "Tus emisoras" grid cell shows the shared '
|
||||
'PluriStationArtFallback for a favourite with no favicon',
|
||||
(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: 'grid-sin-arte', nombre: 'Grid Sin Arte'),
|
||||
);
|
||||
await estado.cargarFavoritos();
|
||||
|
||||
await tester.pumpWidget(
|
||||
_conProviders(estado, _testApp(const PantallaInicio())),
|
||||
);
|
||||
await _pumpStableFrame(tester);
|
||||
|
||||
expect(
|
||||
find.byType(PluriStationArtFallback),
|
||||
findsWidgets,
|
||||
reason:
|
||||
'issue 6: the grid cell must reach the shared fallback, not its '
|
||||
'own flat primaryContainer square',
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// 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,7 +63,9 @@ 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);
|
||||
@@ -201,6 +204,66 @@ void main() {
|
||||
);
|
||||
});
|
||||
|
||||
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 {
|
||||
|
||||
@@ -12,6 +12,7 @@ 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/pluri_station_art_fallback.dart';
|
||||
import 'package:pluriwave/widgets/visualizador_audio.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
@@ -704,4 +705,30 @@ void main() {
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
group('issue 6 (feedback-pruebas): shared station-art fallback', () {
|
||||
testWidgets(
|
||||
'a station with no favicon shows the shared PluriStationArtFallback, '
|
||||
'not a flat coloured square',
|
||||
(tester) async {
|
||||
// `emisora` (the file-level fixture) never sets a favicon.
|
||||
final estado = crearEstado();
|
||||
addTearDown(estado.dispose);
|
||||
|
||||
await montarPantalla(tester, estado);
|
||||
|
||||
final arte = find.byKey(const Key('player-hero-art'));
|
||||
expect(
|
||||
find.descendant(
|
||||
of: arte,
|
||||
matching: find.byType(PluriStationArtFallback),
|
||||
),
|
||||
findsOneWidget,
|
||||
reason:
|
||||
'issue 6: the full player must reach the shared fallback, '
|
||||
'not its own flat primaryContainer square',
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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,270 @@ 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);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
group('issue 1 (feedback-pruebas): editar y eliminar rangos', () {
|
||||
testWidgets(
|
||||
'tocar la tarjeta de un rango programado abre el editor precargado '
|
||||
'con su nombre y fechas',
|
||||
(tester) async {
|
||||
final estado = await _crearEstado(
|
||||
vacaciones: [
|
||||
RangoVacaciones(
|
||||
id: 'f2',
|
||||
nombre: 'Verano',
|
||||
inicio: _hoyDia.add(const Duration(days: 20)),
|
||||
fin: _hoyDia.add(const Duration(days: 25)),
|
||||
),
|
||||
],
|
||||
);
|
||||
addTearDown(estado.dispose);
|
||||
|
||||
await tester.pumpWidget(_buildScreen(estado));
|
||||
await _pumpEstable(tester);
|
||||
final l10n = AppLocalizations.of(
|
||||
tester.element(find.byType(PantallaVacaciones)),
|
||||
);
|
||||
|
||||
await tester.tap(find.byKey(const ValueKey('vacaciones-tarjeta-f2')));
|
||||
await _pumpEstable(tester);
|
||||
|
||||
expect(find.text(l10n.editVacationRangeTitle), findsOneWidget);
|
||||
// Scoped to the TextField specifically -- the original card's OWN
|
||||
// "Verano" label is still (offstage, behind the modal) in the tree,
|
||||
// so a bare `find.text('Verano')` would ambiguously match both.
|
||||
expect(find.widgetWithText(TextField, 'Verano'), findsOneWidget);
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets(
|
||||
'guardar el editor abierto por tap actualiza el rango existente (no '
|
||||
'crea uno nuevo)',
|
||||
(tester) async {
|
||||
final estado = await _crearEstado(
|
||||
vacaciones: [
|
||||
RangoVacaciones(
|
||||
id: 'f2',
|
||||
nombre: 'Verano',
|
||||
inicio: _hoyDia.add(const Duration(days: 20)),
|
||||
fin: _hoyDia.add(const Duration(days: 25)),
|
||||
),
|
||||
],
|
||||
);
|
||||
addTearDown(estado.dispose);
|
||||
|
||||
await tester.pumpWidget(_buildScreen(estado));
|
||||
await _pumpEstable(tester);
|
||||
final l10n = AppLocalizations.of(
|
||||
tester.element(find.byType(PantallaVacaciones)),
|
||||
);
|
||||
|
||||
await tester.tap(find.byKey(const ValueKey('vacaciones-tarjeta-f2')));
|
||||
await _pumpEstable(tester);
|
||||
|
||||
await tester.enterText(find.byType(TextField), 'Verano renombrado');
|
||||
final boton = tester.widget<FilledButton>(
|
||||
find.widgetWithText(FilledButton, l10n.saveRangeAction),
|
||||
);
|
||||
boton.onPressed!();
|
||||
await _pumpEstable(tester);
|
||||
|
||||
expect(estado.vacaciones, hasLength(1));
|
||||
expect(estado.vacaciones.single.id, 'f2');
|
||||
expect(estado.vacaciones.single.nombre, 'Verano renombrado');
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets(
|
||||
'deslizar la tarjeta de un rango pide confirmacion; cancelar la '
|
||||
'conserva y confirmar la elimina',
|
||||
(tester) async {
|
||||
final estado = await _crearEstado(
|
||||
vacaciones: [
|
||||
RangoVacaciones(
|
||||
id: 'f2',
|
||||
nombre: 'Verano',
|
||||
inicio: _hoyDia.add(const Duration(days: 20)),
|
||||
fin: _hoyDia.add(const Duration(days: 25)),
|
||||
),
|
||||
],
|
||||
);
|
||||
addTearDown(estado.dispose);
|
||||
|
||||
await tester.pumpWidget(_buildScreen(estado));
|
||||
await _pumpEstable(tester);
|
||||
final l10n = AppLocalizations.of(
|
||||
tester.element(find.byType(PantallaVacaciones)),
|
||||
);
|
||||
|
||||
// Cancelar: el rango se conserva.
|
||||
await tester.drag(
|
||||
find.byKey(const ValueKey('vacaciones-tarjeta-f2')),
|
||||
const Offset(-600, 0),
|
||||
);
|
||||
await _pumpEstable(tester);
|
||||
expect(find.text(l10n.vacationDeleteConfirmTitle), findsOneWidget);
|
||||
await tester.tap(find.text(l10n.cancelAction));
|
||||
await _pumpEstable(tester);
|
||||
expect(estado.vacaciones, hasLength(1));
|
||||
|
||||
// Confirmar: el rango se elimina.
|
||||
await tester.drag(
|
||||
find.byKey(const ValueKey('vacaciones-tarjeta-f2')),
|
||||
const Offset(-600, 0),
|
||||
);
|
||||
await _pumpEstable(tester);
|
||||
expect(find.text(l10n.vacationDeleteConfirmTitle), findsOneWidget);
|
||||
await tester.tap(find.text(l10n.deleteAction));
|
||||
await _pumpEstable(tester);
|
||||
|
||||
expect(estado.vacaciones, isEmpty);
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets(
|
||||
'el rango ACTIVO (mostrado en el hero) tambien se puede editar (tap) '
|
||||
'y eliminar (swipe) -- un rango recien creado siempre esta activo y '
|
||||
'nunca aparece en las listas programado/pasado',
|
||||
(tester) async {
|
||||
final estado = await _crearEstado(
|
||||
vacaciones: [
|
||||
RangoVacaciones(
|
||||
id: 'v1',
|
||||
nombre: 'Julio activo',
|
||||
inicio: _hoyDia.subtract(const Duration(days: 3)),
|
||||
fin: _hoyDia.add(const Duration(days: 5)),
|
||||
),
|
||||
],
|
||||
);
|
||||
addTearDown(estado.dispose);
|
||||
|
||||
await tester.pumpWidget(_buildScreen(estado));
|
||||
await _pumpEstable(tester);
|
||||
final l10n = AppLocalizations.of(
|
||||
tester.element(find.byType(PantallaVacaciones)),
|
||||
);
|
||||
|
||||
await tester.tap(find.byKey(const ValueKey('vacaciones-tarjeta-v1')));
|
||||
await _pumpEstable(tester);
|
||||
expect(find.text(l10n.editVacationRangeTitle), findsOneWidget);
|
||||
// Scoped to the TextField specifically -- the hero's OWN "Julio
|
||||
// activo" label is still (offstage, behind the modal) in the tree.
|
||||
expect(find.widgetWithText(TextField, 'Julio activo'), findsOneWidget);
|
||||
// Dismiss the editor sheet (no explicit close button -- same as the
|
||||
// pre-existing "Anadir rango" sheet, dismissible via the standard
|
||||
// modal-bottom-sheet Navigator.pop) before interacting with the
|
||||
// list underneath it.
|
||||
Navigator.of(tester.element(find.byType(PantallaVacaciones))).pop();
|
||||
await _pumpEstable(tester);
|
||||
|
||||
await tester.drag(
|
||||
find.byKey(const ValueKey('vacaciones-tarjeta-v1')),
|
||||
const Offset(-600, 0),
|
||||
);
|
||||
await _pumpEstable(tester);
|
||||
await tester.tap(find.text(l10n.deleteAction));
|
||||
await _pumpEstable(tester);
|
||||
|
||||
expect(estado.vacaciones, isEmpty);
|
||||
expect(find.text(l10n.vacationNoActiveRangeHint), findsOneWidget);
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ 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/pluri_station_art_fallback.dart';
|
||||
import 'package:pluriwave/widgets/tarjeta_emisora.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
@@ -77,6 +78,32 @@ void main() {
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets(
|
||||
'issue 6 (feedback-pruebas): a station with no favicon shows the shared '
|
||||
'PluriStationArtFallback, not a flat coloured square',
|
||||
(tester) async {
|
||||
final estado = _estado();
|
||||
addTearDown(estado.dispose);
|
||||
final emisora = emisoraDemo(uuid: 'sin-arte', nombre: 'Sin Arte');
|
||||
|
||||
await tester.pumpWidget(
|
||||
_host(estado, FilaEmisoraPlana(emisora: emisora, meta: '')),
|
||||
);
|
||||
await tester.pump();
|
||||
|
||||
expect(
|
||||
find.descendant(
|
||||
of: find.byKey(const ValueKey('fila-emisora-plana-arte')),
|
||||
matching: find.byType(PluriStationArtFallback),
|
||||
),
|
||||
findsOneWidget,
|
||||
reason:
|
||||
'issue 6: the flat row must reach the shared fallback, not its '
|
||||
'own flat primaryContainer square',
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets('omits the meta line entirely when empty (no stray gap)', (
|
||||
tester,
|
||||
) async {
|
||||
|
||||
@@ -5,6 +5,7 @@ import 'package:pluriwave/l10n/gen/app_localizations.dart';
|
||||
import 'package:pluriwave/tema/pluriwave_theme.dart';
|
||||
import 'package:pluriwave/tema/pluriwave_tokens.dart';
|
||||
import 'package:pluriwave/widgets/mini_reproductor.dart';
|
||||
import 'package:pluriwave/widgets/pluri_station_art_fallback.dart';
|
||||
import 'package:pluriwave/widgets/visualizador_audio.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
@@ -75,6 +76,33 @@ void main() {
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets(
|
||||
'issue 6 (feedback-pruebas): a station with no favicon shows the shared '
|
||||
'PluriStationArtFallback, not a flat coloured square',
|
||||
(tester) async {
|
||||
final estado = _estadoConEmisora();
|
||||
addTearDown(estado.dispose);
|
||||
await estado.reproducir(
|
||||
emisoraDemo(uuid: 'sin-arte', nombre: 'Sin Arte'),
|
||||
);
|
||||
|
||||
await tester.pumpWidget(_hostFor(estado));
|
||||
await tester.pump();
|
||||
|
||||
final arte = find.byKey(const ValueKey('mini-reproductor-arte'));
|
||||
expect(
|
||||
find.descendant(
|
||||
of: arte,
|
||||
matching: find.byType(PluriStationArtFallback),
|
||||
),
|
||||
findsOneWidget,
|
||||
reason:
|
||||
'issue 6: the mini player must reach the shared fallback, not '
|
||||
'its own flat primaryContainer square',
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets(
|
||||
'is opaque -- no BackdropFilter -- unlike the former glass pill',
|
||||
(tester) async {
|
||||
|
||||
@@ -96,13 +96,17 @@ void main() {
|
||||
);
|
||||
expect(inactiveIcon.color, isNull);
|
||||
|
||||
final dimmed = tester.widget<Opacity>(
|
||||
// AnimatedOpacity, not a plain Opacity: the dim now transitions with the
|
||||
// balloon instead of snapping. Users reported the bar's contents
|
||||
// teleporting while the balloon slid — the lift, dim, icon size and
|
||||
// label all animate together now.
|
||||
final dimmed = tester.widget<AnimatedOpacity>(
|
||||
find.ancestor(
|
||||
of: find.descendant(
|
||||
of: find.byKey(PluriBottomNavigation.itemKey(1)),
|
||||
matching: find.byType(PluriIcon),
|
||||
),
|
||||
matching: find.byType(Opacity),
|
||||
matching: find.byType(AnimatedOpacity),
|
||||
),
|
||||
);
|
||||
expect(dimmed.opacity, closeTo(0.46, 0.001));
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:pluriwave/estado/estado_radio.dart';
|
||||
import 'package:pluriwave/l10n/gen/app_localizations.dart';
|
||||
import 'package:pluriwave/widgets/pluri_sleep_timer_sheet.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import '../helpers/fakes.dart';
|
||||
import '../helpers/fakes_alarmas.dart';
|
||||
|
||||
/// Issue 2 (feedback-pruebas): "Timer de sueño" opened from Escuchar (and
|
||||
/// every other root header) showed a bottom sheet with no visible
|
||||
/// countdown. `ServicioTimer.tiempoRestanteStream`/`tiempoRestante` already
|
||||
/// existed and this sheet already had a `StreamBuilder` countdown branch —
|
||||
/// but every preset/custom-duration action popped the sheet immediately
|
||||
/// after starting the timer, so the countdown never had a chance to render
|
||||
/// in the primary flow. Zero test coverage existed for this file before.
|
||||
EstadoRadio _estado() => EstadoRadio(
|
||||
audio: FakeServicioAudio(),
|
||||
favoritos: FakeServicioFavoritos(),
|
||||
radio: FakeServicioRadio(),
|
||||
servicioEcualizador: FakeServicioEcualizador(),
|
||||
servicioGrabacion: FakeServicioGrabacionRadioInactiva(),
|
||||
iniciarAutomaticamente: false,
|
||||
);
|
||||
|
||||
Widget _host(EstadoRadio estado) {
|
||||
return ChangeNotifierProvider<EstadoRadio>.value(
|
||||
value: estado,
|
||||
child: MaterialApp(
|
||||
locale: const Locale('es'),
|
||||
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||
supportedLocales: AppLocalizations.supportedLocales,
|
||||
home: Scaffold(
|
||||
body: Builder(
|
||||
builder:
|
||||
(context) => TextButton(
|
||||
onPressed: () => showPluriSleepTimerSheet(context),
|
||||
child: const Text('abrir'),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void main() {
|
||||
setUp(() {
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
});
|
||||
|
||||
// The default 800x600 test viewport is shorter than a typical phone —
|
||||
// same fix `pantalla_reproductor_test.dart`/`pantalla_inicio_test.dart`
|
||||
// already established for other bottom-sheet/full-bleed content.
|
||||
void ajustarSuperficieRealista(WidgetTester tester) {
|
||||
tester.view.physicalSize = const Size(390, 844);
|
||||
tester.view.devicePixelRatio = 1.0;
|
||||
addTearDown(tester.view.resetPhysicalSize);
|
||||
addTearDown(tester.view.resetDevicePixelRatio);
|
||||
}
|
||||
|
||||
testWidgets(
|
||||
'selecting a preset keeps the sheet open and switches it to the live '
|
||||
'remaining-time countdown, instead of closing with no feedback',
|
||||
(tester) async {
|
||||
ajustarSuperficieRealista(tester);
|
||||
final estado = _estado();
|
||||
addTearDown(estado.dispose);
|
||||
|
||||
await tester.pumpWidget(_host(estado));
|
||||
await tester.tap(find.text('abrir'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final l10n = AppLocalizations.of(tester.element(find.text('abrir')));
|
||||
|
||||
expect(estado.timer.activo, isFalse);
|
||||
expect(find.byType(ActionChip), findsWidgets);
|
||||
|
||||
final chip = tester.widget<ActionChip>(
|
||||
find.widgetWithText(ActionChip, l10n.durationMinutesOnly(5)),
|
||||
);
|
||||
chip.onPressed!();
|
||||
await tester.pump();
|
||||
|
||||
expect(estado.timer.activo, isTrue);
|
||||
expect(
|
||||
find.byType(ActionChip),
|
||||
findsNothing,
|
||||
reason: 'issue 2: the picker is replaced by the countdown view',
|
||||
);
|
||||
expect(find.text(l10n.cancelTimer), findsOneWidget);
|
||||
expect(
|
||||
find.text(l10n.durationMinutesOnly(5)),
|
||||
findsOneWidget,
|
||||
reason: 'issue 2: the remaining time is now surfaced live',
|
||||
);
|
||||
|
||||
// `ServicioTimer` starts a REAL periodic Timer -- `addTearDown`
|
||||
// callbacks run too late to satisfy the "no pending timers" check
|
||||
// (a well-known flutter_test ordering quirk), so it must be
|
||||
// cancelled here, inside the test body, before it ends.
|
||||
await estado.timer.cancelar();
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets('reopening the sheet while a timer is already active shows the '
|
||||
'countdown immediately, not the duration picker', (tester) async {
|
||||
ajustarSuperficieRealista(tester);
|
||||
final estado = _estado();
|
||||
addTearDown(estado.dispose);
|
||||
estado.iniciarTimerDuracion(const Duration(minutes: 10));
|
||||
|
||||
await tester.pumpWidget(_host(estado));
|
||||
await tester.tap(find.text('abrir'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final l10n = AppLocalizations.of(tester.element(find.text('abrir')));
|
||||
expect(find.byType(ActionChip), findsNothing);
|
||||
expect(find.text(l10n.cancelTimer), findsOneWidget);
|
||||
expect(find.text(l10n.durationMinutesOnly(10)), findsOneWidget);
|
||||
|
||||
// See the comment in the previous test — cancel before the body ends.
|
||||
await estado.timer.cancelar();
|
||||
});
|
||||
|
||||
testWidgets(
|
||||
'cancelling from the countdown view stops the timer and closes the '
|
||||
'sheet',
|
||||
(tester) async {
|
||||
ajustarSuperficieRealista(tester);
|
||||
final estado = _estado();
|
||||
addTearDown(estado.dispose);
|
||||
estado.iniciarTimerDuracion(const Duration(minutes: 10));
|
||||
|
||||
await tester.pumpWidget(_host(estado));
|
||||
await tester.tap(find.text('abrir'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final l10n = AppLocalizations.of(tester.element(find.text('abrir')));
|
||||
final boton = tester.widget<FilledButton>(
|
||||
find.widgetWithText(FilledButton, l10n.cancelTimer),
|
||||
);
|
||||
boton.onPressed!();
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(estado.timer.activo, isFalse);
|
||||
expect(find.text(l10n.cancelTimer), findsNothing);
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets(
|
||||
'starting a custom duration ALSO keeps the sheet open on the countdown '
|
||||
'view, not just the presets',
|
||||
(tester) async {
|
||||
ajustarSuperficieRealista(tester);
|
||||
final estado = _estado();
|
||||
addTearDown(estado.dispose);
|
||||
|
||||
await tester.pumpWidget(_host(estado));
|
||||
await tester.tap(find.text('abrir'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final l10n = AppLocalizations.of(tester.element(find.text('abrir')));
|
||||
await tester.tap(find.text(l10n.optionOther));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Confirm the custom-duration sub-sheet directly (default prefilled
|
||||
// value is already "15" minutes -- see _TimerPersonalizadoSheetState).
|
||||
final confirmar = tester.widget<FilledButton>(
|
||||
find.widgetWithText(FilledButton, l10n.startTimer),
|
||||
);
|
||||
confirmar.onPressed!();
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(estado.timer.activo, isTrue);
|
||||
expect(find.byType(ActionChip), findsNothing);
|
||||
expect(find.text(l10n.cancelTimer), findsOneWidget);
|
||||
|
||||
// See the comment in the first test — cancel before the body ends.
|
||||
await estado.timer.cancelar();
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:pluriwave/l10n/gen/app_localizations.dart';
|
||||
import 'package:pluriwave/widgets/pluri_icon.dart';
|
||||
import 'package:pluriwave/widgets/pluri_station_art_fallback.dart';
|
||||
|
||||
/// Issue 6 (feedback-pruebas): the shared station-art fallback extracted
|
||||
/// from `TarjetaEmisora`'s original `_fallbackArtFor`. The formula and asset
|
||||
/// order are pinned exactly — `navegacion_auto_test.dart`'s
|
||||
/// `indiceArtePara`/`artUriPara` tests mirror the SAME formula independently
|
||||
/// (protected file, empty diff vs main) and must keep agreeing with this
|
||||
/// widget without either suite importing the other.
|
||||
void main() {
|
||||
Widget host(Widget child) {
|
||||
return MaterialApp(
|
||||
locale: const Locale('en'),
|
||||
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||
supportedLocales: AppLocalizations.supportedLocales,
|
||||
home: Scaffold(body: SizedBox(width: 60, height: 60, child: child)),
|
||||
);
|
||||
}
|
||||
|
||||
test('artFor reproduces the canonical aurora/cosmic/pulse/nova order '
|
||||
'(same formula as navegacion_auto.dart\'s indiceArtePara)', () {
|
||||
// Single-letter seeds whose codeUnit % 4 covers all 4 indices, mirroring
|
||||
// navegacion_auto_test.dart's own fixture exactly: 'd'(100)->0 aurora,
|
||||
// 'a'(97)->1 cosmic, 'b'(98)->2 pulse, 'c'(99)->3 nova.
|
||||
expect(
|
||||
PluriStationArtFallback.artFor('d'),
|
||||
'assets/images/station_art_aurora.png',
|
||||
);
|
||||
expect(
|
||||
PluriStationArtFallback.artFor('a'),
|
||||
'assets/images/station_art_cosmic.png',
|
||||
);
|
||||
expect(
|
||||
PluriStationArtFallback.artFor('b'),
|
||||
'assets/images/station_art_pulse.png',
|
||||
);
|
||||
expect(
|
||||
PluriStationArtFallback.artFor('c'),
|
||||
'assets/images/station_art_nova.png',
|
||||
);
|
||||
});
|
||||
|
||||
test('artFor is deterministic for a given seed (same station always picks '
|
||||
'the same art)', () {
|
||||
const seed = 'uuid-1234-abcd-real-looking';
|
||||
expect(
|
||||
PluriStationArtFallback.artFor(seed),
|
||||
PluriStationArtFallback.artFor(seed),
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets(
|
||||
'renders the deterministic station-art asset plus a centred player glyph',
|
||||
(tester) async {
|
||||
await tester.pumpWidget(
|
||||
host(const PluriStationArtFallback(seed: 'd', iconSize: 22)),
|
||||
);
|
||||
await tester.pump();
|
||||
|
||||
// Scoped by asset-name prefix, not `find.byType(Image).first` — the
|
||||
// player glyph (`PluriIcon`) ALSO renders via its own `Image.asset`
|
||||
// internally, so a bare type match would be ambiguous.
|
||||
final arte = tester
|
||||
.widgetList<Image>(find.byType(Image))
|
||||
.firstWhere(
|
||||
(img) =>
|
||||
img.image is AssetImage &&
|
||||
(img.image as AssetImage).assetName.startsWith(
|
||||
'assets/images/station_art_',
|
||||
),
|
||||
);
|
||||
expect(
|
||||
(arte.image as AssetImage).assetName,
|
||||
'assets/images/station_art_aurora.png',
|
||||
);
|
||||
|
||||
final icono = tester.widget<PluriIcon>(find.byType(PluriIcon));
|
||||
expect(icono.glyph, PluriIconGlyph.player);
|
||||
expect(icono.variant, PluriIconVariant.activeGlow);
|
||||
expect(icono.size, 22);
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:pluriwave/estado/estado_radio.dart';
|
||||
import 'package:pluriwave/l10n/gen/app_localizations.dart';
|
||||
import 'package:pluriwave/widgets/pluri_station_art_fallback.dart';
|
||||
import 'package:pluriwave/widgets/tarjeta_emisora.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
@@ -73,6 +74,24 @@ void main() {
|
||||
expect(tester.getSize(thumbnailClip), const Size(48, 48));
|
||||
});
|
||||
|
||||
testWidgets(
|
||||
'issue 6 (feedback-pruebas): a station with no favicon renders via the '
|
||||
'shared PluriStationArtFallback (extracted from this exact fallback)',
|
||||
(tester) async {
|
||||
await tester.pumpWidget(
|
||||
host(
|
||||
TarjetaEmisora(
|
||||
emisora: emisoraDemo(uuid: 'sin-arte', nombre: 'Sin Arte'),
|
||||
esCompacta: true,
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pump();
|
||||
|
||||
expect(find.byType(PluriStationArtFallback), findsOneWidget);
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets('esCompacta shimmer placeholder is a square block, not a '
|
||||
'circle', (tester) async {
|
||||
await tester.pumpWidget(
|
||||
|
||||
Reference in New Issue
Block a user