TarjetaEmisora had the only good fallback for a station with no artwork -- a deterministic pick from 4 bundled illustrations with a gradient/glyph last resort. FilaEmisoraPlana's flat rows, the Escuchar hero, the "Tus emisoras" grid cell, the mini player and the full player each had their own, separate, flat primaryContainer square instead. Extract the good fallback into PluriStationArtFallback and use it from every one of those call sites. The selection formula (asset order, codeUnits-sum modulo) is preserved exactly, since navegacion_auto.dart mirrors the same formula independently for Android Auto's own drawable rotation.
341 lines
15 KiB
Dart
341 lines
15 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_radio.dart';
|
|
import '../l10n/display_names.dart';
|
|
import '../l10n/gen/app_localizations.dart';
|
|
import '../modelos/emisora.dart';
|
|
import '../pantallas/pantalla_reproductor.dart';
|
|
import '../servicios/servicio_audio.dart';
|
|
import '../tema/pluriwave_theme.dart';
|
|
import 'pluri_station_art_fallback.dart';
|
|
|
|
/// Barra inferior persistente con controles básicos de reproducción.
|
|
/// Toca la barra para abrir PantallaReproductor completa.
|
|
///
|
|
/// Item 22 / audit 3.6 (t4:184-188): a 60px full-bleed opaque bar with
|
|
/// station artwork -- replacing the former floating 999-radius glass pill,
|
|
/// which had no artwork at all.
|
|
class MiniReproductor extends StatefulWidget {
|
|
const MiniReproductor({super.key, this.visible = true});
|
|
|
|
/// Design ADR-7(b): on Escuchar, the embedded hero already shows the same
|
|
/// station, so `_PaginaPrincipal` passes `visible: false` there to avoid
|
|
/// showing it twice. Hidden VISUALLY only (`build` returns
|
|
/// `SizedBox.shrink()`) — the widget stays in the tree and mounted, so
|
|
/// `didChangeDependencies`'s `configurarLocalizaciones` call (S3-R3) keeps
|
|
/// running on every locale change regardless of which tab is active.
|
|
final bool visible;
|
|
|
|
/// Measured (not guessed) from this widget's actual laid-out height — see
|
|
/// `mini_reproductor_configurar_test.dart`'s measurement assertion. Backs
|
|
/// `PluriLayout.escucharBottomChromeInset` (ADR-7(b)). Item 22 fixes this
|
|
/// bar's content to a t4:184 `height:60px` container, so the measured
|
|
/// height is deterministic regardless of station-name text metrics.
|
|
static const double altura = 60;
|
|
|
|
@override
|
|
State<MiniReproductor> createState() => _MiniReproductorState();
|
|
}
|
|
|
|
class _MiniReproductorState extends State<MiniReproductor> {
|
|
Locale? _localeConfigurado;
|
|
|
|
@override
|
|
void didChangeDependencies() {
|
|
super.didChangeDependencies();
|
|
// S3-R3: configure localizations once per locale change — never from
|
|
// build(), which re-runs on every playback notification.
|
|
final locale = Localizations.localeOf(context);
|
|
if (_localeConfigurado != locale) {
|
|
_localeConfigurado = locale;
|
|
context.read<EstadoRadio>().configurarLocalizaciones(
|
|
AppLocalizations.of(context),
|
|
);
|
|
}
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final estado = context.watch<EstadoRadio>();
|
|
final l10n = AppLocalizations.of(context);
|
|
final emisora = estado.emisoraActual;
|
|
|
|
if (!widget.visible || emisora == null) return const SizedBox.shrink();
|
|
|
|
final t = context.pluriTokens;
|
|
final stationName = localizedStationName(l10n, emisora.nombre);
|
|
|
|
// Item 22 / audit 3.6 (t4:184-188): a 60px, full-bleed, OPAQUE bar
|
|
// (rgba(16,37,50,.97) == listSurface at .97 alpha) with a top hairline —
|
|
// no BackdropFilter, no side margins, no pill radius. `app.dart` no
|
|
// longer wraps this widget in its own horizontal padding either (see
|
|
// its `bottomNavigationBar` composition).
|
|
return DecoratedBox(
|
|
key: const ValueKey('mini-reproductor-superficie'),
|
|
decoration: BoxDecoration(
|
|
color: t.listSurface.withValues(alpha: 0.97),
|
|
border: Border(
|
|
top: BorderSide(color: Colors.white.withValues(alpha: 0.07)),
|
|
),
|
|
),
|
|
child: SafeArea(
|
|
top: false,
|
|
child: SizedBox(
|
|
height: 60,
|
|
child: Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: 12),
|
|
child: Row(
|
|
children: [
|
|
Expanded(
|
|
child: Semantics(
|
|
button: true,
|
|
label: l10n.miniPlayerOpenLabel(stationName),
|
|
child: Material(
|
|
color: Colors.transparent,
|
|
child: InkWell(
|
|
onTap:
|
|
() => PantallaReproductor.abrir(context, emisora),
|
|
child: Row(
|
|
children: [
|
|
_ArteMiniReproductor(emisora: emisora),
|
|
SizedBox(width: t.spacingSm),
|
|
Expanded(
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
stationName,
|
|
style: Theme.of(context)
|
|
.textTheme
|
|
.titleSmall
|
|
?.copyWith(fontWeight: FontWeight.w700),
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
StreamBuilder<EstadoReproduccion>(
|
|
stream: estado.estadoStream,
|
|
builder: (context, snapshot) {
|
|
final s =
|
|
snapshot.data ??
|
|
EstadoReproduccion.detenido;
|
|
final activo =
|
|
s == EstadoReproduccion.reproduciendo;
|
|
// WU16: reconectando/error are
|
|
// "connectivity trouble" states —
|
|
// tinted with offlineAccent so they
|
|
// read as visually distinct from
|
|
// ordinary loading/paused/stopped.
|
|
final conexionEnProblema =
|
|
s ==
|
|
EstadoReproduccion.reconectando ||
|
|
s == EstadoReproduccion.error;
|
|
return Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
// t4:187: a live station gets a
|
|
// small teal dot before its label
|
|
// ("En directo"). Static, not the
|
|
// prototype's `pw-pulse` animation
|
|
// — an infinite AnimationController
|
|
// here would hang every existing
|
|
// `pumpAndSettle()` call in this
|
|
// widget's own test suite that
|
|
// exercises a playing station.
|
|
if (activo) ...[
|
|
Container(
|
|
width: 6,
|
|
height: 6,
|
|
margin: const EdgeInsets.only(
|
|
right: 5,
|
|
),
|
|
decoration: BoxDecoration(
|
|
color: t.liveGreen,
|
|
shape: BoxShape.circle,
|
|
),
|
|
),
|
|
],
|
|
Flexible(
|
|
child: Text(
|
|
_labelEstado(l10n, s),
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: Theme.of(
|
|
context,
|
|
).textTheme.bodySmall?.copyWith(
|
|
color:
|
|
conexionEnProblema
|
|
? t.offlineAccent
|
|
: activo
|
|
? t.liveGreen
|
|
: Theme.of(context)
|
|
.colorScheme
|
|
.onSurface
|
|
.withValues(
|
|
alpha: 0.7,
|
|
),
|
|
fontWeight:
|
|
(activo ||
|
|
conexionEnProblema)
|
|
? FontWeight.w600
|
|
: FontWeight.w400,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
);
|
|
},
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
StreamBuilder<EstadoReproduccion>(
|
|
stream: estado.estadoStream,
|
|
builder: (context, snapshot) {
|
|
final s = snapshot.data ?? EstadoReproduccion.detenido;
|
|
// S7-R3: reconectando is a transient stall — render it
|
|
// like cargando (spinner), never as the error/retry
|
|
// affordance.
|
|
if (s == EstadoReproduccion.cargando ||
|
|
s == EstadoReproduccion.reconectando) {
|
|
// WU16: only the reconectando sub-state gets the
|
|
// offline accent — plain cargando (e.g. the very first
|
|
// play) keeps the default spinner colour, since it is
|
|
// not a connectivity problem.
|
|
final reconectando = s == EstadoReproduccion.reconectando;
|
|
return SizedBox(
|
|
width: 48,
|
|
height: 48,
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(12),
|
|
child: CircularProgressIndicator(
|
|
strokeWidth: 2,
|
|
color: reconectando ? t.offlineAccent : null,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
if (s == EstadoReproduccion.error) {
|
|
final emisoraActual = estado.emisoraActual;
|
|
return IconButton(
|
|
tooltip: l10n.retryAction,
|
|
icon: Icon(
|
|
Icons.refresh_rounded,
|
|
color: t.offlineAccent,
|
|
),
|
|
onPressed:
|
|
emisoraActual != null
|
|
? () => estado.reproducir(emisoraActual)
|
|
: null,
|
|
constraints: const BoxConstraints.tightFor(
|
|
width: 48,
|
|
height: 48,
|
|
),
|
|
);
|
|
}
|
|
|
|
return Semantics(
|
|
button: true,
|
|
label:
|
|
s == EstadoReproduccion.reproduciendo
|
|
? l10n.pauseAction
|
|
: l10n.playAction,
|
|
child: IconButton(
|
|
tooltip:
|
|
s == EstadoReproduccion.reproduciendo
|
|
? l10n.pauseAction
|
|
: l10n.playAction,
|
|
// t4:188: pause_circle at 30px.
|
|
icon: Icon(
|
|
s == EstadoReproduccion.reproduciendo
|
|
? Icons.pause_circle_filled_rounded
|
|
: Icons.play_circle_fill_rounded,
|
|
size: 30,
|
|
color: t.electricMagenta,
|
|
),
|
|
onPressed: estado.togglePlay,
|
|
constraints: const BoxConstraints.tightFor(
|
|
width: 48,
|
|
height: 48,
|
|
),
|
|
),
|
|
);
|
|
},
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
String _labelEstado(AppLocalizations l10n, EstadoReproduccion estado) {
|
|
return switch (estado) {
|
|
EstadoReproduccion.cargando => l10n.playbackStatusConnecting,
|
|
EstadoReproduccion.reproduciendo => l10n.playbackStatusLive,
|
|
EstadoReproduccion.pausado => l10n.playbackStatusPaused,
|
|
EstadoReproduccion.reconectando => l10n.playbackStatusReconnecting,
|
|
EstadoReproduccion.error => l10n.playbackStatusConnectionError,
|
|
EstadoReproduccion.detenido => l10n.playbackStatusStopped,
|
|
};
|
|
}
|
|
}
|
|
|
|
/// Item 22 / audit 3.6 (t4:186): square station artwork, 42x42, radius 11 —
|
|
/// the element the former glass pill never rendered at all. Mirrors the
|
|
/// CachedNetworkImage/shimmer/fallback pattern already established by
|
|
/// `TarjetaEmisora` and `_ArteEscuchar`, sized for this bar specifically.
|
|
class _ArteMiniReproductor extends StatelessWidget {
|
|
const _ArteMiniReproductor({required this.emisora});
|
|
|
|
final Emisora emisora;
|
|
|
|
static const _lado = 42.0;
|
|
static const _radio = 11.0;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final theme = Theme.of(context);
|
|
return ClipRRect(
|
|
key: const ValueKey('mini-reproductor-arte'),
|
|
borderRadius: BorderRadius.circular(_radio),
|
|
child: SizedBox(
|
|
width: _lado,
|
|
height: _lado,
|
|
child:
|
|
(emisora.favicon != null && emisora.favicon!.isNotEmpty)
|
|
? CachedNetworkImage(
|
|
imageUrl: emisora.favicon!,
|
|
fit: BoxFit.cover,
|
|
placeholder: (_, __) => _shimmer(theme),
|
|
errorWidget: (_, __, ___) => _iconoFallback(),
|
|
)
|
|
: _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);
|
|
}
|