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).
268 lines
12 KiB
Dart
268 lines
12 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:provider/provider.dart';
|
|
|
|
import '../estado/estado_radio.dart';
|
|
import '../l10n/display_names.dart';
|
|
import '../l10n/gen/app_localizations.dart';
|
|
import '../pantallas/pantalla_reproductor.dart';
|
|
import '../servicios/servicio_audio.dart';
|
|
import '../tema/pluriwave_theme.dart';
|
|
import 'pluri_glass_surface.dart';
|
|
import 'pluri_icon.dart';
|
|
import 'visualizador_audio.dart';
|
|
|
|
/// Barra inferior persistente con controles básicos de reproducción.
|
|
/// Toca la barra para abrir PantallaReproductor completa.
|
|
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 with a
|
|
/// representative station name, default text scale and theme — see
|
|
/// `mini_reproductor_configurar_test.dart`'s measurement assertion. Backs
|
|
/// `PluriLayout.escucharBottomChromeInset` (ADR-7(b)).
|
|
static const double altura = 72;
|
|
|
|
@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);
|
|
|
|
return SafeArea(
|
|
top: false,
|
|
child: Padding(
|
|
padding: EdgeInsets.fromLTRB(
|
|
t.spacingMd,
|
|
t.spacingSm,
|
|
t.spacingMd,
|
|
t.spacingSm,
|
|
),
|
|
child: PluriGlassSurface(
|
|
// S3 (Tier 1 visual fidelity): chrome — one of the system rule's
|
|
// two named exceptions to the opaque list-surface default.
|
|
glass: true,
|
|
padding: EdgeInsets.symmetric(
|
|
horizontal: t.spacingSm,
|
|
vertical: t.spacingXs,
|
|
),
|
|
borderRadius: BorderRadius.circular(999),
|
|
child: Row(
|
|
children: [
|
|
Expanded(
|
|
child: Semantics(
|
|
button: true,
|
|
label: l10n.miniPlayerOpenLabel(stationName),
|
|
child: Material(
|
|
color: Colors.transparent,
|
|
child: InkWell(
|
|
borderRadius: BorderRadius.circular(999),
|
|
onTap: () => PantallaReproductor.abrir(context, emisora),
|
|
child: Padding(
|
|
padding: EdgeInsets.symmetric(
|
|
horizontal: t.spacingXs,
|
|
vertical: t.spacingXs,
|
|
),
|
|
child: Row(
|
|
children: [
|
|
SizedBox(
|
|
width: 40,
|
|
height: 40,
|
|
child: Center(
|
|
child: IndicadorReproduccion(
|
|
estadoStream: estado.estadoStream,
|
|
color: t.electricMagenta,
|
|
size: 20,
|
|
),
|
|
),
|
|
),
|
|
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 Text(
|
|
_labelEstado(l10n, s),
|
|
style: Theme.of(
|
|
context,
|
|
).textTheme.bodySmall?.copyWith(
|
|
color:
|
|
conexionEnProblema
|
|
? t.offlineAccent
|
|
: activo
|
|
? t.warmCoral
|
|
: Theme.of(context)
|
|
.colorScheme
|
|
.onSurface
|
|
.withValues(alpha: 0.7),
|
|
fontWeight:
|
|
(activo || conexionEnProblema)
|
|
? FontWeight.w600
|
|
: FontWeight.w400,
|
|
),
|
|
);
|
|
},
|
|
),
|
|
],
|
|
),
|
|
),
|
|
PluriIcon(
|
|
glyph: PluriIconGlyph.player,
|
|
variant: PluriIconVariant.activeGlow,
|
|
size: 18,
|
|
semanticLabel: l10n.playerIconLabel,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
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,
|
|
icon: Icon(
|
|
s == EstadoReproduccion.reproduciendo
|
|
? Icons.pause_circle_filled_rounded
|
|
: Icons.play_circle_fill_rounded,
|
|
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,
|
|
};
|
|
}
|
|
}
|