Files
pluriwave/lib/pantallas/pantalla_favoritos.dart
T
FreeTLab 202bef3539 feat(ui): design token discipline, accessibility and i18n pass
- Replace all hardcoded Color literals outside lib/tema with theme tokens (new static brand palette in PluriWaveTokens); media notification uses the brand color instead of the Material default purple
- Favorite button on station cards grows to a 48dp target and becomes an independent semantics node for screen readers (Semantics container fix)
- All flutter_animate call sites route through the PluriAnimate reduced-motion gate (zero direct .animate() left)
- Locale-aware short dates via intl DateFormat (new lib/l10n/formato_fechas.dart) replacing the hardcoded DD/MM/YYYY; proper plural messages for the favorites counter; example stream URL as a localized key - all 13 locales
- Rounded shimmer placeholders matching card radii; shimmer loading state in search instead of a bare spinner; rounded icon variants unified in settings; bottom-sheet conventions on the custom station form
- Fix latent debug crash: vacation editor read AppLocalizations in initState
- 11 new tests (121 total green), flutter analyze clean
2026-06-11 23:42:16 +02:00

288 lines
8.8 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_glass_surface.dart';
import '../widgets/pluri_icon.dart';
import '../widgets/pluri_layout.dart';
import '../widgets/pluri_premium_widgets.dart';
import 'package:pluriwave/widgets/tarjeta_emisora.dart';
import 'reproducir_minimizado.dart';
class PantallaFavoritos extends StatelessWidget {
const PantallaFavoritos({super.key});
@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.listaFavoritos,
);
final grupos = context.select<EstadoRadio, List<GrupoFavoritos>>(
(e) => e.gruposFavoritos,
);
final l10n = AppLocalizations.of(context);
if (favoritos.isEmpty) {
return ListView(
padding: PluriLayout.pageListPadding,
children: [
PluriScreenHeader(
title: l10n.favoritesTitle,
subtitle: l10n.favoritesHeaderSubtitle,
glyph: PluriIconGlyph.favorites,
trailing: PluriStatusPill(
icon: Icons.favorite_rounded,
label: l10n.favoritesCollection,
),
),
SizedBox(
height: 320,
child: PluriEmptyState(
glyph: PluriIconGlyph.favorites,
title: l10n.favoritesEmptyTitle,
subtitle: l10n.favoritesEmptySubtitle,
),
),
],
);
}
final gruposVisibles =
grupos.isEmpty
? [
GrupoFavoritos(
id: GrupoFavoritos.sinAsignarId,
nombre: l10n.favoriteGroupsUnassigned,
orden: 0,
protegido: true,
),
]
: grupos;
return CustomScrollView(
slivers: [
SliverToBoxAdapter(
child: PluriScreenHeader(
title: l10n.favoritesTitle,
subtitle: l10n.favoritesHeaderSubtitle,
glyph: PluriIconGlyph.favorites,
trailing: PluriStatusPill(
icon: Icons.library_music_rounded,
label: l10n.favoritesSavedCount(favoritos.length),
),
),
),
SliverPadding(
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,
),
),
),
// S5-R5: proper plural message, not a bare number.
Text(l10n.stationCount(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);
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 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),
),
],
),
],
);
}
}