feat(favoritos): replace stacked group panels with chip-filtered reorderable list
Replaces the stacked per-group panel layout with a single
chip-filtered flat list. Chips read "{name} · {count}" (new ARB keys
favoriteGroupsChipLabel/favoritesFilterAllLabel), one per group plus
an "All" chip. Rows drag-reorder via a leading handle
(ReorderableDragStartListener, buildDefaultDragHandles: false) using
the modern onReorderItem callback rather than the now-@Deprecated
onReorder (Flutter 3.44 marks it obsolete).
EstadoRadio additions: listaFavoritosManual (a new memoized getter
returning the stored order untouched by the global ordenListas
setting - listaFavoritos itself always re-sorts by
name/quality on every read, which would silently discard any
drag-to-reorder), reordenarFavorito (thin wrapper over the
already-existing ServicioFavoritos.reordenar, previously unused
outside its own service test), and ordenarFavoritos (applies an
existing OrdenEmisoras criterion via ordenarEmisoras() and persists
the result as the new manual order, so the swap_vert sort action's
result also survives a restart). listaFavoritos itself is untouched,
so Android Auto's tree and the future Escuchar grid (WU5) are
unaffected by Favoritos' own manual order.
Group management: an "Manage lists" action chip pushes the existing
PantallaAjustesGruposFavoritos screen (Settings' own screen, reused
rather than duplicated) - a second entry point to the same screen.
Custom-station CTA: a new dashed-bordered card opens the add-station
form directly; that form was renamed from private _FormularioEmisora
to public FormularioEmisoraPersonalizada in
pantalla_ajustes_emisoras_personalizadas.dart so both screens share
one implementation. New ARB keys: favoriteGroupsManage,
customStationsAddCta.
Tests: pantalla_favoritos_plural_test.dart (the file tasks.md named)
never imported PantallaFavoritos - it only covers stationCount's ARB
plural formatting, unrelated to this screen. Left it untouched and
added test/pantallas/pantalla_favoritos_test.dart instead: 3
state-layer tests for the new EstadoRadio surface plus 6 widget
scenarios (empty-state CTA, chip filter, drag-reorder persistence,
sort action, group management + chip reactivity, custom-station
CTA). 604 -> 614 tests (2 skipped, unchanged). flutter analyze
unchanged at 1 pre-existing info.
Recorded in tasks.md with the test-file correction and the
design decisions this WU had to make on its own (no ADR covers
Favoritos' manual-order persistence).
This commit is contained in:
@@ -164,6 +164,7 @@ class EstadoRadio extends ChangeNotifier {
|
||||
final _memoPopulares = MemoLista<Emisora>();
|
||||
final _memoTendencias = MemoLista<Emisora>();
|
||||
final _memoFavoritos = MemoLista<Emisora>();
|
||||
final _memoFavoritosManual = MemoLista<Emisora>();
|
||||
final _memoGrupos = MemoLista<GrupoFavoritos>();
|
||||
final _memoCustom = MemoLista<Emisora>();
|
||||
final _memoInicio = MemoLista<Emisora>();
|
||||
@@ -200,6 +201,17 @@ class EstadoRadio extends ChangeNotifier {
|
||||
_listaFavoritos,
|
||||
_ordenListas,
|
||||
], () => ordenarEmisoras(_listaFavoritos, _ordenListas));
|
||||
|
||||
/// WU4, `favorites-organization` spec: Favoritos' own manual order —
|
||||
/// unlike [listaFavoritos], this is NOT re-sorted by the global
|
||||
/// [ordenListas] setting on every read. It reflects exactly the order
|
||||
/// stored via the `orden` column (`ServicioFavoritos.obtenerTodos()`),
|
||||
/// which [reordenarFavorito] and [ordenarFavoritos] mutate. Other
|
||||
/// consumers of `listaFavoritos` (Android Auto, the Escuchar grid) are
|
||||
/// unaffected by drag-reordering Favoritos.
|
||||
List<Emisora> get listaFavoritosManual => _memoFavoritosManual.obtener([
|
||||
_listaFavoritos,
|
||||
], () => List<Emisora>.unmodifiable(_listaFavoritos));
|
||||
List<GrupoFavoritos> get gruposFavoritos => _memoGrupos.obtener([
|
||||
_gruposFavoritos,
|
||||
], () => List<GrupoFavoritos>.unmodifiable(_gruposFavoritos));
|
||||
@@ -371,6 +383,30 @@ class EstadoRadio extends ChangeNotifier {
|
||||
await cargarFavoritos();
|
||||
}
|
||||
|
||||
/// WU4: persists a single drag-to-reorder move within [listaFavoritosManual].
|
||||
/// [nuevoIndice] is the absolute target position among ALL favorites (not
|
||||
/// scoped to any chip filter) — the screen translates a filtered-list drag
|
||||
/// into this global index before calling this method.
|
||||
Future<void> reordenarFavorito(String uuid, int nuevoIndice) async {
|
||||
await favoritos.reordenar(uuid, nuevoIndice);
|
||||
await cargarFavoritos();
|
||||
}
|
||||
|
||||
/// WU4: the Favoritos `swap_vert` sort action. Reuses the existing
|
||||
/// [OrdenEmisoras] criteria and [ordenarEmisoras] function — "It MUST NOT
|
||||
/// introduce a sort criterion with no backing implementation"
|
||||
/// (favorites-organization spec). Unlike [ordenListas] (a GLOBAL setting
|
||||
/// affecting favorites/searches/nearby/quick-lists), this sorts ONLY the
|
||||
/// favorites list once and PERSISTS the result as the new manual order —
|
||||
/// consistent with [reordenarFavorito]'s "the order MUST persist" contract.
|
||||
Future<void> ordenarFavoritos(OrdenEmisoras criterio) async {
|
||||
final ordenados = ordenarEmisoras(_listaFavoritos, criterio);
|
||||
for (var i = 0; i < ordenados.length; i++) {
|
||||
await favoritos.reordenar(ordenados[i].uuid, i);
|
||||
}
|
||||
await cargarFavoritos();
|
||||
}
|
||||
|
||||
Future<void> cambiarEmisoraPreferida(Emisora? emisora) async {
|
||||
_emisoraPreferidaUuid = emisora?.uuid;
|
||||
final prefs = await _resolverPrefs();
|
||||
|
||||
@@ -282,6 +282,18 @@
|
||||
"stationName": {}
|
||||
}
|
||||
},
|
||||
"favoritesFilterAllLabel": "All",
|
||||
"favoriteGroupsChipLabel": "{groupName} · {count}",
|
||||
"@favoriteGroupsChipLabel": {
|
||||
"placeholders": {
|
||||
"groupName": {},
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"favoriteGroupsManage": "Manage lists",
|
||||
"customStationsAddCta": "Add custom station",
|
||||
"alarmPostponedCurrentExecution": "Alarm postponed for this occurrence.",
|
||||
"searchScreenTitle": "Search signal",
|
||||
"searchScreenSubtitle": "Find stations by name, country, or language with fast filters and high contrast.",
|
||||
|
||||
@@ -282,6 +282,18 @@
|
||||
"stationName": {}
|
||||
}
|
||||
},
|
||||
"favoritesFilterAllLabel": "Todas",
|
||||
"favoriteGroupsChipLabel": "{groupName} · {count}",
|
||||
"@favoriteGroupsChipLabel": {
|
||||
"placeholders": {
|
||||
"groupName": {},
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"favoriteGroupsManage": "Gestionar listas",
|
||||
"customStationsAddCta": "Añadir emisora personalizada",
|
||||
"alarmPostponedCurrentExecution": "Alarma pospuesta para esta ejecución.",
|
||||
"searchScreenTitle": "Buscar señal",
|
||||
"searchScreenSubtitle": "Encontrá radios por nombre, país o idioma con filtros rápidos y alto contraste.",
|
||||
|
||||
@@ -1036,6 +1036,30 @@ abstract class AppLocalizations {
|
||||
/// **'{stationName} eliminada de favoritos'**
|
||||
String favoritesRemovedMessage(Object stationName);
|
||||
|
||||
/// No description provided for @favoritesFilterAllLabel.
|
||||
///
|
||||
/// In es, this message translates to:
|
||||
/// **'Todas'**
|
||||
String get favoritesFilterAllLabel;
|
||||
|
||||
/// No description provided for @favoriteGroupsChipLabel.
|
||||
///
|
||||
/// In es, this message translates to:
|
||||
/// **'{groupName} · {count}'**
|
||||
String favoriteGroupsChipLabel(Object groupName, int count);
|
||||
|
||||
/// No description provided for @favoriteGroupsManage.
|
||||
///
|
||||
/// In es, this message translates to:
|
||||
/// **'Gestionar listas'**
|
||||
String get favoriteGroupsManage;
|
||||
|
||||
/// No description provided for @customStationsAddCta.
|
||||
///
|
||||
/// In es, this message translates to:
|
||||
/// **'Añadir emisora personalizada'**
|
||||
String get customStationsAddCta;
|
||||
|
||||
/// No description provided for @alarmPostponedCurrentExecution.
|
||||
///
|
||||
/// In es, this message translates to:
|
||||
|
||||
@@ -533,6 +533,20 @@ class AppLocalizationsAr extends AppLocalizations {
|
||||
return 'تمت إزالة $stationName من المفضلة';
|
||||
}
|
||||
|
||||
@override
|
||||
String get favoritesFilterAllLabel => 'Todas';
|
||||
|
||||
@override
|
||||
String favoriteGroupsChipLabel(Object groupName, int count) {
|
||||
return '$groupName · $count';
|
||||
}
|
||||
|
||||
@override
|
||||
String get favoriteGroupsManage => 'Gestionar listas';
|
||||
|
||||
@override
|
||||
String get customStationsAddCta => 'Añadir emisora personalizada';
|
||||
|
||||
@override
|
||||
String get alarmPostponedCurrentExecution => 'تم تأجيل المنبه لهذا التشغيل.';
|
||||
|
||||
|
||||
@@ -536,6 +536,20 @@ class AppLocalizationsBn extends AppLocalizations {
|
||||
return '$stationName প্রিয় থেকে সরানো হয়েছে';
|
||||
}
|
||||
|
||||
@override
|
||||
String get favoritesFilterAllLabel => 'Todas';
|
||||
|
||||
@override
|
||||
String favoriteGroupsChipLabel(Object groupName, int count) {
|
||||
return '$groupName · $count';
|
||||
}
|
||||
|
||||
@override
|
||||
String get favoriteGroupsManage => 'Gestionar listas';
|
||||
|
||||
@override
|
||||
String get customStationsAddCta => 'Añadir emisora personalizada';
|
||||
|
||||
@override
|
||||
String get alarmPostponedCurrentExecution =>
|
||||
'এই চালনার জন্য অ্যালার্ম পিছিয়ে দেওয়া হয়েছে।';
|
||||
|
||||
@@ -539,6 +539,20 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
return '$stationName aus Favoriten entfernt';
|
||||
}
|
||||
|
||||
@override
|
||||
String get favoritesFilterAllLabel => 'Todas';
|
||||
|
||||
@override
|
||||
String favoriteGroupsChipLabel(Object groupName, int count) {
|
||||
return '$groupName · $count';
|
||||
}
|
||||
|
||||
@override
|
||||
String get favoriteGroupsManage => 'Gestionar listas';
|
||||
|
||||
@override
|
||||
String get customStationsAddCta => 'Añadir emisora personalizada';
|
||||
|
||||
@override
|
||||
String get alarmPostponedCurrentExecution =>
|
||||
'Alarm für diese Ausführung verschoben.';
|
||||
|
||||
@@ -533,6 +533,20 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
return '$stationName removed from favorites';
|
||||
}
|
||||
|
||||
@override
|
||||
String get favoritesFilterAllLabel => 'All';
|
||||
|
||||
@override
|
||||
String favoriteGroupsChipLabel(Object groupName, int count) {
|
||||
return '$groupName · $count';
|
||||
}
|
||||
|
||||
@override
|
||||
String get favoriteGroupsManage => 'Manage lists';
|
||||
|
||||
@override
|
||||
String get customStationsAddCta => 'Add custom station';
|
||||
|
||||
@override
|
||||
String get alarmPostponedCurrentExecution =>
|
||||
'Alarm postponed for this occurrence.';
|
||||
|
||||
@@ -537,6 +537,20 @@ class AppLocalizationsEs extends AppLocalizations {
|
||||
return '$stationName eliminada de favoritos';
|
||||
}
|
||||
|
||||
@override
|
||||
String get favoritesFilterAllLabel => 'Todas';
|
||||
|
||||
@override
|
||||
String favoriteGroupsChipLabel(Object groupName, int count) {
|
||||
return '$groupName · $count';
|
||||
}
|
||||
|
||||
@override
|
||||
String get favoriteGroupsManage => 'Gestionar listas';
|
||||
|
||||
@override
|
||||
String get customStationsAddCta => 'Añadir emisora personalizada';
|
||||
|
||||
@override
|
||||
String get alarmPostponedCurrentExecution =>
|
||||
'Alarma pospuesta para esta ejecución.';
|
||||
|
||||
@@ -541,6 +541,20 @@ class AppLocalizationsFr extends AppLocalizations {
|
||||
return '$stationName retirée des favoris';
|
||||
}
|
||||
|
||||
@override
|
||||
String get favoritesFilterAllLabel => 'Todas';
|
||||
|
||||
@override
|
||||
String favoriteGroupsChipLabel(Object groupName, int count) {
|
||||
return '$groupName · $count';
|
||||
}
|
||||
|
||||
@override
|
||||
String get favoriteGroupsManage => 'Gestionar listas';
|
||||
|
||||
@override
|
||||
String get customStationsAddCta => 'Añadir emisora personalizada';
|
||||
|
||||
@override
|
||||
String get alarmPostponedCurrentExecution =>
|
||||
'Alarme reportée pour cette exécution.';
|
||||
|
||||
@@ -534,6 +534,20 @@ class AppLocalizationsHi extends AppLocalizations {
|
||||
return '$stationName पसंदीदा से हटाया गया';
|
||||
}
|
||||
|
||||
@override
|
||||
String get favoritesFilterAllLabel => 'Todas';
|
||||
|
||||
@override
|
||||
String favoriteGroupsChipLabel(Object groupName, int count) {
|
||||
return '$groupName · $count';
|
||||
}
|
||||
|
||||
@override
|
||||
String get favoriteGroupsManage => 'Gestionar listas';
|
||||
|
||||
@override
|
||||
String get customStationsAddCta => 'Añadir emisora personalizada';
|
||||
|
||||
@override
|
||||
String get alarmPostponedCurrentExecution =>
|
||||
'इस चाल के लिए अलार्म स्थगित किया गया।';
|
||||
|
||||
@@ -535,6 +535,20 @@ class AppLocalizationsId extends AppLocalizations {
|
||||
return '$stationName dihapus dari favorit';
|
||||
}
|
||||
|
||||
@override
|
||||
String get favoritesFilterAllLabel => 'Todas';
|
||||
|
||||
@override
|
||||
String favoriteGroupsChipLabel(Object groupName, int count) {
|
||||
return '$groupName · $count';
|
||||
}
|
||||
|
||||
@override
|
||||
String get favoriteGroupsManage => 'Gestionar listas';
|
||||
|
||||
@override
|
||||
String get customStationsAddCta => 'Añadir emisora personalizada';
|
||||
|
||||
@override
|
||||
String get alarmPostponedCurrentExecution =>
|
||||
'Alarm ditunda untuk eksekusi ini.';
|
||||
|
||||
@@ -537,6 +537,20 @@ class AppLocalizationsIt extends AppLocalizations {
|
||||
return '$stationName rimossa dai preferiti';
|
||||
}
|
||||
|
||||
@override
|
||||
String get favoritesFilterAllLabel => 'Todas';
|
||||
|
||||
@override
|
||||
String favoriteGroupsChipLabel(Object groupName, int count) {
|
||||
return '$groupName · $count';
|
||||
}
|
||||
|
||||
@override
|
||||
String get favoriteGroupsManage => 'Gestionar listas';
|
||||
|
||||
@override
|
||||
String get customStationsAddCta => 'Añadir emisora personalizada';
|
||||
|
||||
@override
|
||||
String get alarmPostponedCurrentExecution =>
|
||||
'Sveglia posticipata per questa esecuzione.';
|
||||
|
||||
@@ -518,6 +518,20 @@ class AppLocalizationsJa extends AppLocalizations {
|
||||
return '$stationName をお気に入りから削除しました';
|
||||
}
|
||||
|
||||
@override
|
||||
String get favoritesFilterAllLabel => 'Todas';
|
||||
|
||||
@override
|
||||
String favoriteGroupsChipLabel(Object groupName, int count) {
|
||||
return '$groupName · $count';
|
||||
}
|
||||
|
||||
@override
|
||||
String get favoriteGroupsManage => 'Gestionar listas';
|
||||
|
||||
@override
|
||||
String get customStationsAddCta => 'Añadir emisora personalizada';
|
||||
|
||||
@override
|
||||
String get alarmPostponedCurrentExecution => 'この実行のアラームを延期しました。';
|
||||
|
||||
|
||||
@@ -536,6 +536,20 @@ class AppLocalizationsPt extends AppLocalizations {
|
||||
return '$stationName removida dos favoritos';
|
||||
}
|
||||
|
||||
@override
|
||||
String get favoritesFilterAllLabel => 'Todas';
|
||||
|
||||
@override
|
||||
String favoriteGroupsChipLabel(Object groupName, int count) {
|
||||
return '$groupName · $count';
|
||||
}
|
||||
|
||||
@override
|
||||
String get favoriteGroupsManage => 'Gestionar listas';
|
||||
|
||||
@override
|
||||
String get customStationsAddCta => 'Añadir emisora personalizada';
|
||||
|
||||
@override
|
||||
String get alarmPostponedCurrentExecution =>
|
||||
'Alarme adiado para esta execução.';
|
||||
|
||||
@@ -537,6 +537,20 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
return '$stationName удалена из избранного';
|
||||
}
|
||||
|
||||
@override
|
||||
String get favoritesFilterAllLabel => 'Todas';
|
||||
|
||||
@override
|
||||
String favoriteGroupsChipLabel(Object groupName, int count) {
|
||||
return '$groupName · $count';
|
||||
}
|
||||
|
||||
@override
|
||||
String get favoriteGroupsManage => 'Gestionar listas';
|
||||
|
||||
@override
|
||||
String get customStationsAddCta => 'Añadir emisora personalizada';
|
||||
|
||||
@override
|
||||
String get alarmPostponedCurrentExecution =>
|
||||
'Будильник отложен для этого запуска.';
|
||||
|
||||
@@ -516,6 +516,20 @@ class AppLocalizationsZh extends AppLocalizations {
|
||||
return '$stationName 已从收藏中移除';
|
||||
}
|
||||
|
||||
@override
|
||||
String get favoritesFilterAllLabel => 'Todas';
|
||||
|
||||
@override
|
||||
String favoriteGroupsChipLabel(Object groupName, int count) {
|
||||
return '$groupName · $count';
|
||||
}
|
||||
|
||||
@override
|
||||
String get favoriteGroupsManage => 'Gestionar listas';
|
||||
|
||||
@override
|
||||
String get customStationsAddCta => 'Añadir emisora personalizada';
|
||||
|
||||
@override
|
||||
String get alarmPostponedCurrentExecution => '本次闹钟已推迟。';
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ import '../../widgets/pluri_layout.dart';
|
||||
import '../../widgets/pluri_push_scaffold.dart';
|
||||
|
||||
/// EMISORAS group · "Emisoras personalizadas" (design ADR-3). Body moved
|
||||
/// verbatim from the former `_SeccionEmisoras` + `_FormularioEmisora` in
|
||||
/// verbatim from the former `_SeccionEmisoras` + `FormularioEmisoraPersonalizada` in
|
||||
/// `pantalla_ajustes.dart` — the panel header's icon, title and spacer were
|
||||
/// removed (the pushed screen's title now carries them); the "Add" action,
|
||||
/// being a real capability rather than decorative chrome, stays in the body,
|
||||
@@ -110,19 +110,21 @@ class _CuerpoEmisorasPersonalizadas extends StatelessWidget {
|
||||
isScrollControlled: true,
|
||||
useSafeArea: true,
|
||||
showDragHandle: true,
|
||||
builder: (ctx) => const _FormularioEmisora(),
|
||||
builder: (ctx) => const FormularioEmisoraPersonalizada(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _FormularioEmisora extends StatefulWidget {
|
||||
const _FormularioEmisora();
|
||||
class FormularioEmisoraPersonalizada extends StatefulWidget {
|
||||
const FormularioEmisoraPersonalizada({super.key});
|
||||
|
||||
@override
|
||||
State<_FormularioEmisora> createState() => _FormularioEmisoraState();
|
||||
State<FormularioEmisoraPersonalizada> createState() =>
|
||||
FormularioEmisoraPersonalizadaState();
|
||||
}
|
||||
|
||||
class _FormularioEmisoraState extends State<_FormularioEmisora> {
|
||||
class FormularioEmisoraPersonalizadaState
|
||||
extends State<FormularioEmisoraPersonalizada> {
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
final _nombreCtrl = TextEditingController();
|
||||
final _urlCtrl = TextEditingController();
|
||||
|
||||
@@ -6,24 +6,91 @@ import '../l10n/display_names.dart';
|
||||
import '../l10n/gen/app_localizations.dart';
|
||||
import '../modelos/emisora.dart';
|
||||
import '../modelos/grupo_favoritos.dart';
|
||||
import '../widgets/pluri_glass_surface.dart';
|
||||
import '../widgets/pluri_icon.dart';
|
||||
import '../widgets/pluri_layout.dart';
|
||||
import '../widgets/pluri_premium_widgets.dart';
|
||||
import '../widgets/pluri_push_scaffold.dart';
|
||||
import 'package:pluriwave/widgets/tarjeta_emisora.dart';
|
||||
import 'ajustes/pantalla_ajustes_emisoras_personalizadas.dart';
|
||||
import 'ajustes/pantalla_ajustes_grupos_favoritos.dart';
|
||||
|
||||
import 'reproducir_minimizado.dart';
|
||||
|
||||
class PantallaFavoritos extends StatelessWidget {
|
||||
/// WU4, `favorites-organization` spec: a chip-filtered flat list replacing
|
||||
/// the previous stacked per-group panels, with drag-to-reorder, a `swap_vert`
|
||||
/// sort action, group management, and the custom-station CTA all reachable
|
||||
/// from this root screen. Favoritos is the one root that keeps its bottom
|
||||
/// tab bar (design ADR-2's documented exemption) — this file constructs no
|
||||
/// `PluriPushScaffold` and stays a plain body widget for that reason.
|
||||
class PantallaFavoritos extends StatefulWidget {
|
||||
const PantallaFavoritos({super.key});
|
||||
|
||||
@override
|
||||
State<PantallaFavoritos> createState() => _PantallaFavoritosState();
|
||||
}
|
||||
|
||||
class _PantallaFavoritosState extends State<PantallaFavoritos> {
|
||||
/// Ephemeral UI state only (design's "State is for ephemeral UI only"
|
||||
/// ruling) — null means the "All" chip is active.
|
||||
String? _grupoSeleccionadoId;
|
||||
|
||||
Future<void> _abrirFormularioEmisoraPersonalizada() async {
|
||||
await showModalBottomSheet(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
useSafeArea: true,
|
||||
showDragHandle: true,
|
||||
builder: (ctx) => const FormularioEmisoraPersonalizada(),
|
||||
);
|
||||
}
|
||||
|
||||
void _abrirGestionDeListas() {
|
||||
PluriPushScaffold.push(
|
||||
context,
|
||||
(_) => const PantallaAjustesGruposFavoritos(),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _elegirOrden(OrdenEmisoras criterio) =>
|
||||
context.read<EstadoRadio>().ordenarFavoritos(criterio);
|
||||
|
||||
/// Translates a drag within the currently FILTERED view into the absolute
|
||||
/// global position [EstadoRadio.reordenarFavorito] expects, so a drag
|
||||
/// while a group chip is active still produces a coherent global order
|
||||
/// (other groups' relative order is left untouched).
|
||||
void _onReorder(
|
||||
List<Emisora> filtrados,
|
||||
List<Emisora> favoritos,
|
||||
int oldIndex,
|
||||
int newIndex,
|
||||
) {
|
||||
final movido = filtrados[oldIndex];
|
||||
final restantes = List<Emisora>.from(filtrados)..removeAt(oldIndex);
|
||||
final int nuevoIndiceGlobal;
|
||||
if (restantes.isEmpty) {
|
||||
nuevoIndiceGlobal = favoritos.length - 1;
|
||||
} else if (newIndex >= restantes.length) {
|
||||
nuevoIndiceGlobal = favoritos.indexWhere(
|
||||
(e) => e.uuid == restantes.last.uuid,
|
||||
);
|
||||
} else {
|
||||
nuevoIndiceGlobal = favoritos.indexWhere(
|
||||
(e) => e.uuid == restantes[newIndex].uuid,
|
||||
);
|
||||
}
|
||||
context.read<EstadoRadio>().reordenarFavorito(
|
||||
movido.uuid,
|
||||
nuevoIndiceGlobal,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// S4-R5: no root watch — select only the fields this screen reads. The
|
||||
// getters are identity-memoized, so playback notifications that do not
|
||||
// change favorites/groups no longer rebuild the screen.
|
||||
final favoritos = context.select<EstadoRadio, List<Emisora>>(
|
||||
(e) => e.listaFavoritos,
|
||||
(e) => e.listaFavoritosManual,
|
||||
);
|
||||
final grupos = context.select<EstadoRadio, List<GrupoFavoritos>>(
|
||||
(e) => e.gruposFavoritos,
|
||||
@@ -51,6 +118,12 @@ class PantallaFavoritos extends StatelessWidget {
|
||||
subtitle: l10n.favoritesEmptySubtitle,
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: PluriLayout.pageContentPadding,
|
||||
child: _CtaEmisoraPersonalizada(
|
||||
onTap: _abrirFormularioEmisoraPersonalizada,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
@@ -67,57 +140,117 @@ class PantallaFavoritos extends StatelessWidget {
|
||||
]
|
||||
: grupos;
|
||||
|
||||
return CustomScrollView(
|
||||
slivers: [
|
||||
SliverToBoxAdapter(
|
||||
child: PluriScreenHeader(
|
||||
title: l10n.favoritesTitle,
|
||||
subtitle: l10n.favoritesHeaderSubtitle,
|
||||
glyph: PluriIconGlyph.favorites,
|
||||
trailing: PluriStatusPill(
|
||||
icon: Icons.library_music_rounded,
|
||||
label: l10n.favoritesSavedCount(favoritos.length),
|
||||
// Defensive: a group selected before it was deleted elsewhere (e.g. via
|
||||
// the pushed management screen) falls back to "All" instead of showing
|
||||
// an empty list with no chip highlighted.
|
||||
final seleccionEfectiva =
|
||||
gruposVisibles.any((g) => g.id == _grupoSeleccionadoId)
|
||||
? _grupoSeleccionadoId
|
||||
: null;
|
||||
|
||||
final filtrados =
|
||||
seleccionEfectiva == null
|
||||
? favoritos
|
||||
: favoritos
|
||||
.where((e) => e.grupoFavoritosId == seleccionEfectiva)
|
||||
.toList();
|
||||
|
||||
return ReorderableListView(
|
||||
buildDefaultDragHandles: false,
|
||||
padding: const EdgeInsets.fromLTRB(
|
||||
PluriLayout.horizontal,
|
||||
4,
|
||||
PluriLayout.horizontal,
|
||||
PluriLayout.bottomChromeInset,
|
||||
),
|
||||
header: Padding(
|
||||
padding: const EdgeInsets.only(bottom: 12),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
PluriScreenHeader(
|
||||
title: l10n.favoritesTitle,
|
||||
subtitle: l10n.favoritesHeaderSubtitle,
|
||||
glyph: PluriIconGlyph.favorites,
|
||||
trailing: PluriStatusPill(
|
||||
icon: Icons.library_music_rounded,
|
||||
label: l10n.favoritesSavedCount(favoritos.length),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _FilaChipsGrupos(
|
||||
grupos: gruposVisibles,
|
||||
favoritos: favoritos,
|
||||
seleccionado: seleccionEfectiva,
|
||||
onSeleccionar:
|
||||
(id) => setState(() => _grupoSeleccionadoId = id),
|
||||
onGestionar: _abrirGestionDeListas,
|
||||
),
|
||||
),
|
||||
PopupMenuButton<OrdenEmisoras>(
|
||||
icon: const Icon(Icons.swap_vert_rounded),
|
||||
tooltip: l10n.stationOrderTitle,
|
||||
onSelected: _elegirOrden,
|
||||
itemBuilder:
|
||||
(context) => [
|
||||
PopupMenuItem(
|
||||
value: OrdenEmisoras.nombre,
|
||||
child: Text(l10n.stationOrderByName),
|
||||
),
|
||||
PopupMenuItem(
|
||||
value: OrdenEmisoras.calidad,
|
||||
child: Text(l10n.stationOrderByQuality),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
footer: Padding(
|
||||
padding: const EdgeInsets.only(top: 4),
|
||||
child: _CtaEmisoraPersonalizada(
|
||||
onTap: _abrirFormularioEmisoraPersonalizada,
|
||||
),
|
||||
),
|
||||
onReorderItem:
|
||||
(oldIndex, newIndex) =>
|
||||
_onReorder(filtrados, favoritos, oldIndex, newIndex),
|
||||
children: [
|
||||
for (var i = 0; i < filtrados.length; i++)
|
||||
_FilaFavorito(
|
||||
key: ValueKey(filtrados[i].uuid),
|
||||
index: i,
|
||||
emisora: filtrados[i],
|
||||
grupos: gruposVisibles,
|
||||
grupoActual: gruposVisibles.firstWhere(
|
||||
(g) => g.id == filtrados[i].grupoFavoritosId,
|
||||
orElse: () => gruposVisibles.first,
|
||||
),
|
||||
),
|
||||
),
|
||||
SliverPadding(
|
||||
padding: const EdgeInsets.fromLTRB(
|
||||
PluriLayout.horizontal,
|
||||
4,
|
||||
PluriLayout.horizontal,
|
||||
PluriLayout.bottomChromeInset,
|
||||
),
|
||||
sliver: SliverList(
|
||||
delegate: SliverChildListDelegate([
|
||||
for (final grupo in gruposVisibles) ...[
|
||||
_GrupoFavoritosPanel(
|
||||
grupo: grupo,
|
||||
grupos: gruposVisibles,
|
||||
emisoras:
|
||||
favoritos
|
||||
.where((e) => e.grupoFavoritosId == grupo.id)
|
||||
.toList(),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
],
|
||||
]),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _GrupoFavoritosPanel extends StatelessWidget {
|
||||
const _GrupoFavoritosPanel({
|
||||
required this.grupo,
|
||||
class _FilaChipsGrupos extends StatelessWidget {
|
||||
const _FilaChipsGrupos({
|
||||
required this.grupos,
|
||||
required this.emisoras,
|
||||
required this.favoritos,
|
||||
required this.seleccionado,
|
||||
required this.onSeleccionar,
|
||||
required this.onGestionar,
|
||||
});
|
||||
|
||||
final GrupoFavoritos grupo;
|
||||
final List<GrupoFavoritos> grupos;
|
||||
final List<Emisora> emisoras;
|
||||
final List<Emisora> favoritos;
|
||||
final String? seleccionado;
|
||||
final ValueChanged<String?> onSeleccionar;
|
||||
final VoidCallback onGestionar;
|
||||
|
||||
String _nombreVisible(AppLocalizations l10n, GrupoFavoritos grupo) =>
|
||||
grupo.esSinAsignar ? l10n.favoriteGroupsUnassigned : grupo.nombre;
|
||||
@@ -125,61 +258,61 @@ class _GrupoFavoritosPanel extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final theme = Theme.of(context);
|
||||
return PluriGlassSurface(
|
||||
padding: const EdgeInsets.all(10),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
return SizedBox(
|
||||
height: 40,
|
||||
child: ListView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
grupo.esSinAsignar ? Icons.lock_rounded : Icons.folder_rounded,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
_nombreVisible(l10n, grupo),
|
||||
style: theme.textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w900,
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: 8),
|
||||
child: ChoiceChip(
|
||||
label: Text(
|
||||
l10n.favoriteGroupsChipLabel(
|
||||
l10n.favoritesFilterAllLabel,
|
||||
favoritos.length,
|
||||
),
|
||||
),
|
||||
// S5-R5: proper plural message, not a bare number.
|
||||
Text(l10n.stationCount(emisoras.length)),
|
||||
],
|
||||
selected: seleccionado == null,
|
||||
onSelected: (_) => onSeleccionar(null),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
if (emisoras.isEmpty)
|
||||
for (final grupo in grupos)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 4),
|
||||
child: Text(
|
||||
l10n.favoritesEmptyTitle,
|
||||
style: theme.textTheme.bodySmall,
|
||||
padding: const EdgeInsets.only(right: 8),
|
||||
child: ChoiceChip(
|
||||
label: Text(
|
||||
l10n.favoriteGroupsChipLabel(
|
||||
_nombreVisible(l10n, grupo),
|
||||
favoritos
|
||||
.where((e) => e.grupoFavoritosId == grupo.id)
|
||||
.length,
|
||||
),
|
||||
),
|
||||
selected: seleccionado == grupo.id,
|
||||
onSelected: (_) => onSeleccionar(grupo.id),
|
||||
),
|
||||
)
|
||||
else
|
||||
for (var i = 0; i < emisoras.length; i++) ...[
|
||||
_FavoritoItem(
|
||||
emisora: emisoras[i],
|
||||
grupos: grupos,
|
||||
grupoActual: grupo,
|
||||
),
|
||||
if (i < emisoras.length - 1) const SizedBox(height: 8),
|
||||
],
|
||||
),
|
||||
ActionChip(
|
||||
avatar: const Icon(Icons.add_rounded, size: 18),
|
||||
label: Text(l10n.favoriteGroupsManage),
|
||||
onPressed: onGestionar,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _FavoritoItem extends StatelessWidget {
|
||||
const _FavoritoItem({
|
||||
class _FilaFavorito extends StatelessWidget {
|
||||
const _FilaFavorito({
|
||||
super.key,
|
||||
required this.index,
|
||||
required this.emisora,
|
||||
required this.grupos,
|
||||
required this.grupoActual,
|
||||
});
|
||||
|
||||
final int index;
|
||||
final Emisora emisora;
|
||||
final List<GrupoFavoritos> grupos;
|
||||
final GrupoFavoritos grupoActual;
|
||||
@@ -253,35 +386,123 @@ class _FavoritoItem extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
return Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: TarjetaEmisora(
|
||||
key: Key(emisora.uuid),
|
||||
emisora: emisora,
|
||||
esCompacta: true,
|
||||
onTap: () => reproducirMinimizado(context, emisora),
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8),
|
||||
child: Row(
|
||||
children: [
|
||||
ReorderableDragStartListener(
|
||||
index: index,
|
||||
child: const Padding(
|
||||
padding: EdgeInsets.only(right: 4),
|
||||
child: Icon(Icons.drag_handle_rounded),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
IconButton.filledTonal(
|
||||
tooltip: l10n.favoriteGroupsAssignSubtitle(
|
||||
_nombreVisible(l10n, grupoActual),
|
||||
Expanded(
|
||||
child: TarjetaEmisora(
|
||||
key: Key(emisora.uuid),
|
||||
emisora: emisora,
|
||||
esCompacta: true,
|
||||
onTap: () => reproducirMinimizado(context, emisora),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
IconButton.filledTonal(
|
||||
tooltip: l10n.favoriteGroupsAssignSubtitle(
|
||||
_nombreVisible(l10n, grupoActual),
|
||||
),
|
||||
icon: const Icon(Icons.drive_file_move_rounded),
|
||||
onPressed: () => _asignar(context),
|
||||
),
|
||||
icon: const Icon(Icons.drive_file_move_rounded),
|
||||
onPressed: () => _asignar(context),
|
||||
),
|
||||
IconButton.filledTonal(
|
||||
tooltip: l10n.favoritesRemoveTooltip,
|
||||
icon: const Icon(Icons.delete_outline_rounded),
|
||||
onPressed: () => _eliminar(context),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
IconButton.filledTonal(
|
||||
tooltip: l10n.favoritesRemoveTooltip,
|
||||
icon: const Icon(Icons.delete_outline_rounded),
|
||||
onPressed: () => _eliminar(context),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The dashed "Añadir emisora personalizada" CTA (favorites-organization
|
||||
/// spec, "Custom-Station CTA Preserved") — opens the SAME add-station form
|
||||
/// used from Settings' Emisoras personalizadas screen
|
||||
/// ([FormularioEmisoraPersonalizada]), not a duplicate.
|
||||
class _CtaEmisoraPersonalizada extends StatelessWidget {
|
||||
const _CtaEmisoraPersonalizada({required this.onTap});
|
||||
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final color = Theme.of(
|
||||
context,
|
||||
).colorScheme.onSurface.withValues(alpha: 0.5);
|
||||
return CustomPaint(
|
||||
painter: _DashedBorderPainter(color: color),
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
onTap: onTap,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 18, horizontal: 16),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(Icons.add_circle_outline_rounded, color: color),
|
||||
const SizedBox(width: 8),
|
||||
Text(l10n.customStationsAddCta, style: TextStyle(color: color)),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _DashedBorderPainter extends CustomPainter {
|
||||
const _DashedBorderPainter({required this.color});
|
||||
|
||||
final Color color;
|
||||
|
||||
static const _radius = 16.0;
|
||||
static const _dashWidth = 6.0;
|
||||
static const _gapWidth = 4.0;
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
final rrect = RRect.fromRectAndRadius(
|
||||
Offset.zero & size,
|
||||
const Radius.circular(_radius),
|
||||
);
|
||||
final path = Path()..addRRect(rrect);
|
||||
final paint =
|
||||
Paint()
|
||||
..color = color
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeWidth = 1.5;
|
||||
for (final metric in path.computeMetrics()) {
|
||||
var distance = 0.0;
|
||||
while (distance < metric.length) {
|
||||
final next = distance + _dashWidth;
|
||||
canvas.drawPath(
|
||||
metric.extractPath(distance, next.clamp(0.0, metric.length)),
|
||||
paint,
|
||||
);
|
||||
distance = next + _gapWidth;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(covariant _DashedBorderPainter oldDelegate) =>
|
||||
oldDelegate.color != color;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user