Files
pluriwave/lib/pantallas/pantalla_favoritos.dart
T
FreeTLab 97e38becfe fix(chrome): retire PluriScreenHeader, it has no equivalent in the prototype
PluriScreenHeader was a 38-radius glass hero: an aurora banner at 24%
opacity, a black-to-transparent scrim, two radial orbs, a 120px app-mark
watermark and a 56px tri-gradient glyph badge. None of it is in the
prototype (t4) -- every root's title is plain text in its own 56px
row, which PluriRootHeader (S1) already provides. Delete the class
(and its now-orphaned _Orb helper) and its four call sites (Buscar,
Favoritos x2, Alarmas, Ajustes landed with S8).

Two of those call sites carried the hero's only functional bit besides
the title, so PluriRootHeader gains an `actions` slot (rendered before
the shared bedtime button) to keep them reachable:
  - Alarmas' create-alarm button (FilledButton.tonalIcon, unchanged
    shape, just relocated)
  - Buscar's filters entry point (the same tappable PluriStatusPill,
    just relocated)
Every other retired trailing pill (Ajustes' "Secure" status,
Favoritos' collection-count badges, Alarmas' alarm-count badge) was
purely decorative and matches the prototype by simply disappearing.

S2, Tier 1 visual-fidelity pass (audit id 2521).
2026-07-29 21:29:49 +02:00

522 lines
17 KiB
Dart

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 '../widgets/pluri_root_header.dart';
import '../widgets/pluri_sleep_timer_sheet.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<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).
///
/// [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<Emisora> filtrados,
List<Emisora> favoritos,
int oldIndex,
int newIndex,
) {
if (newIndex > oldIndex) newIndex -= 1;
final movido = filtrados[oldIndex];
final restantes = List<Emisora>.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<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.listaFavoritosManual,
);
final grupos = context.select<EstadoRadio, List<GrupoFavoritos>>(
(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.
PluriRootHeader(
title: l10n.favoritesTitle,
onSleepTimer: () => showPluriSleepTimerSheet(context),
),
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,
),
),
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,
required this.onGestionar,
});
final List<GrupoFavoritos> grupos;
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;
@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<GrupoFavoritos> grupos;
final GrupoFavoritos grupoActual;
String _nombreVisible(AppLocalizations l10n, GrupoFavoritos grupo) =>
grupo.esSinAsignar ? l10n.favoriteGroupsUnassigned : grupo.nombre;
Future<void> _asignar(BuildContext context) async {
final l10n = AppLocalizations.of(context);
final seleccionado = await showModalBottomSheet<String>(
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<EstadoRadio>().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<void> _eliminar(BuildContext context) async {
final l10n = AppLocalizations.of(context);
final estado = context.read<EstadoRadio>();
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;
}