Files
pluriwave/lib/pantallas/pantalla_inicio.dart
T
FreeTLab 5a5f1655a9 fix(surfaces): make list/card surfaces opaque by default, fix card radius
The prototype states an explicit system rule (t4 line 40): "opaque list
surface #102532, glass only in the chrome and in the active card."
PluriGlassSurface backs nearly every card/row in the app, but always
rendered translucent + blurred (glassSurface, blur 18) — the listSurface
token (#102532) existed since WU1 but was only ever used at reduced
alpha, never opaquely.

Add a `glass` flag to PluriGlassSurface, defaulting to false: an opaque
listSurface fill with no BackdropFilter. Chrome (MiniReproductor) and
the active/now-playing card (Escuchar's hero, when a station is
playing) opt into the old translucent look via `glass: true` — every
other of the ~26 call sites needs no change and now renders opaquely.

Also fix the card radius: the prototype's dominant card radius is 18
(t4 lines 512, 613, 715, 133); radiusMd was 22, a systematic +4px drift
across every card that defaults to it. Retune TarjetaEmisora's own
radius ternary so its compact (row) variant uses the dominant row
radius, 14, instead of inheriting the card radius.

S3+S4, Tier 1 visual-fidelity pass (audit id 2521).
2026-07-29 20:23:55 +02:00

536 lines
19 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/pluri_root_header.dart';
import '../widgets/pluri_sleep_timer_sheet.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: [
// 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).
SliverToBoxAdapter(
child: PluriRootHeader(
title: l10n.navHome,
onSleepTimer: () => showPluriSleepTimerSheet(context),
),
),
// 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(
// S3 (Tier 1 visual fidelity): the active/now-playing card — the
// system rule's other named exception to the opaque default.
glass: true,
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);
}
}