fix: address the issues found in on-device testing
Build & Deploy PluriWave / Análisis de código (push) Successful in 28s
Build & Deploy PluriWave / Build APK + AAB release (push) Successful in 2m18s

Seven of the eight points reported after the first real build.

- Favourites overflow menu was clipped to one letter per item by a
  constraints property that sizes the popup, not the button
- Bottom bar painted a square ink splash over the icon, and its lift,
  dim, icon size and label snapped while the balloon slid
- Station artwork fallback is now shared by every surface instead of the
  flat rows painting a plain coloured square
- Vacation ranges can be edited and deleted
- Settings row titles no longer wrap into cut lines
- Sleep timer sheet shows the live countdown
- The last-played station survives a restart, shown stopped

Spacing review is done for Buscar only; the rest of the app is still
outstanding.

Tests: 903 -> 926.
This commit is contained in:
2026-07-30 21:47:13 +02:00
54 changed files with 1887 additions and 500 deletions
+13
View File
@@ -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
+55
View File
@@ -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;
+3
View File
@@ -468,6 +468,9 @@
"deleteRangeTooltip": "حذف النطاق",
"vacationsDefaultName": "إجازات",
"newVacationRangeTitle": "نطاق إجازة جديد",
"editVacationRangeTitle": "تعديل نطاق الإجازة",
"vacationDeleteConfirmTitle": "هل تريد حذف نطاق الإجازة؟",
"vacationDeleteConfirmMessage": "لا يمكن التراجع عن هذا الإجراء.",
"startField": "البداية",
"endField": "النهاية",
"saveRangeAction": "حفظ النطاق",
+3
View File
@@ -468,6 +468,9 @@
"deleteRangeTooltip": "পরিসর মুছুন",
"vacationsDefaultName": "ছুটি",
"newVacationRangeTitle": "নতুন ছুটির পরিসর",
"editVacationRangeTitle": "ছুটির পরিসর সম্পাদনা করুন",
"vacationDeleteConfirmTitle": "ছুটির পরিসর মুছবেন?",
"vacationDeleteConfirmMessage": "এই পদক্ষেপ ফিরিয়ে নেওয়া যাবে না।",
"startField": "শুরু",
"endField": "শেষ",
"saveRangeAction": "পরিসর সংরক্ষণ করুন",
+3
View File
@@ -468,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",
+3
View File
@@ -580,6 +580,9 @@
"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",
+3
View File
@@ -580,6 +580,9 @@
"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",
+3
View File
@@ -468,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",
+3
View File
@@ -468,6 +468,9 @@
"deleteRangeTooltip": "अवधि हटाएँ",
"vacationsDefaultName": "छुट्टियाँ",
"newVacationRangeTitle": "नई छुट्टी अवधि",
"editVacationRangeTitle": "छुट्टी अवधि संपादित करें",
"vacationDeleteConfirmTitle": "छुट्टी अवधि हटाएं?",
"vacationDeleteConfirmMessage": "इसे वापस नहीं लिया जा सकता।",
"startField": "शुरुआत",
"endField": "समाप्ति",
"saveRangeAction": "अवधि सहेजें",
+3
View File
@@ -468,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",
+3
View File
@@ -468,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",
+3
View File
@@ -468,6 +468,9 @@
"deleteRangeTooltip": "期間を削除",
"vacationsDefaultName": "休暇",
"newVacationRangeTitle": "新しい休暇期間",
"editVacationRangeTitle": "休暇期間を編集",
"vacationDeleteConfirmTitle": "休暇期間を削除しますか?",
"vacationDeleteConfirmMessage": "この操作は元に戻せません。",
"startField": "開始",
"endField": "終了",
"saveRangeAction": "期間を保存",
+3
View File
@@ -468,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",
+3
View File
@@ -468,6 +468,9 @@
"deleteRangeTooltip": "Удалить период",
"vacationsDefaultName": "Отпуск",
"newVacationRangeTitle": "Новый период отпуска",
"editVacationRangeTitle": "Изменить период отпуска",
"vacationDeleteConfirmTitle": "Удалить период отпуска?",
"vacationDeleteConfirmMessage": "Это действие нельзя отменить.",
"startField": "Начало",
"endField": "Конец",
"saveRangeAction": "Сохранить период",
+3
View File
@@ -468,6 +468,9 @@
"deleteRangeTooltip": "删除范围",
"vacationsDefaultName": "假期",
"newVacationRangeTitle": "新的假期范围",
"editVacationRangeTitle": "编辑假期范围",
"vacationDeleteConfirmTitle": "删除假期范围?",
"vacationDeleteConfirmMessage": "此操作无法撤销。",
"startField": "开始",
"endField": "结束",
"saveRangeAction": "保存范围",
+18
View File
@@ -2078,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:
+9
View File
@@ -1133,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 => 'البداية';
+10
View File
@@ -1140,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 => 'শুরু';
+10
View File
@@ -1142,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';
+9
View File
@@ -1133,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';
+10
View File
@@ -1139,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';
+9
View File
@@ -1145,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';
+9
View File
@@ -1134,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 => 'शुरुआत';
+10
View File
@@ -1139,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';
+10
View File
@@ -1144,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';
+9
View File
@@ -1101,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 => '開始';
+9
View File
@@ -1138,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';
+9
View File
@@ -1140,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 => 'Начало';
+9
View File
@@ -1097,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 => '开始';
+30 -7
View File
@@ -93,6 +93,14 @@ class FilaAjuste extends StatelessWidget {
/// "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;
@@ -106,17 +114,32 @@ class FilaAjuste extends StatelessWidget {
// 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),
+31 -3
View File
@@ -543,8 +543,17 @@ 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: [
@@ -579,8 +588,17 @@ class _PantallaBuscarState extends State<PantallaBuscar> {
// 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.symmetric(horizontal: PluriLayout.horizontal),
padding: const EdgeInsets.fromLTRB(
PluriLayout.horizontal,
PluriLayout.sectionGap,
PluriLayout.horizontal,
0,
),
child: _TarjetaSinResultados(
titulo:
sinFiltros
@@ -616,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) {
+7 -1
View File
@@ -465,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);
+15 -20
View File
@@ -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);
}
+8 -10
View File
@@ -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
+278 -111
View File
@@ -82,13 +82,78 @@ class PantallaVacaciones extends StatelessWidget {
);
}
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,
),
);
}
}
@@ -242,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),
),
),
],
],
),
),
],
],
),
),
),
);
}
@@ -452,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,
),
),
],
),
],
),
),
],
),
),
),
);
@@ -609,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();
@@ -629,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,
);
}
@@ -662,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),
@@ -709,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;
@@ -728,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);
}
}
+8 -10
View File
@@ -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);
}
+8 -10
View File
@@ -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);
}
+76 -46
View File
@@ -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,
),
),
],
],
],
),
),
),
),
+20 -7
View File
@@ -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,
),
),
],
);
}
}
+6 -44
View File
@@ -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);
}
}
+284 -229
View File
@@ -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
@@ -98,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)',
+73
View File
@@ -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';
@@ -835,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 {
@@ -472,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',
);
},
);
+71
View File
@@ -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
@@ -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',
);
},
);
});
}
@@ -445,4 +445,173 @@ void main() {
},
);
});
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);
},
);
});
}
+27
View File
@@ -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(