feat(ui): add premium PluriWave redesign
This commit is contained in:
@@ -2,11 +2,15 @@ import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:shimmer/shimmer.dart';
|
||||
|
||||
import '../estado/estado_radio.dart';
|
||||
import '../modelos/emisora.dart';
|
||||
import '../tema/pluriwave_theme.dart';
|
||||
import 'pluri_glass_surface.dart';
|
||||
import 'pluri_icon.dart';
|
||||
|
||||
/// Tarjeta compacta para mostrar una emisora en listas y grids.
|
||||
/// Incluye botón de favorito visible en ambos modos.
|
||||
/// Incluye botón de favorito visible en ambos modos.
|
||||
class TarjetaEmisora extends StatefulWidget {
|
||||
final Emisora emisora;
|
||||
final VoidCallback? onTap;
|
||||
@@ -24,31 +28,18 @@ class TarjetaEmisora extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _TarjetaEmisoraState extends State<TarjetaEmisora> {
|
||||
bool _esFavorito = false;
|
||||
bool _toggling = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_checkFavorito();
|
||||
}
|
||||
|
||||
Future<void> _checkFavorito() async {
|
||||
final fav = await context.read<EstadoRadio>().esFavorito(widget.emisora.uuid);
|
||||
if (mounted) setState(() => _esFavorito = fav);
|
||||
}
|
||||
|
||||
Future<void> _toggle() async {
|
||||
if (_toggling) return;
|
||||
_toggling = true;
|
||||
setState(() => _toggling = true);
|
||||
final estado = context.read<EstadoRadio>();
|
||||
final esFav = await estado.toggleFavorito(widget.emisora);
|
||||
if (mounted) setState(() => _esFavorito = esFav);
|
||||
_toggling = false;
|
||||
if (mounted) setState(() => _toggling = false);
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
|
||||
content: Text(esFav
|
||||
? '${widget.emisora.nombre} añadida a favoritos'
|
||||
? '${widget.emisora.nombre} añadida a favoritos'
|
||||
: '${widget.emisora.nombre} eliminada de favoritos'),
|
||||
duration: const Duration(seconds: 2),
|
||||
));
|
||||
@@ -57,19 +48,26 @@ class _TarjetaEmisoraState extends State<TarjetaEmisora> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return Card(
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: InkWell(
|
||||
onTap: widget.onTap,
|
||||
child: widget.esCompacta
|
||||
? _buildCompacta(theme)
|
||||
: _buildCompleta(theme),
|
||||
final t = context.pluriTokens;
|
||||
return Semantics(
|
||||
button: widget.onTap != null,
|
||||
label: 'Emisora ${widget.emisora.nombre}',
|
||||
child: PluriGlassSurface(
|
||||
padding: EdgeInsets.zero,
|
||||
borderRadius: BorderRadius.circular(widget.esCompacta ? t.radiusMd : t.radiusLg),
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
onTap: widget.onTap,
|
||||
child: widget.esCompacta ? _buildCompacta() : _buildCompleta(),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildCompleta(ThemeData theme) {
|
||||
Widget _buildCompleta() {
|
||||
final t = context.pluriTokens;
|
||||
return Stack(
|
||||
children: [
|
||||
Column(
|
||||
@@ -77,96 +75,148 @@ class _TarjetaEmisoraState extends State<TarjetaEmisora> {
|
||||
children: [
|
||||
AspectRatio(
|
||||
aspectRatio: 1,
|
||||
child: _logo(theme, 60),
|
||||
child: _logo(60),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(8, 8, 8, 4),
|
||||
padding: EdgeInsets.fromLTRB(t.spacingMd, t.spacingSm, t.spacingMd, t.spacingMd),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
widget.emisora.nombre,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.bold),
|
||||
style: Theme.of(context).textTheme.titleSmall?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
if (widget.emisora.pais != null)
|
||||
Text(
|
||||
widget.emisora.pais!,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
Padding(
|
||||
padding: EdgeInsets.only(top: t.spacingXs),
|
||||
child: Text(
|
||||
widget.emisora.pais!,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.onSurface
|
||||
.withValues(alpha: 0.72),
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
// Botón favorito superpuesto (esquina superior derecha)
|
||||
Positioned(
|
||||
top: 4,
|
||||
right: 4,
|
||||
child: _botonFavorito(theme, mini: true),
|
||||
top: t.spacingSm,
|
||||
right: t.spacingSm,
|
||||
child: _botonFavorito(mini: true),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildCompacta(ThemeData theme) {
|
||||
return ListTile(
|
||||
leading: SizedBox(width: 48, height: 48, child: _logo(theme, 24)),
|
||||
title: Text(
|
||||
widget.emisora.nombre,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
Widget _buildCompacta() {
|
||||
final t = context.pluriTokens;
|
||||
final subtitulo = [widget.emisora.pais, widget.emisora.idioma]
|
||||
.where((s) => s != null && s.isNotEmpty)
|
||||
.join(' · ');
|
||||
|
||||
return Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: t.spacingSm, vertical: t.spacingXs),
|
||||
child: Row(
|
||||
children: [
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(t.radiusSm),
|
||||
child: SizedBox(width: 48, height: 48, child: _logo(24)),
|
||||
),
|
||||
SizedBox(width: t.spacingSm),
|
||||
Expanded(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
widget.emisora.nombre,
|
||||
style: Theme.of(context).textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w700),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
if (subtitulo.isNotEmpty)
|
||||
Text(
|
||||
subtitulo,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.bodySmall
|
||||
?.copyWith(color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.72)),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
_botonFavorito(mini: false),
|
||||
],
|
||||
),
|
||||
subtitle: Text(
|
||||
[widget.emisora.pais, widget.emisora.idioma]
|
||||
.where((s) => s != null && s.isNotEmpty)
|
||||
.join(' · '),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
trailing: _botonFavorito(theme, mini: false),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _botonFavorito(ThemeData theme, {required bool mini}) {
|
||||
return Material(
|
||||
color: mini
|
||||
? theme.colorScheme.surface.withValues(alpha: 0.8)
|
||||
: Colors.transparent,
|
||||
shape: const CircleBorder(),
|
||||
child: InkWell(
|
||||
customBorder: const CircleBorder(),
|
||||
onTap: _toggle,
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(mini ? 6 : 4),
|
||||
child: Icon(
|
||||
_esFavorito ? Icons.favorite_rounded : Icons.favorite_outline_rounded,
|
||||
color: _esFavorito ? theme.colorScheme.error : theme.colorScheme.onSurfaceVariant,
|
||||
size: mini ? 18 : 22,
|
||||
Widget _botonFavorito({required bool mini}) {
|
||||
final t = context.pluriTokens;
|
||||
final esFavorito = context.select<EstadoRadio, bool>(
|
||||
(estado) => estado.listaFavoritos.any((e) => e.uuid == widget.emisora.uuid),
|
||||
);
|
||||
|
||||
final icono = mini
|
||||
? Icon(
|
||||
esFavorito ? Icons.favorite_rounded : Icons.favorite_outline_rounded,
|
||||
color: esFavorito ? t.warmCoral : Colors.white.withValues(alpha: 0.82),
|
||||
size: 18,
|
||||
)
|
||||
: PluriIcon(
|
||||
glyph: PluriIconGlyph.favorites,
|
||||
variant: esFavorito ? PluriIconVariant.activeGlow : PluriIconVariant.outline,
|
||||
size: 20,
|
||||
semanticLabel: esFavorito ? 'Quitar de favoritos' : 'Añadir a favoritos',
|
||||
);
|
||||
|
||||
return Semantics(
|
||||
button: true,
|
||||
toggled: esFavorito,
|
||||
label: esFavorito ? 'Quitar de favoritos' : 'Añadir a favoritos',
|
||||
child: Material(
|
||||
color: mini ? t.glassSurface : Colors.transparent,
|
||||
shape: const CircleBorder(),
|
||||
child: InkWell(
|
||||
customBorder: const CircleBorder(),
|
||||
onTap: _toggling ? null : _toggle,
|
||||
child: SizedBox(
|
||||
width: mini ? 36 : 44,
|
||||
height: mini ? 36 : 44,
|
||||
child: Center(child: icono),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _logo(ThemeData theme, double iconSize) {
|
||||
Widget _logo(double iconSize) {
|
||||
if (widget.emisora.favicon != null && widget.emisora.favicon!.isNotEmpty) {
|
||||
return CachedNetworkImage(
|
||||
imageUrl: widget.emisora.favicon!,
|
||||
fit: BoxFit.cover,
|
||||
placeholder: (_, __) => _shimmer(theme),
|
||||
errorWidget: (_, __, ___) => _iconoFallback(theme, iconSize),
|
||||
placeholder: (_, __) => _shimmer(),
|
||||
errorWidget: (_, __, ___) => _iconoFallback(iconSize),
|
||||
);
|
||||
}
|
||||
return _iconoFallback(theme, iconSize);
|
||||
return _iconoFallback(iconSize);
|
||||
}
|
||||
|
||||
Widget _shimmer(ThemeData theme) {
|
||||
Widget _shimmer() {
|
||||
final theme = Theme.of(context);
|
||||
return Shimmer.fromColors(
|
||||
baseColor: theme.colorScheme.surfaceContainerHighest,
|
||||
highlightColor: theme.colorScheme.surface,
|
||||
@@ -174,10 +224,24 @@ class _TarjetaEmisoraState extends State<TarjetaEmisora> {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _iconoFallback(ThemeData theme, double size) {
|
||||
return Container(
|
||||
color: theme.colorScheme.primaryContainer,
|
||||
child: Icon(Icons.radio, size: size, color: theme.colorScheme.onPrimaryContainer),
|
||||
Widget _iconoFallback(double size) {
|
||||
final t = context.pluriTokens;
|
||||
return DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
colors: [t.deepViolet, t.electricMagenta.withValues(alpha: 0.8)],
|
||||
),
|
||||
),
|
||||
child: Center(
|
||||
child: PluriIcon(
|
||||
glyph: PluriIconGlyph.player,
|
||||
variant: PluriIconVariant.filled,
|
||||
size: size,
|
||||
semanticLabel: 'Icono de emisora',
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user