522 lines
18 KiB
Dart
522 lines
18 KiB
Dart
import 'package:cached_network_image/cached_network_image.dart';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:provider/provider.dart';
|
|
import 'package:shimmer/shimmer.dart' as shimmer;
|
|
|
|
import '../estado/estado_ecualizador.dart';
|
|
import '../estado/estado_navegacion.dart';
|
|
import '../estado/estado_radio.dart';
|
|
import '../l10n/display_names.dart';
|
|
import '../l10n/gen/app_localizations.dart';
|
|
import '../modelos/emisora.dart';
|
|
import '../servicios/servicio_audio.dart';
|
|
import '../tema/pluriwave_theme.dart';
|
|
import '../widgets/pluri_glass_surface.dart';
|
|
import '../widgets/pluri_layout.dart';
|
|
import '../widgets/pluri_premium_widgets.dart';
|
|
import '../widgets/visualizador_audio.dart';
|
|
import 'package:pluriwave/widgets/tarjeta_emisora.dart';
|
|
|
|
import 'pantalla_reproductor.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> {
|
|
@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);
|
|
|
|
return CustomScrollView(
|
|
slivers: [
|
|
// WU5 built the hero; WU6 relocated the discovery sections that
|
|
// used to follow it (_seccionCercanas, _seccionTendencias,
|
|
// _chipGeneros, _errorBanner, the browse grid) into
|
|
// PantallaBuscar's landing state and DELETED them here (this WU's
|
|
// own task 6.5 — completing WU5's task 5.9 deferral). Escuchar's
|
|
// content is now just the hero and the favorites preview below;
|
|
// pull-to-refresh was dropped along with the grid it refreshed —
|
|
// the retry button that used to live in the (now relocated) error
|
|
// banner already covers manual recovery on Buscar.
|
|
const SliverToBoxAdapter(child: _EscucharHero()),
|
|
SliverToBoxAdapter(child: _seccionTusEmisoras(context, theme, l10n)),
|
|
// ADR-7(b): the mini player is hidden on this whole screen
|
|
// (app.dart), so its content needs less bottom padding than every
|
|
// other root — escucharBottomChromeInset, not the plain
|
|
// bottomChromeInset every other root/scrollable uses.
|
|
const SliverToBoxAdapter(
|
|
child: SizedBox(height: PluriLayout.escucharBottomChromeInset),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
/// WU5 task 5.8: a preview of `listaFavoritos` (capped — full browsing,
|
|
/// filtering, and reordering live on Favoritos itself, WU4), with "Ver
|
|
/// todas" switching the root tab via `EstadoNavegacionRaiz.irA` rather
|
|
/// than pushing a route (`app-navigation-shell` — Root-to-Root Switching
|
|
/// Without Push).
|
|
static const _capTusEmisoras = 6;
|
|
|
|
Widget _seccionTusEmisoras(
|
|
BuildContext context,
|
|
ThemeData theme,
|
|
AppLocalizations l10n,
|
|
) {
|
|
final favoritos = context.select<EstadoRadio, List<Emisora>>(
|
|
(e) => e.listaFavoritos,
|
|
);
|
|
final mostrados = favoritos.take(_capTusEmisoras).toList();
|
|
|
|
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(
|
|
l10n.yourStationsTitle,
|
|
style: theme.textTheme.titleMedium?.copyWith(
|
|
fontWeight: FontWeight.w900,
|
|
),
|
|
),
|
|
),
|
|
TextButton(
|
|
onPressed:
|
|
() => context.read<EstadoNavegacionRaiz>().irA(
|
|
RaizPluriWave.favoritos,
|
|
),
|
|
child: Text(l10n.seeAllAction),
|
|
),
|
|
],
|
|
),
|
|
if (mostrados.isEmpty)
|
|
Padding(
|
|
padding: const EdgeInsets.only(bottom: 4),
|
|
child: Text(
|
|
l10n.favoritesEmptySubtitle,
|
|
style: theme.textTheme.bodySmall,
|
|
),
|
|
)
|
|
else
|
|
SizedBox(
|
|
height: 76,
|
|
child: ListView.separated(
|
|
scrollDirection: Axis.horizontal,
|
|
itemCount: mostrados.length,
|
|
separatorBuilder: (_, __) => const SizedBox(width: 8),
|
|
itemBuilder: (context, i) {
|
|
final emisora = mostrados[i];
|
|
return SizedBox(
|
|
width: 260,
|
|
child: TarjetaEmisora(
|
|
emisora: emisora,
|
|
esCompacta: true,
|
|
onTap: () => reproducirMinimizado(context, emisora),
|
|
),
|
|
);
|
|
},
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
}
|
|
|
|
/// WU5, design ADR-7: the Escuchar embedded player. `EstadoRadio` is the
|
|
/// single source of truth (`emisoraActual` already feeds `MiniReproductor`
|
|
/// and `PantallaReproductor`) — this hero is a third VIEW, never a third
|
|
/// STATE.
|
|
///
|
|
/// Binding rules this class exists to enforce:
|
|
/// 1. StatelessWidget — no cached `Emisora`, no local playback flag.
|
|
/// 2. Reads use `context.select` per scalar (here, `emisoraActual` itself —
|
|
/// `Emisora`'s own `==`/`hashCode` are uuid-based, so this only rebuilds
|
|
/// the hero when the STATION actually changes, not on every audio buffer
|
|
/// event `EstadoRadio` also notifies on). Fast-changing playback status
|
|
/// (`EstadoReproduccion`) is read via `StreamBuilder` instead, the same
|
|
/// pattern `_Controles`/`MiniReproductor` already use, so status ticks
|
|
/// don't even reach this widget's own rebuild path.
|
|
/// 3. Transport calls the SAME `EstadoRadio`/`EstadoEcualizador` methods the
|
|
/// full player calls — no new playback methods.
|
|
/// 4. `VisualizadorAudio` is reused UNCHANGED, just re-parameterised
|
|
/// (`barras: 30`, `altura: 26`, `color: liveGreen`).
|
|
class _EscucharHero extends StatelessWidget {
|
|
const _EscucharHero();
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final l10n = AppLocalizations.of(context);
|
|
final theme = Theme.of(context);
|
|
final emisora = context.select<EstadoRadio, Emisora?>(
|
|
(e) => e.emisoraActual,
|
|
);
|
|
|
|
if (emisora == null) {
|
|
return Padding(
|
|
padding: const EdgeInsets.fromLTRB(
|
|
PluriLayout.horizontal,
|
|
8,
|
|
PluriLayout.horizontal,
|
|
0,
|
|
),
|
|
child: PluriGlassSurface(
|
|
padding: const EdgeInsets.all(20),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
l10n.nothingPlayingTitle,
|
|
style: theme.textTheme.titleMedium?.copyWith(
|
|
fontWeight: FontWeight.w900,
|
|
),
|
|
),
|
|
const SizedBox(height: 4),
|
|
Text(
|
|
l10n.nothingPlayingSubtitle,
|
|
style: theme.textTheme.bodySmall,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
final estado = context.read<EstadoRadio>();
|
|
final stationName = localizedStationName(l10n, emisora.nombre);
|
|
|
|
return Padding(
|
|
padding: const EdgeInsets.fromLTRB(
|
|
PluriLayout.horizontal,
|
|
8,
|
|
PluriLayout.horizontal,
|
|
0,
|
|
),
|
|
child: PluriGlassSurface(
|
|
padding: const EdgeInsets.all(16),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: [
|
|
Row(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
_ArteEscuchar(emisora: emisora),
|
|
const SizedBox(width: 12),
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
StreamBuilder<EstadoReproduccion>(
|
|
stream: estado.estadoStream,
|
|
builder: (context, snap) {
|
|
final s = snap.data ?? EstadoReproduccion.detenido;
|
|
final enVivo = s == EstadoReproduccion.reproduciendo;
|
|
return PluriStatusPill(
|
|
icon:
|
|
enVivo
|
|
? Icons.podcasts_rounded
|
|
: Icons.pause_circle_outline_rounded,
|
|
label: enVivo ? l10n.liveNow : l10n.notPlaying,
|
|
accent:
|
|
enVivo ? context.pluriTokens.liveGreen : null,
|
|
);
|
|
},
|
|
),
|
|
const SizedBox(height: 6),
|
|
Text(
|
|
stationName,
|
|
style: theme.textTheme.titleLarge?.copyWith(
|
|
fontWeight: FontWeight.w800,
|
|
),
|
|
maxLines: 2,
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
const SizedBox(height: 8),
|
|
VisualizadorAudio(
|
|
estadoStream: estado.estadoStream,
|
|
androidAudioSessionIdStream:
|
|
estado.audio.androidAudioSessionIdStream,
|
|
barras: 30,
|
|
altura: 26,
|
|
color: context.pluriTokens.liveGreen,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 14),
|
|
_FilaTransporteEscuchar(emisora: emisora),
|
|
const SizedBox(height: 10),
|
|
Align(
|
|
alignment: Alignment.centerLeft,
|
|
child: OutlinedButton.icon(
|
|
icon: const Icon(Icons.tune_rounded, size: 18),
|
|
label: Text(l10n.openFullPlayerTooltip),
|
|
onPressed: () => PantallaReproductor.abrir(context, emisora),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Square art (the design's requested shape for the Escuchar hero — the
|
|
/// full player's own `_WaveHero`, `pantalla_reproductor.dart`, stays
|
|
/// circular and unchanged).
|
|
class _ArteEscuchar extends StatelessWidget {
|
|
const _ArteEscuchar({required this.emisora});
|
|
|
|
final Emisora emisora;
|
|
|
|
static const _lado = 84.0;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final theme = Theme.of(context);
|
|
final radius = BorderRadius.circular(context.pluriTokens.radiusMd);
|
|
return PluriGlassSurface(
|
|
padding: EdgeInsets.zero,
|
|
borderRadius: radius,
|
|
child: SizedBox(
|
|
width: _lado,
|
|
height: _lado,
|
|
child: ClipRRect(
|
|
borderRadius: radius,
|
|
child:
|
|
(emisora.favicon != null && emisora.favicon!.isNotEmpty)
|
|
? CachedNetworkImage(
|
|
imageUrl: emisora.favicon!,
|
|
fit: BoxFit.cover,
|
|
placeholder: (_, __) => _shimmerCuadrado(theme),
|
|
errorWidget: (_, __, ___) => _iconoFallback(theme),
|
|
)
|
|
: _iconoFallback(theme),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _shimmerCuadrado(ThemeData theme) => shimmer.Shimmer.fromColors(
|
|
baseColor: theme.colorScheme.surfaceContainerHighest,
|
|
highlightColor: theme.colorScheme.surface,
|
|
child: Container(color: theme.colorScheme.surfaceContainerHighest),
|
|
);
|
|
|
|
Widget _iconoFallback(ThemeData theme) => Container(
|
|
color: theme.colorScheme.primaryContainer,
|
|
child: Icon(
|
|
Icons.radio_rounded,
|
|
size: 36,
|
|
color: theme.colorScheme.onPrimaryContainer,
|
|
),
|
|
);
|
|
}
|
|
|
|
/// The hero's transport row — favorite / EQ toggle / stop / play-pause
|
|
/// (primary) / sleep, in that order, with sleep as the documented 5th
|
|
/// action. Every action calls an EXISTING `EstadoRadio`/`EstadoEcualizador`
|
|
/// method — no new playback surface (ADR-7 rule 3).
|
|
class _FilaTransporteEscuchar extends StatelessWidget {
|
|
const _FilaTransporteEscuchar({required this.emisora});
|
|
|
|
final Emisora emisora;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final l10n = AppLocalizations.of(context);
|
|
final t = context.pluriTokens;
|
|
final estado = context.read<EstadoRadio>();
|
|
final esFavorito = context.select<EstadoRadio, bool>(
|
|
(e) => e.listaFavoritos.any((x) => x.uuid == emisora.uuid),
|
|
);
|
|
final eqActivo = context.select<EstadoEcualizador, bool>((e) => e.activo);
|
|
final timerActivo = context.select<EstadoRadio, bool>(
|
|
(e) => e.timer.activo,
|
|
);
|
|
|
|
return Row(
|
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
children: [
|
|
IconButton(
|
|
tooltip:
|
|
esFavorito
|
|
? l10n.favoritesRemoveTooltip
|
|
: l10n.favoritesAddTooltip,
|
|
icon: Icon(
|
|
esFavorito
|
|
? Icons.favorite_rounded
|
|
: Icons.favorite_outline_rounded,
|
|
color: esFavorito ? Theme.of(context).colorScheme.error : null,
|
|
),
|
|
onPressed: () => estado.toggleFavorito(emisora),
|
|
),
|
|
IconButton(
|
|
tooltip: eqActivo ? l10n.equalizerDisable : l10n.equalizerEnable,
|
|
icon: Icon(
|
|
eqActivo ? Icons.equalizer_rounded : Icons.equalizer_outlined,
|
|
color: eqActivo ? t.warmCoral : null,
|
|
),
|
|
onPressed:
|
|
() => context.read<EstadoEcualizador>().cambiarActivo(!eqActivo),
|
|
),
|
|
StreamBuilder<EstadoReproduccion>(
|
|
stream: estado.estadoStream,
|
|
builder: (context, snap) {
|
|
final s = snap.data ?? EstadoReproduccion.detenido;
|
|
final cargando =
|
|
s == EstadoReproduccion.cargando ||
|
|
s == EstadoReproduccion.reconectando;
|
|
return IconButton(
|
|
tooltip: l10n.stopAction,
|
|
icon: const Icon(Icons.stop_circle_outlined),
|
|
onPressed: cargando ? null : estado.detenerReproduccion,
|
|
);
|
|
},
|
|
),
|
|
StreamBuilder<EstadoReproduccion>(
|
|
stream: estado.estadoStream,
|
|
builder: (context, snap) {
|
|
final s = snap.data ?? EstadoReproduccion.detenido;
|
|
final reproduciendo = s == EstadoReproduccion.reproduciendo;
|
|
final cargando =
|
|
s == EstadoReproduccion.cargando ||
|
|
s == EstadoReproduccion.reconectando;
|
|
return SizedBox(
|
|
width: 56,
|
|
height: 56,
|
|
child: FilledButton(
|
|
style: FilledButton.styleFrom(
|
|
shape: const CircleBorder(),
|
|
padding: EdgeInsets.zero,
|
|
),
|
|
onPressed:
|
|
cargando
|
|
? null
|
|
: () {
|
|
if (reproduciendo ||
|
|
s == EstadoReproduccion.pausado) {
|
|
estado.togglePlay();
|
|
} else {
|
|
estado.reproducir(emisora);
|
|
}
|
|
},
|
|
child:
|
|
cargando
|
|
? const SizedBox(
|
|
width: 22,
|
|
height: 22,
|
|
child: CircularProgressIndicator(strokeWidth: 2),
|
|
)
|
|
: Icon(
|
|
reproduciendo
|
|
? Icons.pause_rounded
|
|
: Icons.play_arrow_rounded,
|
|
size: 28,
|
|
),
|
|
),
|
|
);
|
|
},
|
|
),
|
|
IconButton(
|
|
tooltip: l10n.sleepTimer,
|
|
icon: Icon(
|
|
Icons.bedtime_rounded,
|
|
color: timerActivo ? t.warmCoral : null,
|
|
),
|
|
onPressed: () => _mostrarTimerSheet(context, estado, l10n),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
Future<void> _mostrarTimerSheet(
|
|
BuildContext context,
|
|
EstadoRadio estado,
|
|
AppLocalizations l10n,
|
|
) {
|
|
return showModalBottomSheet(
|
|
context: context,
|
|
showDragHandle: true,
|
|
builder:
|
|
(ctx) => SafeArea(
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(24),
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
l10n.sleepTimer,
|
|
style: Theme.of(ctx).textTheme.titleLarge,
|
|
),
|
|
const SizedBox(height: 16),
|
|
if (estado.timer.activo)
|
|
FilledButton.tonal(
|
|
onPressed: () {
|
|
estado.cancelarTimer();
|
|
Navigator.pop(ctx);
|
|
},
|
|
child: Text(l10n.cancelTimer),
|
|
)
|
|
else
|
|
Wrap(
|
|
spacing: 8,
|
|
runSpacing: 8,
|
|
children: [
|
|
for (final segundos in estado.timerSuenoPresetsSegundos)
|
|
ActionChip(
|
|
label: Text(_formatearMinutos(l10n, segundos)),
|
|
onPressed: () {
|
|
estado.iniciarTimerDuracion(
|
|
Duration(seconds: segundos),
|
|
);
|
|
Navigator.pop(ctx);
|
|
},
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
String _formatearMinutos(AppLocalizations l10n, int segundos) {
|
|
final d = Duration(seconds: segundos);
|
|
final m = d.inMinutes.remainder(60).toString().padLeft(2, '0');
|
|
final s = d.inSeconds.remainder(60).toString().padLeft(2, '0');
|
|
if (d.inHours > 0) {
|
|
return l10n.durationHoursMinutesSeconds(d.inHours, m, s);
|
|
}
|
|
return d.inSeconds.remainder(60) == 0
|
|
? l10n.durationMinutesOnly(d.inMinutes)
|
|
: l10n.durationMinutesSeconds(d.inMinutes, s);
|
|
}
|
|
}
|