202bef3539
- 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
413 lines
13 KiB
Dart
413 lines
13 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:provider/provider.dart';
|
|
import 'package:shimmer/shimmer.dart' as shimmer;
|
|
|
|
import '../estado/estado_busqueda.dart';
|
|
import '../estado/estado_radio.dart';
|
|
import '../l10n/gen/app_localizations.dart';
|
|
import '../modelos/emisora.dart';
|
|
import '../tema/pluri_animate.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';
|
|
|
|
/// Pantalla principal: emisoras populares y por género.
|
|
class PantallaInicio extends StatefulWidget {
|
|
const PantallaInicio({super.key});
|
|
|
|
@override
|
|
State<PantallaInicio> createState() => _PantallaInicioState();
|
|
}
|
|
|
|
class _PantallaInicioState extends State<PantallaInicio> {
|
|
static const _generos = [
|
|
'pop',
|
|
'rock',
|
|
'jazz',
|
|
'classical',
|
|
'electronic',
|
|
'news',
|
|
'talk',
|
|
'hip-hop',
|
|
'country',
|
|
'metal',
|
|
'reggae',
|
|
'latin',
|
|
];
|
|
String? _generoSeleccionado;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
// S4-R5: no root watch on EstadoRadio. Every field is consumed through
|
|
// context.select over identity-memoized getters, so audio buffer events
|
|
// (which notify EstadoRadio) no longer rebuild this screen.
|
|
final theme = Theme.of(context);
|
|
final l10n = AppLocalizations.of(context);
|
|
final error = context.select<EstadoRadio, String?>((e) => e.error);
|
|
|
|
return RefreshIndicator(
|
|
onRefresh: () => context.read<EstadoRadio>().cargarPopulares(),
|
|
child: CustomScrollView(
|
|
slivers: [
|
|
SliverToBoxAdapter(child: _heroHeader(context, l10n)),
|
|
SliverToBoxAdapter(child: _seccionCercanas(context, theme, l10n)),
|
|
SliverToBoxAdapter(child: _seccionTendencias(context, theme, l10n)),
|
|
SliverToBoxAdapter(child: _chipGeneros(context, theme, l10n)),
|
|
if (error != null)
|
|
SliverToBoxAdapter(
|
|
child: _errorBanner(context, error, theme, l10n),
|
|
),
|
|
SliverPadding(
|
|
padding: const EdgeInsets.fromLTRB(
|
|
PluriLayout.horizontal,
|
|
0,
|
|
PluriLayout.horizontal,
|
|
PluriLayout.bottomChromeInset,
|
|
),
|
|
sliver: _gridEmisoras(context, l10n),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _heroHeader(BuildContext context, AppLocalizations l10n) {
|
|
final totalEmisoras = context.select<EstadoRadio, int>(
|
|
(e) => e.emisorasInicio.length,
|
|
);
|
|
return PluriScreenHeader(
|
|
title: l10n.appTitle,
|
|
subtitle: l10n.homeScreenSubtitle,
|
|
glyph: PluriIconGlyph.home,
|
|
primaryActionLabel: l10n.exploreStations,
|
|
onPrimaryAction: () => context.read<EstadoRadio>().cargarPopulares(),
|
|
trailing: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.end,
|
|
children: [
|
|
PluriStatusPill(
|
|
icon: Icons.public_rounded,
|
|
label: l10n.stationsCount(totalEmisoras),
|
|
accent: Theme.of(context).colorScheme.secondary,
|
|
),
|
|
const SizedBox(height: 8),
|
|
PluriStatusPill(icon: Icons.hd_rounded, label: l10n.qualityHd),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _seccionCercanas(
|
|
BuildContext context,
|
|
ThemeData theme,
|
|
AppLocalizations l10n,
|
|
) {
|
|
// Nearby stations live in EstadoBusqueda (S4-R3).
|
|
final busqueda = context.watch<EstadoBusqueda>();
|
|
final pais = busqueda.paisCercanoDetectado;
|
|
return Padding(
|
|
padding: const EdgeInsets.fromLTRB(
|
|
PluriLayout.horizontal,
|
|
8,
|
|
PluriLayout.horizontal,
|
|
0,
|
|
),
|
|
child: PluriGlassSurface(
|
|
padding: const EdgeInsets.all(12),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Row(
|
|
children: [
|
|
Expanded(
|
|
child: Text(
|
|
pais == null ? l10n.nearYou : l10n.nearYouInCountry(pais),
|
|
style: theme.textTheme.titleMedium?.copyWith(
|
|
fontWeight: FontWeight.w900,
|
|
),
|
|
),
|
|
),
|
|
TextButton.icon(
|
|
onPressed:
|
|
busqueda.cargandoCercanas
|
|
? null
|
|
: busqueda.cargarEmisorasCercanas,
|
|
icon:
|
|
busqueda.cargandoCercanas
|
|
? const SizedBox(
|
|
width: 16,
|
|
height: 16,
|
|
child: CircularProgressIndicator(strokeWidth: 2),
|
|
)
|
|
: const Icon(Icons.my_location_rounded, size: 18),
|
|
label: Text(l10n.detectAction),
|
|
),
|
|
],
|
|
),
|
|
if (busqueda.errorCercanas != null)
|
|
Text(
|
|
busqueda.errorCercanas!,
|
|
style: theme.textTheme.bodySmall?.copyWith(
|
|
color: theme.colorScheme.error,
|
|
),
|
|
),
|
|
if (busqueda.cercanas.isNotEmpty) ...[
|
|
const SizedBox(height: 8),
|
|
SizedBox(
|
|
height: 76,
|
|
child: ListView.separated(
|
|
scrollDirection: Axis.horizontal,
|
|
itemCount: busqueda.cercanas.length,
|
|
separatorBuilder: (_, __) => const SizedBox(width: 8),
|
|
itemBuilder: (context, i) {
|
|
final emisora = busqueda.cercanas[i];
|
|
return SizedBox(
|
|
width: 260,
|
|
child: TarjetaEmisora(
|
|
emisora: emisora,
|
|
esCompacta: true,
|
|
onTap: () => reproducirMinimizado(context, emisora),
|
|
),
|
|
);
|
|
},
|
|
),
|
|
),
|
|
],
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _seccionTendencias(
|
|
BuildContext context,
|
|
ThemeData theme,
|
|
AppLocalizations l10n,
|
|
) {
|
|
final cargando = context.select<EstadoRadio, bool>(
|
|
(e) => e.cargandoPopulares,
|
|
);
|
|
final tendencias = context.select<EstadoRadio, List<Emisora>>(
|
|
(e) => e.tendencias,
|
|
);
|
|
return Padding(
|
|
padding: const EdgeInsets.fromLTRB(
|
|
PluriLayout.horizontal,
|
|
8,
|
|
PluriLayout.horizontal,
|
|
0,
|
|
),
|
|
child: PluriGlassSurface(
|
|
padding: const EdgeInsets.all(12),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(l10n.liveRadar, style: theme.textTheme.titleMedium),
|
|
const SizedBox(height: 8),
|
|
SizedBox(
|
|
height: 56,
|
|
child:
|
|
cargando
|
|
? ListView.separated(
|
|
scrollDirection: Axis.horizontal,
|
|
itemCount: 5,
|
|
separatorBuilder: (_, __) => const SizedBox(width: 8),
|
|
itemBuilder: (_, __) => _ChipShimmer(theme: theme),
|
|
)
|
|
: ListView.separated(
|
|
scrollDirection: Axis.horizontal,
|
|
itemCount: tendencias.length,
|
|
separatorBuilder: (_, __) => const SizedBox(width: 8),
|
|
itemBuilder: (context, i) {
|
|
final e = tendencias[i];
|
|
return ActionChip(
|
|
avatar: const Icon(
|
|
Icons.graphic_eq_rounded,
|
|
size: 18,
|
|
),
|
|
label: Text(e.nombre, maxLines: 1),
|
|
onPressed: () => reproducirMinimizado(context, e),
|
|
).pluriFadeIn(
|
|
context,
|
|
delay: Duration(milliseconds: i * 50),
|
|
);
|
|
},
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _chipGeneros(
|
|
BuildContext context,
|
|
ThemeData theme,
|
|
AppLocalizations l10n,
|
|
) {
|
|
return Padding(
|
|
padding: const EdgeInsets.fromLTRB(
|
|
PluriLayout.horizontal,
|
|
16,
|
|
PluriLayout.horizontal,
|
|
8,
|
|
),
|
|
child: PluriGlassSurface(
|
|
padding: const EdgeInsets.all(12),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(l10n.genresTitle, style: theme.textTheme.titleMedium),
|
|
const SizedBox(height: 8),
|
|
Wrap(
|
|
spacing: 8,
|
|
runSpacing: 4,
|
|
children:
|
|
_generos.map((g) {
|
|
final seleccionado = _generoSeleccionado == g;
|
|
return FilterChip(
|
|
label: Text(_genreName(l10n, g)),
|
|
selected: seleccionado,
|
|
onSelected: (_) {
|
|
setState(() {
|
|
_generoSeleccionado = seleccionado ? null : g;
|
|
});
|
|
if (!seleccionado) {
|
|
context.read<EstadoBusqueda>().buscar(tag: g);
|
|
} else {
|
|
context.read<EstadoRadio>().cargarPopulares();
|
|
}
|
|
},
|
|
);
|
|
}).toList(),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _errorBanner(
|
|
BuildContext context,
|
|
String error,
|
|
ThemeData theme,
|
|
AppLocalizations l10n,
|
|
) {
|
|
return Padding(
|
|
padding: const EdgeInsets.all(16),
|
|
child: PluriGlassSurface(
|
|
padding: const EdgeInsets.all(12),
|
|
child: Row(
|
|
children: [
|
|
Icon(Icons.wifi_off, color: theme.colorScheme.error),
|
|
const SizedBox(width: 8),
|
|
Expanded(child: Text(error)),
|
|
TextButton(
|
|
onPressed: () => context.read<EstadoRadio>().cargarPopulares(),
|
|
child: Text(l10n.retryAction),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _gridEmisoras(BuildContext context, AppLocalizations l10n) {
|
|
final porGenero = _generoSeleccionado != null;
|
|
final emisoras =
|
|
porGenero
|
|
? context.select<EstadoBusqueda, List<Emisora>>((b) => b.resultados)
|
|
: context.select<EstadoRadio, List<Emisora>>(
|
|
(e) => e.emisorasInicio,
|
|
);
|
|
final cargando =
|
|
context.select<EstadoRadio, bool>((e) => e.cargandoPopulares) ||
|
|
(porGenero && context.select<EstadoBusqueda, bool>((b) => b.cargando));
|
|
|
|
if (cargando) {
|
|
return SliverGrid(
|
|
delegate: SliverChildBuilderDelegate(
|
|
(_, __) => const TarjetaEmisoraShimmer(),
|
|
childCount: 12,
|
|
),
|
|
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
|
crossAxisCount: 2,
|
|
childAspectRatio: 0.78,
|
|
crossAxisSpacing: 12,
|
|
mainAxisSpacing: 12,
|
|
),
|
|
);
|
|
}
|
|
|
|
if (emisoras.isEmpty) {
|
|
return SliverFillRemaining(
|
|
child: PluriEmptyState(
|
|
glyph: PluriIconGlyph.home,
|
|
title: l10n.noStationsAvailable,
|
|
subtitle: l10n.noStationsAvailableSubtitle,
|
|
),
|
|
);
|
|
}
|
|
|
|
return SliverGrid(
|
|
delegate: SliverChildBuilderDelegate(
|
|
(context, i) => TarjetaEmisora(
|
|
emisora: emisoras[i],
|
|
onTap: () => reproducirMinimizado(context, emisoras[i]),
|
|
).pluriFadeSlideIn(
|
|
context,
|
|
delay: Duration(milliseconds: i * 30),
|
|
beginY: 0.1,
|
|
),
|
|
childCount: emisoras.length,
|
|
),
|
|
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
|
crossAxisCount: 2,
|
|
childAspectRatio: 0.78,
|
|
crossAxisSpacing: 12,
|
|
mainAxisSpacing: 12,
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
String _genreName(AppLocalizations l10n, String tag) => switch (tag) {
|
|
'pop' => l10n.genrePop,
|
|
'rock' => l10n.genreRock,
|
|
'jazz' => l10n.genreJazz,
|
|
'classical' => l10n.genreClassical,
|
|
'electronic' => l10n.genreElectronic,
|
|
'news' => l10n.genreNews,
|
|
'talk' => l10n.genreTalk,
|
|
'hip-hop' => l10n.genreHipHop,
|
|
'country' => l10n.genreCountry,
|
|
'metal' => l10n.genreMetal,
|
|
'reggae' => l10n.genreReggae,
|
|
'latin' => l10n.genreLatin,
|
|
_ => tag,
|
|
};
|
|
|
|
class _ChipShimmer extends StatelessWidget {
|
|
final ThemeData theme;
|
|
const _ChipShimmer({required this.theme});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return shimmer.Shimmer.fromColors(
|
|
baseColor: theme.colorScheme.surfaceContainerHighest,
|
|
highlightColor: theme.colorScheme.surface,
|
|
child: Container(
|
|
width: 120,
|
|
height: 56,
|
|
decoration: BoxDecoration(
|
|
color: theme.colorScheme.surfaceContainerHighest,
|
|
borderRadius: BorderRadius.circular(20),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|