Files
pluriwave/lib/pantallas/pantalla_inicio.dart
T
FreeTLab acf2ebb55f
Build & Deploy PluriWave / Análisis de código (push) Successful in 26s
Build & Deploy PluriWave / Build APK + AAB release (push) Successful in 3m13s
fix: alinear permisos, paywall y grabacion con lo que la app hace de verdad
Revision previa al envio a produccion. Cada punto se verifico en el codigo
antes de tocarlo; lo que ya estaba bien se dejo como estaba.

Ubicacion: se declaraba precision fina sin usarla

El unico consumidor de ubicacion pide `LocationAccuracy.low` y se queda solo
con el codigo ISO del pais, asi que `ACCESS_FINE_LOCATION` no aportaba nada. Y
contradecia la declaracion de Seguridad de los datos ya aprobada en Play, que
dice ubicacion APROXIMADA: declarar una cosa y pedir otra es precisamente lo
que se penaliza en revision.

Verificado que los manifiestos de geolocator_android y geocoding_android no
declaran permisos propios, asi que el merge no lo reinyecta y no hace falta
`tools:node="remove"`. El plugin construye su peticion en tiempo de ejecucion a
partir de lo declarado, de modo que con COARSE pide COARSE. Sin cambio
funcional: la deteccion de pais sigue igual.

El paywall vendia Android Auto como exclusivo, y ya no lo es

La etiqueta era literalmente "Android Auto", a secas. Pero el tier gratuito
recibio una carpeta navegable con emisoras reproducibles cuando hubo que
cumplir las guias del coche, asi que esa frase dejo de ser cierta. Ahora dice
que PRO añade el catalogo completo, favoritos, mis emisoras y musica local, y
aclara que gratis tiene las destacadas. Un paywall que promete lo que el tier
gratuito ya tiene expone a reclamacion y a que se cite en revision.

Microfono: se pide al activar el visualizador, no antes

Con una explicacion previa en los 13 idiomas, en vez de aparecer sin contexto.

Grabacion: uso privado de verdad, no solo en el aviso

La pantalla de grabaciones entregaba el fichero a cualquier aplicacion con
`Share.shareXFiles`. La intencion era abrirlo en un reproductor del propio
telefono, no redistribuirlo, y una cosa es copia privada y la otra no. Ahora
usa el `openFile` que ya existia -- FileProvider + ACTION_VIEW -- y avisa
cuando ningun reproductor del dispositivo puede abrirla, en vez de fallar en
silencio. Se añade ademas el aviso de uso privado en esa pantalla.

`recordingActionShare` la usaban DOS botones con significados distintos: el de
grabaciones, que mandaba el audio, y el del reproductor, que comparte el nombre
y la url de la emisora. Una clave, dos sentidos, y esa ambiguedad basto para
que al leer el codigo pareciera que solo se compartian enlaces. Separadas en
`stationActionShare` y `recordingActionOpenIn`.

La grabacion sigue siendo PRO. Lo que reduce el riesgo es que la copia no salga
del dispositivo, no regalar la funcion: los anuncios tambien son monetizacion.

Suite completa: 1587 pasan, 2 omitidos. flutter analyze mantiene los 5 avisos
preexistentes.
2026-09-18 17:06:59 +02:00

937 lines
35 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 '../estado/estado_visualizador.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_sleep_timer_sheet.dart';
import '../widgets/pluri_station_art_fallback.dart';
import '../widgets/visualizador_audio.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);
// Audit 1.1 (t4 lines 49-51): a full-bleed art/gradient/noise wash sits
// BEHIND the whole screen, not just inside the hero card -- the Stack
// wrapper is new; everything that used to be the bare CustomScrollView
// return value is unchanged below.
return Stack(
fit: StackFit.expand,
children: [
const _FondoEscuchar(),
CustomScrollView(
slivers: [
// Audit 1.2 (t4 line 53): unlike the other 3 roots' plain 56px
// PluriRootHeader, Escuchar's own header is a 52px "Now
// listening" eyebrow row -- the tab bar already identifies this
// destination, so it never repeats "Escuchar" as a title.
SliverToBoxAdapter(
child: _CabeceraEscuchar(
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).
///
/// Audit 1.10 (t4 lines 82-88): the prototype shows a 2-column grid of 8,
/// not a 6-capped horizontal strip.
static const _capTusEmisoras = 8;
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: Row(
children: [
Flexible(
child: Text(
l10n.yourStationsTitle,
// S7 (visual fidelity, t4 line 80): the in-page
// section heading style (15/w800/ls-.2), not
// titleMedium(16)/w900.
style: context.pluriType.inPageSectionHeading,
overflow: TextOverflow.ellipsis,
),
),
// Audit 1.11 (t4 line 80): a count badge next to the
// section title — the TOTAL favorite count, not the
// 8-capped grid size shown below it.
if (favoritos.isNotEmpty) ...[
const SizedBox(width: 10),
_InsigniaRecuento(cuenta: favoritos.length),
],
],
),
),
// Audit 1.12 (t4 line 80): "Ver todas" is plain 12px/w800
// brand-teal text — not a Material TextButton with its own
// padding and splash.
Semantics(
button: true,
child: GestureDetector(
onTap:
() => context.read<EstadoNavegacionRaiz>().irA(
RaizPluriWave.favoritos,
),
child: Text(
l10n.seeAllAction,
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w800,
color: context.pluriTokens.electricMagenta,
),
),
),
),
],
),
if (mostrados.isEmpty)
Padding(
padding: const EdgeInsets.only(bottom: 4),
child: Text(
l10n.favoritesEmptySubtitle,
style: theme.textTheme.bodySmall,
),
)
else
// Audit 1.10 (t4 lines 82-88): a 2-column grid, gap 10 — was a
// horizontal strip of 260px-wide compact rows.
GridView.builder(
padding: EdgeInsets.zero,
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
itemCount: mostrados.length,
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2,
mainAxisSpacing: 10,
crossAxisSpacing: 10,
childAspectRatio: 2.6,
),
itemBuilder: (context, i) {
final emisora = mostrados[i];
return _CeldaTusEmisoras(
emisora: emisora,
onTap: () => reproducirMinimizado(context, emisora),
);
},
),
],
),
),
);
}
}
/// Audit 1.1 (t4 lines 49-51): a full-bleed backdrop behind the ENTIRE
/// screen -- 400px of the current station's own art at 50% opacity, a
/// gradient fading it into the page background by 430px, and a faint noise
/// texture. Reads `emisoraActual` ITSELF (not a parent watch), so this stays
/// the only new subscriber -- `_PantallaInicioState.build()` keeps its
/// documented S4-R5 "no root watch" rule. Renders the gradient/noise even
/// with nothing playing (there is simply no image layer to show then) so
/// the page keeps the same base depth in every state.
class _FondoEscuchar extends StatelessWidget {
const _FondoEscuchar();
static const double _altoArte = 400;
static const double _altoDegradado = 430;
@override
Widget build(BuildContext context) {
final emisora = context.select<EstadoRadio, Emisora?>(
(e) => e.emisoraActual,
);
final tokens = context.pluriTokens;
final favicon = emisora?.favicon;
return IgnorePointer(
key: const ValueKey('escuchar-background-art'),
child: Stack(
children: [
if (favicon != null && favicon.isNotEmpty)
SizedBox(
width: double.infinity,
height: _altoArte,
child: Opacity(
opacity: 0.5,
child: CachedNetworkImage(
imageUrl: favicon,
fit: BoxFit.cover,
errorWidget: (_, __, ___) => const SizedBox.shrink(),
),
),
),
SizedBox(
width: double.infinity,
height: _altoDegradado,
child: DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
tokens.deepViolet.withValues(alpha: 0.4),
tokens.deepViolet.withValues(alpha: 0.86),
tokens.deepViolet,
],
stops: const [0, 0.58, 1],
),
),
),
),
SizedBox(
width: double.infinity,
height: _altoDegradado,
child: Opacity(
opacity: 0.05,
child: Image.asset(
'assets/images/noise_soft.png',
fit: BoxFit.cover,
errorBuilder: (_, __, ___) => const SizedBox.shrink(),
),
),
),
],
),
);
}
}
/// Audit 1.2 (t4 line 53): Escuchar's own header -- 52px, an eyebrow-styled
/// "Now listening" label (not a screen title -- the tab bar already
/// identifies this destination) and a single trailing action.
///
/// The prototype also draws a `cast` icon here. This app has no casting
/// service anywhere in `lib/servicios` (checked before writing this) --
/// inventing a Chromecast-style button with no backing capability would fail
/// the same "don't invent a capability absent from the domain" test WU5/WU9
/// already established elsewhere in this app, so it is omitted, not faked.
/// The ONE real action this row keeps is the sleep timer, which used to be
/// [PluriRootHeader]'s bedtime button on this exact screen.
class _CabeceraEscuchar extends StatelessWidget {
const _CabeceraEscuchar({required this.onSleepTimer});
final VoidCallback onSleepTimer;
static const double altura = 52;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
return SizedBox(
height: altura,
child: Padding(
padding: const EdgeInsets.fromLTRB(20, 0, 10, 0),
child: Row(
children: [
Expanded(
child: Text(
l10n.nowListeningLabel,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.w800,
letterSpacing: 0.3,
color: const Color(0xFFF2F7FA).withValues(alpha: 0.55),
),
),
),
IconButton(
icon: const Icon(Icons.bedtime_outlined),
iconSize: 23,
tooltip: l10n.sleepTimer,
onPressed: onSleepTimer,
),
],
),
),
);
}
}
/// 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,
// Audit 1.5 (t4 line 60): 24/w800/ls-.5/height 1.12
// -- titleLarge is 22 with neither letter-spacing
// nor an explicit line height.
style: theme.textTheme.titleLarge?.copyWith(
fontSize: 24,
fontWeight: FontWeight.w800,
letterSpacing: -0.5,
height: 1.12,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
if (_metaEscuchar(emisora) case final meta?) ...[
const SizedBox(height: 4),
Text(
meta,
key: const ValueKey('escuchar-hero-meta'),
style: TextStyle(
fontSize: 12.5,
fontWeight: FontWeight.w600,
color: theme.colorScheme.onSurface.withValues(
alpha: 0.62,
),
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
const SizedBox(height: 8),
VisualizadorAudio(
estadoStream: estado.estadoStream,
androidAudioSessionIdStream:
estado.audio.androidAudioSessionIdStream,
barras: 30,
altura: 26,
color: context.pluriTokens.liveGreen,
// Audit 1.7 (t4 lines 66-68): 30 discrete
// bottom-anchored bars, not a continuous stroke.
barrasDiscretas: true,
// Sensitive-permission gate: subscribing to the
// native waveform channel is what makes Android ask
// for RECORD_AUDIO, so it happens only after the
// user opts in from Settings.
capturaRealHabilitada:
context
.watch<EstadoVisualizador>()
.ondaRealHabilitada,
),
],
),
),
],
),
const SizedBox(height: 14),
_FilaTransporteEscuchar(emisora: emisora),
const SizedBox(height: 10),
// Audit 1.9 (t4 line 78): a CENTRED pill -- padding:9px 15px,
// radius:999, rgba(255,255,255,.07) + border .1, 12px/w700,
// icon expand_less 17px -- not a left-aligned OutlinedButton
// with tune_rounded at 18px.
Center(
child: Material(
type: MaterialType.transparency,
child: InkWell(
borderRadius: BorderRadius.circular(999),
onTap: () => PantallaReproductor.abrir(context, emisora),
child: DecoratedBox(
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.07),
borderRadius: BorderRadius.circular(999),
border: Border.all(
color: Colors.white.withValues(alpha: 0.1),
),
),
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 15,
vertical: 9,
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.expand_less_rounded,
size: 17,
color: theme.colorScheme.onSurface.withValues(
alpha: 0.78,
),
),
const SizedBox(width: 6),
Text(
l10n.openFullPlayerTooltip,
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w700,
color: theme.colorScheme.onSurface.withValues(
alpha: 0.78,
),
),
),
],
),
),
),
),
),
),
],
),
),
);
}
}
/// 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;
/// Audit 1.3: the prototype's Escuchar hero art is 132 (t4 line 56), and
/// its corner radius is 24 rather than the shared `radiusMd`.
static const _lado = 132.0;
static const _radio = 24.0;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final radius = BorderRadius.circular(_radio);
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(),
)
: _iconoFallback(),
),
),
);
}
Widget _shimmerCuadrado(ThemeData theme) => shimmer.Shimmer.fromColors(
baseColor: theme.colorScheme.surfaceContainerHighest,
highlightColor: theme.colorScheme.surface,
child: Container(color: theme.colorScheme.surfaceContainerHighest),
);
// Issue 6 (feedback-pruebas): was a flat `primaryContainer` square with a
// bare `radio_rounded` icon — now the same shared fallback every other
// surface uses.
Widget _iconoFallback() =>
PluriStationArtFallback(seed: emisora.uuid, iconSize: 36);
}
/// 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(
// Audit 1.8 (t4 line 73): 72x72 with a 38px icon, not 56x56
// with a 28px icon.
width: 72,
height: 72,
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: 38,
),
),
);
},
),
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);
}
}
/// Audit 1.6 (t4 line 62): "género · país · kbps" built ONLY from fields
/// [Emisora] already carries (`tags`/`pais`/`bitrate`) — no new fields, no
/// service calls. Whatever a station lacks is omitted gracefully; this
/// never renders a stray leading/trailing/doubled " · ".
String? _metaEscuchar(Emisora emisora) {
final partes = <String>[
if (emisora.generos.isNotEmpty) emisora.generos.first,
if (emisora.pais != null && emisora.pais!.isNotEmpty) emisora.pais!,
if (emisora.bitrate != null && emisora.bitrate! > 0)
'${emisora.bitrate} kbps',
];
return partes.isEmpty ? null : partes.join(' · ');
}
/// Audit 1.11 (t4 line 80): the pill badge next to "Tus emisoras" showing
/// the total favorite count — `rgba(255,255,255,.08)` fill, 11px/w800/60%.
class _InsigniaRecuento extends StatelessWidget {
const _InsigniaRecuento({required this.cuenta});
final int cuenta;
@override
Widget build(BuildContext context) {
return DecoratedBox(
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.08),
borderRadius: BorderRadius.circular(999),
),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
child: Text(
'$cuenta',
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w800,
color: const Color(0xFFF2F7FA).withValues(alpha: 0.6),
),
),
),
);
}
}
/// Audit 1.10 (t4 lines 82-88): the "Tus emisoras" grid cell — a 44px
/// square thumbnail (radius 11), name (13/w700/lh1.2) and genre
/// (11/55%). Deliberately NOT `TarjetaEmisora(esCompacta: true)`: that
/// widget always renders a favorite button and a live badge, neither of
/// which the prototype's grid cell draws (t4 line 84-87 is art + two text
/// lines, nothing else).
class _CeldaTusEmisoras extends StatelessWidget {
const _CeldaTusEmisoras({required this.emisora, required this.onTap});
final Emisora emisora;
final VoidCallback onTap;
static const _ladoArte = 44.0;
static const _radioArte = 11.0;
static const _radioCelda = 16.0;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final theme = Theme.of(context);
final stationName = localizedStationName(l10n, emisora.nombre);
final genero = emisora.generos.isNotEmpty ? emisora.generos.first : null;
return Semantics(
button: true,
label: l10n.stationSemanticLabel(stationName),
child: PluriGlassSurface(
borderRadius: BorderRadius.circular(_radioCelda),
padding: const EdgeInsets.all(8),
child: Material(
type: MaterialType.transparency,
child: InkWell(
borderRadius: BorderRadius.circular(_radioCelda),
onTap: onTap,
child: Row(
children: [
ClipRRect(
borderRadius: BorderRadius.circular(_radioArte),
child: SizedBox(
width: _ladoArte,
height: _ladoArte,
child: _arte(theme),
),
),
const SizedBox(width: 10),
Expanded(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
stationName,
style: const TextStyle(
fontSize: 13,
fontWeight: FontWeight.w700,
height: 1.2,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
if (genero != null)
Text(
genero,
style: TextStyle(
fontSize: 11,
color: theme.colorScheme.onSurface.withValues(
alpha: 0.55,
),
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
),
],
),
),
),
),
);
}
Widget _arte(ThemeData theme) {
if (emisora.favicon != null && emisora.favicon!.isNotEmpty) {
return CachedNetworkImage(
imageUrl: emisora.favicon!,
fit: BoxFit.cover,
placeholder: (_, __) => _shimmer(theme),
errorWidget: (_, __, ___) => _iconoFallback(),
);
}
return _iconoFallback();
}
Widget _shimmer(ThemeData theme) => shimmer.Shimmer.fromColors(
baseColor: theme.colorScheme.surfaceContainerHighest,
highlightColor: theme.colorScheme.surface,
child: Container(color: theme.colorScheme.surfaceContainerHighest),
);
// Issue 6 (feedback-pruebas): was a flat `primaryContainer` square with a
// bare `radio_rounded` icon — now the same shared fallback every other
// surface uses.
Widget _iconoFallback() =>
PluriStationArtFallback(seed: emisora.uuid, iconSize: 20);
}