feat(favorites): manage favorite groups in ui
Build & Deploy Pluriwave / Análisis de código (push) Successful in 24s
Build & Deploy Pluriwave / Build APK + AAB release (push) Successful in 1m39s

This commit is contained in:
2026-05-22 16:18:20 +02:00
parent c46d941e6c
commit 5f35db6352
29 changed files with 2151 additions and 65 deletions
+138
View File
@@ -13,6 +13,7 @@ import '../estado/estado_idioma.dart';
import '../estado/estado_radio.dart';
import '../l10n/gen/app_localizations.dart';
import '../modelos/emisora.dart';
import '../modelos/grupo_favoritos.dart';
import '../widgets/ecualizador_widget.dart';
import '../widgets/pluri_glass_surface.dart';
import '../widgets/pluri_icon.dart';
@@ -64,6 +65,8 @@ class _AjustesContent extends StatelessWidget {
SizedBox(height: 12),
_SeccionOrdenListas(),
SizedBox(height: 12),
_SeccionGruposFavoritos(),
SizedBox(height: 12),
_SeccionEmisoraPreferida(),
SizedBox(height: 12),
_SeccionEmisoras(),
@@ -693,6 +696,141 @@ class _SeccionOrdenListas extends StatelessWidget {
}
}
class _SeccionGruposFavoritos extends StatelessWidget {
const _SeccionGruposFavoritos();
Future<void> _editarGrupo(BuildContext context, [GrupoFavoritos? grupo]) async {
final l10n = AppLocalizations.of(context);
final controller = TextEditingController(text: grupo?.nombre ?? '');
final nombre = await showModalBottomSheet<String>(
context: context,
isScrollControlled: true,
showDragHandle: true,
builder: (ctx) {
final bottom = MediaQuery.viewInsetsOf(ctx).bottom;
return Padding(
padding: EdgeInsets.fromLTRB(20, 0, 20, bottom + 24),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
grupo == null ? l10n.favoriteGroupsAdd : l10n.favoriteGroupsEdit,
style: Theme.of(ctx).textTheme.titleLarge,
),
const SizedBox(height: 16),
TextField(
controller: controller,
autofocus: true,
maxLength: 28,
decoration: InputDecoration(
labelText: l10n.favoriteGroupsNameLabel,
helperText: l10n.favoriteGroupsNameTooLong,
border: const OutlineInputBorder(),
),
),
const SizedBox(height: 12),
FilledButton.icon(
icon: const Icon(Icons.save_rounded),
label: Text(AppLocalizations.of(ctx).saveQuickAccessButton),
onPressed: () {
final value = controller.text.trim();
if (value.isEmpty || value.length > 28) return;
Navigator.pop(ctx, value);
},
),
],
),
);
},
);
controller.dispose();
if (nombre == null || !context.mounted) return;
final estado = context.read<EstadoRadio>();
if (grupo == null) {
await estado.crearGrupoFavoritos(nombre);
} else {
await estado.renombrarGrupoFavoritos(grupo.id, nombre);
}
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(grupo == null ? l10n.favoriteGroupsCreated : l10n.favoriteGroupsUpdated)),
);
}
Future<void> _eliminarGrupo(BuildContext context, GrupoFavoritos grupo) async {
final l10n = AppLocalizations.of(context);
await context.read<EstadoRadio>().eliminarGrupoFavoritos(grupo.id);
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(l10n.favoriteGroupsDeleted)),
);
}
String _nombreVisible(AppLocalizations l10n, GrupoFavoritos grupo) =>
grupo.esSinAsignar ? l10n.favoriteGroupsUnassigned : grupo.nombre;
@override
Widget build(BuildContext context) {
final estado = context.watch<EstadoRadio>();
final l10n = AppLocalizations.of(context);
final grupos = estado.gruposFavoritos;
return PluriGlassSurface(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const Icon(Icons.playlist_add_check_circle_rounded),
const SizedBox(width: 12),
Expanded(
child: Text(
l10n.favoriteGroupsTitle,
style: Theme.of(context).textTheme.titleMedium,
),
),
TextButton.icon(
icon: const Icon(Icons.add_rounded),
label: Text(l10n.favoriteGroupsAdd),
onPressed: () => _editarGrupo(context),
),
],
),
const SizedBox(height: 4),
Text(l10n.favoriteGroupsDescription),
const SizedBox(height: 8),
for (final grupo in grupos)
ListTile(
contentPadding: EdgeInsets.zero,
leading: Icon(
grupo.esSinAsignar ? Icons.lock_rounded : Icons.folder_rounded,
),
title: Text(_nombreVisible(l10n, grupo)),
subtitle: grupo.esSinAsignar ? Text(l10n.favoriteGroupsProtectedHint) : null,
trailing: grupo.esSinAsignar
? null
: Wrap(
spacing: 4,
children: [
IconButton(
tooltip: l10n.favoriteGroupsEdit,
icon: const Icon(Icons.edit_rounded),
onPressed: () => _editarGrupo(context, grupo),
),
IconButton(
tooltip: l10n.favoriteGroupsDelete,
icon: const Icon(Icons.delete_outline_rounded),
onPressed: () => _eliminarGrupo(context, grupo),
),
],
),
),
],
),
);
}
}
class _SeccionEmisoraPreferida extends StatelessWidget {
const _SeccionEmisoraPreferida();
+211 -65
View File
@@ -2,6 +2,9 @@ import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../estado/estado_radio.dart';
import '../l10n/gen/app_localizations.dart';
import '../modelos/emisora.dart';
import '../modelos/grupo_favoritos.dart';
import '../widgets/pluri_glass_surface.dart';
import '../widgets/pluri_icon.dart';
import '../widgets/pluri_layout.dart';
@@ -17,107 +20,250 @@ class PantallaFavoritos extends StatelessWidget {
Widget build(BuildContext context) {
final estado = context.watch<EstadoRadio>();
final favoritos = estado.listaFavoritos;
final grupos = estado.gruposFavoritos;
final l10n = AppLocalizations.of(context);
if (favoritos.isEmpty) {
return ListView(
padding: PluriLayout.pageListPadding,
children: [
PluriScreenHeader(
title: 'Favoritos',
subtitle: 'Tu cabina personal para volver a las senales que mas escuchas.',
title: l10n.favoritesTitle,
subtitle: l10n.favoritesHeaderSubtitle,
glyph: PluriIconGlyph.favorites,
trailing: PluriStatusPill(
icon: Icons.favorite_rounded,
label: 'Coleccion',
label: l10n.favoritesCollection,
),
),
SizedBox(
height: 320,
child: PluriEmptyState(
glyph: PluriIconGlyph.favorites,
title: 'Sin favoritos aun',
subtitle: 'Toca el corazon en cualquier emisora para guardarla en tu coleccion.',
title: l10n.favoritesEmptyTitle,
subtitle: l10n.favoritesEmptySubtitle,
),
),
],
);
}
final gruposVisibles = grupos.isEmpty
? const [
GrupoFavoritos(
id: GrupoFavoritos.sinAsignarId,
nombre: 'Sin asignar',
orden: 0,
protegido: true,
),
]
: grupos;
return CustomScrollView(
slivers: [
SliverToBoxAdapter(
child: PluriScreenHeader(
title: 'Favoritos',
subtitle: 'Reordena tu coleccion y deja arriba las radios que mas importan.',
title: l10n.favoritesTitle,
subtitle: l10n.favoritesHeaderSubtitle,
glyph: PluriIconGlyph.favorites,
trailing: PluriStatusPill(
icon: Icons.library_music_rounded,
label: '${favoritos.length} guardadas',
label: l10n.favoritesSavedCount(favoritos.length),
),
),
),
SliverPadding(
padding: const EdgeInsets.fromLTRB(PluriLayout.horizontal, 4, PluriLayout.horizontal, PluriLayout.bottomChromeInset),
sliver: SliverReorderableList(
proxyDecorator: (child, index, animation) => ScaleTransition(
scale: Tween<double>(begin: 1, end: 1.03).animate(animation),
child: child,
),
// ignore: deprecated_member_use
onReorder: (oldIndex, newIndex) async {
if (newIndex > oldIndex) newIndex--;
final emisora = favoritos[oldIndex];
await estado.favoritos.reordenar(emisora.uuid, newIndex);
await estado.cargarFavoritos();
},
itemCount: favoritos.length,
itemBuilder: (context, i) {
final emisora = favoritos[i];
return Padding(
key: ValueKey('favorito-pad-${emisora.uuid}'),
padding: const EdgeInsets.symmetric(vertical: 5),
child: PluriGlassSurface(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 7),
child: Row(
children: [
ReorderableDragStartListener(
index: i,
child: const Padding(
padding: EdgeInsets.all(8),
child: Icon(Icons.drag_indicator_rounded),
),
),
Expanded(
child: TarjetaEmisora(
key: Key(emisora.uuid),
emisora: emisora,
esCompacta: true,
onTap: () => reproducirMinimizado(context, emisora),
),
),
IconButton.filledTonal(
tooltip: 'Eliminar de favoritos',
icon: const Icon(Icons.delete_outline_rounded),
onPressed: () async {
await estado.favoritos.eliminar(emisora.uuid);
await estado.cargarFavoritos();
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('${emisora.nombre} eliminada de favoritos'),
),
);
}
},
),
],
),
padding: const EdgeInsets.fromLTRB(
PluriLayout.horizontal,
4,
PluriLayout.horizontal,
PluriLayout.bottomChromeInset,
),
sliver: SliverList(
delegate: SliverChildListDelegate([
for (final grupo in gruposVisibles) ...[
_GrupoFavoritosPanel(
grupo: grupo,
grupos: gruposVisibles,
emisoras: favoritos
.where((e) => e.grupoFavoritosId == grupo.id)
.toList(),
),
);
},
const SizedBox(height: 12),
],
]),
),
),
],
);
}
}
class _GrupoFavoritosPanel extends StatelessWidget {
const _GrupoFavoritosPanel({
required this.grupo,
required this.grupos,
required this.emisoras,
});
final GrupoFavoritos grupo;
final List<GrupoFavoritos> grupos;
final List<Emisora> emisoras;
String _nombreVisible(AppLocalizations l10n, GrupoFavoritos grupo) =>
grupo.esSinAsignar ? l10n.favoriteGroupsUnassigned : grupo.nombre;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final theme = Theme.of(context);
return PluriGlassSurface(
padding: const EdgeInsets.all(10),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(grupo.esSinAsignar ? Icons.lock_rounded : Icons.folder_rounded),
const SizedBox(width: 8),
Expanded(
child: Text(
_nombreVisible(l10n, grupo),
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w900,
),
),
),
Text('${emisoras.length}'),
],
),
const SizedBox(height: 8),
if (emisoras.isEmpty)
Padding(
padding: const EdgeInsets.only(bottom: 4),
child: Text(
l10n.favoritesEmptyTitle,
style: theme.textTheme.bodySmall,
),
)
else
for (var i = 0; i < emisoras.length; i++) ...[
_FavoritoItem(
emisora: emisoras[i],
grupos: grupos,
grupoActual: grupo,
),
if (i < emisoras.length - 1) const SizedBox(height: 8),
],
],
),
);
}
}
class _FavoritoItem extends StatelessWidget {
const _FavoritoItem({
required this.emisora,
required this.grupos,
required this.grupoActual,
});
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);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
l10n.favoriteGroupsAssigned(emisora.nombre, _nombreVisible(l10n, destino)),
),
),
);
}
Future<void> _eliminar(BuildContext context) async {
final l10n = AppLocalizations.of(context);
final estado = context.read<EstadoRadio>();
await estado.favoritos.eliminar(emisora.uuid);
await estado.cargarFavoritos();
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(l10n.favoritesRemovedMessage(emisora.nombre))),
);
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
return Row(
children: [
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),
),
],
),
],
);
}
}