Files
pluriwave/lib/pantallas/pantalla_inicio.dart
T
FreeTLab 3a803ce2bf feat(escuchar): replace discovery browser with embedded player and favorites grid
Restructures PantallaInicio's top of screen: a new _EscucharHero
(square art, live/offline pill, VisualizadorAudio at barras: 30 /
altura: 26 / color: liveGreen, a 5-action transport row - favorite,
EQ toggle, stop, play/pause, sleep - plus a tool-tray entry chip
opening the full player) replaces the old PluriScreenHeader hero, and
a new "Tus emisoras" section (favorites preview, capped, "Ver todas")
follows it. Per design ADR-7, EstadoRadio stays the single source of
truth: the hero is a StatelessWidget with no cached fields, reading
emisoraActual via context.select (uuid-based equality scopes rebuilds
to real station changes) and the fast-changing playback status via
StreamBuilder, the same pattern _Controles/MiniReproductor already
use. The still-present discovery sections (_seccionCercanas onward,
including the old grid) are deliberately left in place - WU6
relocates them to Buscar and deletes them from here; removing them
now would leave that content nowhere until WU6 lands.

MiniReproductor gains a `visible` parameter (default true) and a
measured `static const double altura`. app.dart passes
`visible: indice != RaizPluriWave.escuchar.index`, hiding it visually
only (SizedBox.shrink()) while Escuchar is active, since the hero
already shows the same station - the State stays mounted so its
didChangeDependencies side effect (configurarLocalizaciones, S3-R3)
keeps running regardless of tab. altura was measured empirically
(72.0, via tester.getSize) rather than guessed, backing a new derived
PluriLayout.escucharBottomChromeInset constant now wired into
PantallaInicio's own bottom padding.

"Ver todas" switches roots via EstadoNavegacionRaiz.irA(favoritos),
verified via a NavigatorObserver asserting the push count is
unchanged (switches tabs, does not push).

Fixed a pre-existing test-infrastructure gap while writing the
anti-cache test: no test in this codebase had ever exercised
ServicioAudio.androidAudioSessionIdStream against a bare
FakeServicioAudio (pantalla_reproductor.dart has always read it but
has no test file at all) - the real getter needs registrarHandler()
(main.dart, production only) and threw otherwise. Added an empty
stream override to FakeServicioAudio, matching VisualizadorAudio's
own documented no-native-session fallback.

Tests: 614 -> 618 (2 skipped, unchanged). flutter analyze unchanged
at 1 pre-existing info. git diff empty for visualizador_audio.dart
and estado_radio.dart - this WU touches neither.
2026-07-29 00:12:05 +02:00

863 lines
29 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_busqueda.dart';
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/pluri_animate.dart';
import '../tema/pluriwave_theme.dart';
import '../widgets/pluri_glass_surface.dart';
import '../widgets/pluri_icon.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> {
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: [
// WU5: replaces the old PluriScreenHeader hero. The discovery
// sections below (_seccionCercanas onward) are deliberately left
// in place — WU6 relocates them to Buscar and deletes them from
// here; removing them now would leave that content nowhere until
// WU6 lands.
const SliverToBoxAdapter(child: _EscucharHero()),
SliverToBoxAdapter(child: _seccionTusEmisoras(context, theme, 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(
// 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.
padding: const EdgeInsets.fromLTRB(
PluriLayout.horizontal,
0,
PluriLayout.horizontal,
PluriLayout.escucharBottomChromeInset,
),
sliver: _gridEmisoras(context, l10n),
),
],
),
);
}
/// 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),
),
);
},
),
),
],
),
),
);
}
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),
),
),
);
}
}
/// 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);
}
}