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 '../tema/pluriwave_tokens.dart'; import '../widgets/fila_emisora_plana.dart'; import '../widgets/pluri_icon.dart'; import '../widgets/pluri_layout.dart'; import '../widgets/pluri_premium_widgets.dart'; import '../widgets/pluri_push_scaffold.dart'; import '../widgets/pluri_root_header.dart'; import '../widgets/pluri_sleep_timer_sheet.dart'; import '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). /// /// [newIndex] arrives in `ReorderableListView.onReorder`'s pre-removal /// coordinate space: dragging downwards reports the slot the row would /// occupy while it is still in the list. The logic below indexes into the /// list AFTER the row is removed, so shift by one in that direction first. void _onReorder( List filtrados, List favoritos, int oldIndex, int newIndex, ) { if (newIndex > oldIndex) newIndex -= 1; final movido = filtrados[oldIndex]; final restantes = List.from(filtrados)..removeAt(oldIndex); // `ServicioFavoritos.reordenar` removes the station first and THEN // inserts at the index it is given, so the target index must be // expressed in the global list WITHOUT the moved station. Locating the // neighbour in the untrimmed list instead drifts by one whenever the // moved station sits before it. final globalSinMovido = favoritos.where((e) => e.uuid != movido.uuid).toList(); final int nuevoIndiceGlobal; if (restantes.isEmpty) { nuevoIndiceGlobal = globalSinMovido.length; } else if (newIndex >= restantes.length) { nuevoIndiceGlobal = globalSinMovido.indexWhere((e) => e.uuid == restantes.last.uuid) + 1; } else { nuevoIndiceGlobal = globalSinMovido.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: [ // S1 (Tier 1 visual fidelity): the prototype has no global // AppBar — this root now draws its own 56px title row instead of // relying on app.dart's removed shared chrome (which is also // where the sleep-timer action used to live). // S2 (Tier 1 visual fidelity): PluriScreenHeader (the glass hero // this used to be) is retired — it is not in the prototype at // all, and carried no functional action on this screen. PluriRootHeader( title: l10n.favoritesTitle, onSleepTimer: () => showPluriSleepTimerSheet(context), ), 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: [ // S1/S2 (Tier 1 visual fidelity): see the empty-state branch // above — PluriScreenHeader is retired everywhere. // // Audit 4.1 (t4:216): the prototype's two header icon actions // (create_new_folder, swap_vert) now live in PluriRootHeader's // own actions slot -- they used to be scattered as an // ActionChip inside the chip strip and a PopupMenuButton // sharing a Row with it. The back arrow the prototype also // draws stays absent (binding decision: this root keeps its // bottom tab bar, unlike the prototype's own pushed shape). PluriRootHeader( title: l10n.favoritesTitle, onSleepTimer: () => showPluriSleepTimerSheet(context), actions: [ IconButton( key: const ValueKey('favorites-manage-groups-action'), icon: const Icon(Icons.create_new_folder_rounded), tooltip: l10n.favoriteGroupsManage, onPressed: _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), ), ], ), ], ), const SizedBox(height: 12), _FilaChipsGrupos( grupos: gruposVisibles, favoritos: favoritos, seleccionado: seleccionEfectiva, onSeleccionar: (id) => setState(() => _grupoSeleccionadoId = id), ), ], ), ), footer: Padding( padding: const EdgeInsets.only(top: 4), child: _CtaEmisoraPersonalizada( onTap: _abrirFormularioEmisoraPersonalizada, ), ), onReorder: (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, }); final List grupos; final List favoritos; final String? seleccionado; final ValueChanged onSeleccionar; String _nombreVisible(AppLocalizations l10n, GrupoFavoritos grupo) => grupo.esSinAsignar ? l10n.favoriteGroupsUnassigned : grupo.nombre; /// Audit 4.2 (t4:219-221): active `#21D4D9`/`#062126` w800, inactive /// `listSurface` + a faint border / w700 -- was Material's own /// `ChoiceChip` theming (a plain checkbox-style selected fill). Widget _chip({ required String label, required bool selected, required VoidCallback onTap, }) { return ChoiceChip( label: Text(label), labelStyle: TextStyle( fontWeight: selected ? FontWeight.w800 : FontWeight.w700, color: selected ? const Color(0xFF062126) : const Color(0xFFF2F7FA), ), selected: selected, showCheckmark: false, selectedColor: PluriWaveTokens.brand, backgroundColor: PluriWaveTokens.dark.listSurface, side: BorderSide( color: selected ? Colors.transparent : Colors.white.withValues(alpha: 0.09), ), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), onSelected: (_) => onTap(), ); } @override Widget build(BuildContext context) { final l10n = AppLocalizations.of(context); return SizedBox( height: 40, child: ListView( scrollDirection: Axis.horizontal, children: [ Padding( padding: const EdgeInsets.only(right: 8), child: _chip( label: l10n.favoriteGroupsChipLabel( l10n.favoritesFilterAllLabel, favoritos.length, ), selected: seleccionado == null, onTap: () => onSeleccionar(null), ), ), for (final grupo in grupos) Padding( padding: const EdgeInsets.only(right: 8), child: _chip( label: l10n.favoriteGroupsChipLabel( _nombreVisible(l10n, grupo), favoritos.where((e) => e.grupoFavoritosId == grupo.id).length, ), selected: seleccionado == grupo.id, onTap: () => onSeleccionar(grupo.id), ), ), ], ), ); } } 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); final meta = [ emisora.pais, emisora.idioma, ].where((s) => s != null && s.isNotEmpty).join(' · '); // Item 23 / audit 4.3 (t4:226-232): a flat, background-less row -- // drag handle, square art, name+meta, and a circular play affordance -- // replacing the full glass TarjetaEmisora card and its two stacked // filledTonal buttons. "Move to list"/"Remove from favorites" keep their // EXACT prior logic (`_asignar`/`_eliminar`, untouched), now reachable // from an overflow menu instead of two always-visible buttons -- the // prototype's row has no such menu, but dropping either capability // entirely would be a functional regression, not a fidelity fix. return FilaEmisoraPlana( key: Key(emisora.uuid), emisora: emisora, meta: meta, onTap: () => reproducirMinimizado(context, emisora), leading: ReorderableDragStartListener( index: index, child: Icon( // t4:227: drag_indicator, not drag_handle, at 22px/28%. Icons.drag_indicator_rounded, size: 22, color: Theme.of( context, ).colorScheme.onSurface.withValues(alpha: 0.28), ), ), trailing: [ BotonReproducirCircular( onPressed: () => reproducirMinimizado(context, emisora), ), PopupMenuButton( icon: Icon( Icons.more_vert_rounded, size: 20, color: Theme.of( context, ).colorScheme.onSurface.withValues(alpha: 0.45), ), constraints: const BoxConstraints.tightFor(width: 38, height: 42), onSelected: (accion) { if (accion == 'assign') _asignar(context); if (accion == 'remove') _eliminar(context); }, itemBuilder: (context) => [ PopupMenuItem( value: 'assign', child: Text(l10n.favoriteGroupsAssign), ), PopupMenuItem( value: 'remove', child: Text(l10n.favoritesRemoveTooltip), ), ], ), ], ); } } /// 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); // Audit 4.5 (t4 line 235): the border and the label/icon are TWO // different opacities in the prototype — `rgba(255,255,255,.16)` for // the dashed stroke, `rgba(242,247,250,.6)` for the text/icon — not one // shared 50% colour for both. final colorBorde = Colors.white.withValues(alpha: 0.16); final colorTexto = Theme.of( context, ).colorScheme.onSurface.withValues(alpha: 0.6); return CustomPaint( painter: _DashedBorderPainter(color: colorBorde), child: Material( color: Colors.transparent, child: InkWell( borderRadius: BorderRadius.circular(16), onTap: onTap, child: Padding( key: const Key('custom-station-cta-padding'), padding: const EdgeInsets.all(14), child: Row( mainAxisAlignment: MainAxisAlignment.center, children: [ Icon(Icons.add_rounded, size: 20, color: colorTexto), const SizedBox(width: 8), Text( l10n.customStationsAddCta, style: TextStyle( fontSize: 13.5, fontWeight: FontWeight.w800, color: colorTexto, ), ), ], ), ), ), ), ); } } class _DashedBorderPainter extends CustomPainter { const _DashedBorderPainter({required this.color}); final Color color; static const _radius = 16.0; static const _dashWidth = 6.0; static const _gapWidth = 4.0; @override void paint(Canvas canvas, Size size) { final rrect = RRect.fromRectAndRadius( Offset.zero & size, const Radius.circular(_radius), ); final path = Path()..addRRect(rrect); final paint = Paint() ..color = color ..style = PaintingStyle.stroke ..strokeWidth = 1.5; for (final metric in path.computeMetrics()) { var distance = 0.0; while (distance < metric.length) { final next = distance + _dashWidth; canvas.drawPath( metric.extractPath(distance, next.clamp(0.0, metric.length)), paint, ); distance = next + _gapWidth; } } } @override bool shouldRepaint(covariant _DashedBorderPainter oldDelegate) => oldDelegate.color != color; }