Tier 4 visual fidelity, audit 4.5 (t4 line 235): the border and the label/icon now use the prototype's two DIFFERENT opacities (rgba(255,255,255,.16) stroke vs rgba(242,247,250,.6) text/icon, previously one shared 50% colour for both), padding is a uniform 14 instead of symmetric(18,16), and the icon is a plain add glyph at 20px instead of add_circle_outline_rounded.
555 lines
19 KiB
Dart
555 lines
19 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/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<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);
|
|
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<String>(
|
|
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;
|
|
}
|