import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import '../estado/estado_radio.dart'; import '../l10n/display_names.dart'; import '../l10n/gen/app_localizations.dart'; import '../modelos/emisora.dart'; import '../modelos/grupo_favoritos.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'; /// 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 createState() => _PantallaFavoritosState(); } class _PantallaFavoritosState extends State { /// Ephemeral UI state only (design's "State is for ephemeral UI only" /// ruling) — null means the "All" chip is active. String? _grupoSeleccionadoId; Future _abrirFormularioEmisoraPersonalizada() async { await showModalBottomSheet( context: context, isScrollControlled: true, useSafeArea: true, showDragHandle: true, builder: (ctx) => const FormularioEmisoraPersonalizada(), ); } void _abrirGestionDeListas() { PluriPushScaffold.push( context, (_) => const PantallaAjustesGruposFavoritos(), ); } Future _elegirOrden(OrdenEmisoras criterio) => context.read().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 filtrados, List favoritos, int oldIndex, int newIndex, ) { final movido = filtrados[oldIndex]; final restantes = List.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().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>( (e) => e.listaFavoritosManual, ); final grupos = context.select>( (e) => e.gruposFavoritos, ); final l10n = AppLocalizations.of(context); if (favoritos.isEmpty) { return ListView( padding: PluriLayout.pageListPadding, children: [ PluriScreenHeader( title: l10n.favoritesTitle, subtitle: l10n.favoritesHeaderSubtitle, glyph: PluriIconGlyph.favorites, trailing: PluriStatusPill( icon: Icons.favorite_rounded, label: l10n.favoritesCollection, ), ), SizedBox( height: 320, child: PluriEmptyState( glyph: PluriIconGlyph.favorites, title: l10n.favoritesEmptyTitle, subtitle: l10n.favoritesEmptySubtitle, ), ), Padding( padding: PluriLayout.pageContentPadding, child: _CtaEmisoraPersonalizada( onTap: _abrirFormularioEmisoraPersonalizada, ), ), ], ); } final gruposVisibles = grupos.isEmpty ? [ GrupoFavoritos( id: GrupoFavoritos.sinAsignarId, nombre: l10n.favoriteGroupsUnassigned, orden: 0, protegido: true, ), ] : grupos; // 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( 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, ), ), ], ); } } class _FilaChipsGrupos extends StatelessWidget { const _FilaChipsGrupos({ required this.grupos, required this.favoritos, required this.seleccionado, required this.onSeleccionar, required this.onGestionar, }); final List grupos; final List favoritos; final String? seleccionado; final ValueChanged onSeleccionar; final VoidCallback onGestionar; String _nombreVisible(AppLocalizations l10n, GrupoFavoritos grupo) => grupo.esSinAsignar ? l10n.favoriteGroupsUnassigned : grupo.nombre; @override Widget build(BuildContext context) { final l10n = AppLocalizations.of(context); return SizedBox( height: 40, child: ListView( scrollDirection: Axis.horizontal, children: [ Padding( padding: const EdgeInsets.only(right: 8), child: ChoiceChip( label: Text( l10n.favoriteGroupsChipLabel( l10n.favoritesFilterAllLabel, favoritos.length, ), ), selected: seleccionado == null, onSelected: (_) => onSeleccionar(null), ), ), for (final grupo in grupos) Padding( padding: const EdgeInsets.only(right: 8), child: ChoiceChip( label: Text( l10n.favoriteGroupsChipLabel( _nombreVisible(l10n, grupo), favoritos .where((e) => e.grupoFavoritosId == grupo.id) .length, ), ), selected: seleccionado == grupo.id, onSelected: (_) => onSeleccionar(grupo.id), ), ), ActionChip( avatar: const Icon(Icons.add_rounded, size: 18), label: Text(l10n.favoriteGroupsManage), onPressed: onGestionar, ), ], ), ); } } 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 grupos; final GrupoFavoritos grupoActual; String _nombreVisible(AppLocalizations l10n, GrupoFavoritos grupo) => grupo.esSinAsignar ? l10n.favoriteGroupsUnassigned : grupo.nombre; Future _asignar(BuildContext context) async { final l10n = AppLocalizations.of(context); final seleccionado = await showModalBottomSheet( context: context, showDragHandle: true, builder: (ctx) => SafeArea( child: ListView( shrinkWrap: true, children: [ Padding( padding: const EdgeInsets.fromLTRB(20, 4, 20, 12), child: Text( l10n.favoriteGroupsAssign, style: Theme.of(ctx).textTheme.titleLarge, ), ), for (final grupo in grupos) ListTile( leading: Icon( grupo.id == emisora.grupoFavoritosId ? Icons.radio_button_checked_rounded : Icons.radio_button_off_rounded, ), title: Text(_nombreVisible(l10n, grupo)), onTap: () => Navigator.pop(ctx, grupo.id), ), ], ), ), ); if (seleccionado == null || !context.mounted) return; await context.read().asignarGrupoFavorito( emisora.uuid, seleccionado, ); if (!context.mounted) return; final destino = grupos.firstWhere((g) => g.id == seleccionado); final stationName = localizedStationName(l10n, emisora.nombre); ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text( l10n.favoriteGroupsAssigned( stationName, _nombreVisible(l10n, destino), ), ), ), ); } Future _eliminar(BuildContext context) async { final l10n = AppLocalizations.of(context); final estado = context.read(); final stationName = localizedStationName(l10n, emisora.nombre); await estado.favoritos.eliminar(emisora.uuid); await estado.cargarFavoritos(); if (!context.mounted) return; ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text(l10n.favoritesRemovedMessage(stationName))), ); } @override Widget build(BuildContext context) { final l10n = AppLocalizations.of(context); return Padding( padding: const EdgeInsets.only(bottom: 8), child: Row( children: [ ReorderableDragStartListener( index: index, child: const Padding( padding: EdgeInsets.only(right: 4), child: Icon(Icons.drag_handle_rounded), ), ), Expanded( child: TarjetaEmisora( key: Key(emisora.uuid), emisora: emisora, esCompacta: true, onTap: () => reproducirMinimizado(context, emisora), ), ), const SizedBox(width: 6), Column( mainAxisSize: MainAxisSize.min, children: [ IconButton.filledTonal( tooltip: l10n.favoriteGroupsAssignSubtitle( _nombreVisible(l10n, grupoActual), ), icon: const Icon(Icons.drive_file_move_rounded), onPressed: () => _asignar(context), ), IconButton.filledTonal( tooltip: l10n.favoritesRemoveTooltip, icon: const Icon(Icons.delete_outline_rounded), onPressed: () => _eliminar(context), ), ], ), ], ), ); } } /// 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; }