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 '../servicios/servicio_anuncios.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 { // ad-display spec "Interstitial Before Manual Station Add" (design.md // ADR-6): fires on the CTA tap, before the form even opens — a no-op // for premium (ServicioAnuncios' own entitlement gate). await context.read().intentarInterstitial(); if (!mounted) return; 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, // Issue 3 (feedback-pruebas): zero horizontal here, matching every // other root's PluriLayout.pageListPadding convention (Alarmas, // Ajustes, and this screen's OWN empty-state branch above). // ReorderableListView.padding wraps header/children/footer UNIFORMLY, // so a single horizontal value here can never be simultaneously right // for PluriRootHeader (self-padded, wants none), the reorderable rows // (want row tier, applied per item below) and the footer CTA (wants // card tier, applied on the footer's own Padding below). The previous // `PluriLayout.horizontal` doubled up on top of PluriRootHeader's own // internal inset, pushing "Favorites" in by 36px instead of the 20px // every other root uses for its title. padding: const EdgeInsets.only(bottom: 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), // Issue 3 (feedback-pruebas): t4:218 draws this chip strip at // title-tier (20px) horizontal inset, directly on the page // background -- it now needs its OWN inset since the list's // padding no longer supplies one. Padding( padding: const EdgeInsets.symmetric( horizontal: PluriLayout.titleHorizontal, ), child: _FilaChipsGrupos( grupos: gruposVisibles, favoritos: favoritos, seleccionado: seleccionEfectiva, onSeleccionar: (id) => setState(() => _grupoSeleccionadoId = id), ), ), ], ), ), footer: Padding( // Issue 3 (feedback-pruebas): card tier (16, matching every other // screen's dashed CTA) now that the list's own padding no longer // supplies it, plus t4:234's 8px gap above the CTA // (PluriLayout.compactGap) instead of the previous unwired literal // 4 -- the ONLY state of this screen with a nonzero top gap before // its own content used a value that matched neither this screen's // own empty-state branch nor the prototype. padding: const EdgeInsets.fromLTRB( PluriLayout.horizontal, PluriLayout.compactGap, PluriLayout.horizontal, 0, ), child: _CtaEmisoraPersonalizada( onTap: _abrirFormularioEmisoraPersonalizada, ), ), onReorder: (oldIndex, newIndex) => _onReorder(filtrados, favoritos, oldIndex, newIndex), children: [ for (var i = 0; i < filtrados.length; i++) // Issue 3 (feedback-pruebas): row tier (12), not card tier -- the // key moves to this wrapper (ReorderableListView identifies each // child by its own top-level key) since FilaEmisoraPlana rows are // documented (audit 4.3) as flat, background-less rows, the same // tier Buscar's results list already uses for the same widget. Padding( key: ValueKey(filtrados[i].uuid), padding: const EdgeInsets.symmetric( horizontal: PluriLayout.rowHorizontal, ), child: _FilaFavorito( 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({ 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), ), // 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); }, 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; }