Some checks failed
Flutter CI/CD — PluriWave / Test + Build (push) Has been cancelled
- MainActivity: extiende AudioServiceActivity (fix pantalla en blanco) - ServicioAudio: AndroidEqualizer en AudioPipeline, aplicarPreset(), setBanda() - PresetEcualizador: modelo independiente (Flat/Rock/Pop/BassBoost/Jazz/Voz) - EcualizadorWidget: 5 sliders verticales + PresetsEcualizadorWidget - TarjetaEmisora: botón favorito visible en grid y lista (toggle con SnackBar) - EstadoRadio: emisoras custom (CRUD), export/import JSON v1, presets por emisora - PantallaAjustes: ecualizador interactivo, form añadir emisora, backup export/import - pubspec: +file_picker ^8.1.7, +uuid ^4.5.1
211 lines
6.3 KiB
Dart
211 lines
6.3 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';
|
|
import '../estado/estado_radio.dart';
|
|
import '../modelos/emisora.dart';
|
|
|
|
/// Tarjeta compacta para mostrar una emisora en listas y grids.
|
|
/// Incluye botón de favorito visible en ambos modos.
|
|
class TarjetaEmisora extends StatefulWidget {
|
|
final Emisora emisora;
|
|
final VoidCallback? onTap;
|
|
final bool esCompacta;
|
|
|
|
const TarjetaEmisora({
|
|
super.key,
|
|
required this.emisora,
|
|
this.onTap,
|
|
this.esCompacta = false,
|
|
});
|
|
|
|
@override
|
|
State<TarjetaEmisora> createState() => _TarjetaEmisoraState();
|
|
}
|
|
|
|
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;
|
|
final estado = context.read<EstadoRadio>();
|
|
final esFav = await estado.toggleFavorito(widget.emisora);
|
|
if (mounted) setState(() => _esFavorito = esFav);
|
|
_toggling = false;
|
|
if (mounted) {
|
|
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
|
|
content: Text(esFav
|
|
? '${widget.emisora.nombre} añadida a favoritos'
|
|
: '${widget.emisora.nombre} eliminada de favoritos'),
|
|
duration: const Duration(seconds: 2),
|
|
));
|
|
}
|
|
}
|
|
|
|
@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),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildCompleta(ThemeData theme) {
|
|
return Stack(
|
|
children: [
|
|
Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
AspectRatio(
|
|
aspectRatio: 1,
|
|
child: _logo(theme, 60),
|
|
),
|
|
Padding(
|
|
padding: const EdgeInsets.fromLTRB(8, 8, 8, 4),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
widget.emisora.nombre,
|
|
style: theme.textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.bold),
|
|
maxLines: 2,
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
if (widget.emisora.pais != null)
|
|
Text(
|
|
widget.emisora.pais!,
|
|
style: theme.textTheme.bodySmall?.copyWith(
|
|
color: theme.colorScheme.onSurfaceVariant,
|
|
),
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
// Botón favorito superpuesto (esquina superior derecha)
|
|
Positioned(
|
|
top: 4,
|
|
right: 4,
|
|
child: _botonFavorito(theme, 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,
|
|
),
|
|
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 _logo(ThemeData theme, 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),
|
|
);
|
|
}
|
|
return _iconoFallback(theme, iconSize);
|
|
}
|
|
|
|
Widget _shimmer(ThemeData theme) {
|
|
return Shimmer.fromColors(
|
|
baseColor: theme.colorScheme.surfaceContainerHighest,
|
|
highlightColor: theme.colorScheme.surface,
|
|
child: Container(color: theme.colorScheme.surfaceContainerHighest),
|
|
);
|
|
}
|
|
|
|
Widget _iconoFallback(ThemeData theme, double size) {
|
|
return Container(
|
|
color: theme.colorScheme.primaryContainer,
|
|
child: Icon(Icons.radio, size: size, color: theme.colorScheme.onPrimaryContainer),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Placeholder shimmer para listas en carga.
|
|
class TarjetaEmisoraShimmer extends StatelessWidget {
|
|
const TarjetaEmisoraShimmer({super.key});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final theme = Theme.of(context);
|
|
return Shimmer.fromColors(
|
|
baseColor: theme.colorScheme.surfaceContainerHighest,
|
|
highlightColor: theme.colorScheme.surface,
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
AspectRatio(
|
|
aspectRatio: 1,
|
|
child: Container(color: theme.colorScheme.surfaceContainerHighest),
|
|
),
|
|
const SizedBox(height: 8),
|
|
Container(height: 14, color: theme.colorScheme.surfaceContainerHighest),
|
|
const SizedBox(height: 4),
|
|
Container(height: 12, width: 60, color: theme.colorScheme.surfaceContainerHighest),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|