feat(v0.3.0): ecualizador + favoritos en tarjeta + emisoras custom + export/import + fix MainActivity
Some checks failed
Flutter CI/CD — PluriWave / Test + Build (push) Has been cancelled
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
This commit is contained in:
@@ -37,7 +37,7 @@ class PluriWaveApp extends StatelessWidget {
|
||||
textTheme: GoogleFonts.interTextTheme(
|
||||
ThemeData(brightness: brightness).textTheme,
|
||||
),
|
||||
cardTheme: CardTheme(
|
||||
cardTheme: CardThemeData(
|
||||
elevation: 0,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||
color: colorScheme.surfaceContainerLow,
|
||||
|
||||
@@ -1,22 +1,24 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import '../modelos/emisora.dart';
|
||||
import '../modelos/preset_ecualizador.dart';
|
||||
import '../servicios/servicio_audio.dart';
|
||||
import '../servicios/servicio_favoritos.dart';
|
||||
import '../servicios/servicio_radio.dart';
|
||||
import '../servicios/servicio_timer.dart';
|
||||
|
||||
/// Estado global de la app con ChangeNotifier (Provider).
|
||||
///
|
||||
/// Errores de reproducción se emiten por [errorStream] para mostrar como
|
||||
/// SnackBar — no bloquean la UI.
|
||||
class EstadoRadio extends ChangeNotifier {
|
||||
final ServicioAudio audio = ServicioAudio();
|
||||
final ServicioFavoritos favoritos = ServicioFavoritos();
|
||||
final ServicioRadio radio = ServicioRadio();
|
||||
late final ServicioTimer timer;
|
||||
|
||||
// Errores de reproducción → SnackBar en el UI
|
||||
// Errores de reproducción → SnackBar
|
||||
final _errorController = StreamController<String>.broadcast();
|
||||
Stream<String> get errorStream => _errorController.stream;
|
||||
|
||||
@@ -24,10 +26,15 @@ class EstadoRadio extends ChangeNotifier {
|
||||
List<Emisora> _tendencias = [];
|
||||
List<Emisora> _resultadosBusqueda = [];
|
||||
List<Emisora> _listafavoritos = [];
|
||||
List<Emisora> _emisorasCustom = [];
|
||||
|
||||
// Presets EQ guardados por uuid de emisora
|
||||
final Map<String, PresetEcualizador> _presetsEmisoraMap = {};
|
||||
PresetEcualizador _presetActual = PresetEcualizador.flat;
|
||||
|
||||
bool _cargandoPopulares = false;
|
||||
bool _cargandoBusqueda = false;
|
||||
String? _errorCarga; // solo para errores de carga de lista (banner estático)
|
||||
String? _errorCarga;
|
||||
|
||||
EstadoRadio() {
|
||||
timer = ServicioTimer(audio);
|
||||
@@ -38,16 +45,20 @@ class EstadoRadio extends ChangeNotifier {
|
||||
List<Emisora> get tendencias => _tendencias;
|
||||
List<Emisora> get resultadosBusqueda => _resultadosBusqueda;
|
||||
List<Emisora> get listaFavoritos => _listafavoritos;
|
||||
List<Emisora> get emisorasCustom => _emisorasCustom;
|
||||
bool get cargandoPopulares => _cargandoPopulares;
|
||||
bool get cargandoBusqueda => _cargandoBusqueda;
|
||||
String? get error => _errorCarga;
|
||||
Emisora? get emisoraActual => audio.emisoraActual;
|
||||
Stream<EstadoReproduccion> get estadoStream => audio.estadoStream;
|
||||
PresetEcualizador get presetEcualizador => _presetActual;
|
||||
bool get ecualizadorDisponible => audio.ecualizadorDisponible;
|
||||
|
||||
Future<void> _init() async {
|
||||
await Future.wait([
|
||||
cargarPopulares(),
|
||||
cargarFavoritos(),
|
||||
_cargarEmisoresCustom(),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -92,7 +103,6 @@ class EstadoRadio extends ChangeNotifier {
|
||||
tag: tag,
|
||||
);
|
||||
} catch (e) {
|
||||
// Error de búsqueda → toast, no bloquear pantalla
|
||||
_errorController.add('Error en la búsqueda. Comprueba tu conexión.');
|
||||
} finally {
|
||||
_cargandoBusqueda = false;
|
||||
@@ -104,9 +114,11 @@ class EstadoRadio extends ChangeNotifier {
|
||||
try {
|
||||
await audio.reproducir(emisora);
|
||||
radio.registrarClick(emisora.uuid); // fire & forget
|
||||
// Restaurar preset del ecualizador de esta emisora
|
||||
final preset = _presetsEmisoraMap[emisora.uuid] ?? PresetEcualizador.flat;
|
||||
await cambiarPresetEcualizador(preset, guardPorEmisora: false);
|
||||
notifyListeners();
|
||||
} catch (e) {
|
||||
// Error de reproducción → SnackBar, no pintar en medio de la UI
|
||||
_errorController.add('No se puede reproducir "${emisora.nombre}"');
|
||||
}
|
||||
}
|
||||
@@ -119,11 +131,125 @@ class EstadoRadio extends ChangeNotifier {
|
||||
Future<bool> toggleFavorito(Emisora emisora) async {
|
||||
final esFav = await favoritos.toggleFavorito(emisora);
|
||||
await cargarFavoritos();
|
||||
notifyListeners();
|
||||
return esFav;
|
||||
}
|
||||
|
||||
Future<bool> esFavorito(String uuid) => favoritos.esFavorito(uuid);
|
||||
|
||||
// ── Ecualizador ──────────────────────────────────────────────────────────
|
||||
|
||||
Future<void> cambiarPresetEcualizador(
|
||||
PresetEcualizador preset, {
|
||||
bool guardPorEmisora = true,
|
||||
}) async {
|
||||
_presetActual = preset;
|
||||
await audio.aplicarPreset(preset);
|
||||
if (guardPorEmisora && emisoraActual != null) {
|
||||
_presetsEmisoraMap[emisoraActual!.uuid] = preset;
|
||||
}
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> cambiarBandaEcualizador(int index, double db) async {
|
||||
final bandas = List<double>.from(_presetActual.bandas);
|
||||
if (index >= 0 && index < bandas.length) bandas[index] = db;
|
||||
_presetActual = PresetEcualizador(nombre: 'Personalizado', bandas: bandas);
|
||||
await audio.setBanda(index, db);
|
||||
if (emisoraActual != null) {
|
||||
_presetsEmisoraMap[emisoraActual!.uuid] = _presetActual;
|
||||
}
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
// ── Emisoras personalizadas ───────────────────────────────────────────────
|
||||
|
||||
Future<File> _archivoCustom() async {
|
||||
final dir = await getApplicationDocumentsDirectory();
|
||||
return File('${dir.path}/emisoras_custom.json');
|
||||
}
|
||||
|
||||
Future<void> _cargarEmisoresCustom() async {
|
||||
try {
|
||||
final f = await _archivoCustom();
|
||||
if (!await f.exists()) return;
|
||||
final data = jsonDecode(await f.readAsString()) as List;
|
||||
_emisorasCustom = data
|
||||
.map((e) => Emisora.fromMap(Map<String, dynamic>.from(e as Map)))
|
||||
.toList();
|
||||
} catch (_) {
|
||||
_emisorasCustom = [];
|
||||
}
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> _guardarEmisoresCustom() async {
|
||||
final f = await _archivoCustom();
|
||||
await f.writeAsString(jsonEncode(_emisorasCustom.map((e) => e.toMap()).toList()));
|
||||
}
|
||||
|
||||
Future<void> agregarEmitoraCustom(Emisora emisora) async {
|
||||
_emisorasCustom.removeWhere((e) => e.uuid == emisora.uuid);
|
||||
_emisorasCustom.add(emisora);
|
||||
await _guardarEmisoresCustom();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> eliminarEmitoraCustom(String uuid) async {
|
||||
_emisorasCustom.removeWhere((e) => e.uuid == uuid);
|
||||
await _guardarEmisoresCustom();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
// ── Export / Import ───────────────────────────────────────────────────────
|
||||
|
||||
/// Genera el JSON de toda la configuración.
|
||||
Future<Map<String, dynamic>> exportarConfig() async {
|
||||
final favs = await favoritos.obtenerTodos();
|
||||
return {
|
||||
'version': 1,
|
||||
'exportedAt': DateTime.now().toIso8601String(),
|
||||
'favoritos': favs.map((e) => e.toMap()).toList(),
|
||||
'emisorasCustom': _emisorasCustom.map((e) => e.toMap()).toList(),
|
||||
'presetsEcualizador': _presetsEmisoraMap.map(
|
||||
(uuid, preset) => MapEntry(uuid, preset.toJson()),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
/// Importa configuración desde un JSON exportado previamente.
|
||||
Future<void> importarConfig(Map<String, dynamic> data) async {
|
||||
final version = data['version'] as int? ?? 1;
|
||||
if (version != 1) throw Exception('Versión de configuración no compatible');
|
||||
|
||||
// Importar favoritos
|
||||
final favRaw = data['favoritos'] as List? ?? [];
|
||||
for (final raw in favRaw) {
|
||||
final emisora = Emisora.fromMap(Map<String, dynamic>.from(raw as Map));
|
||||
await favoritos.agregar(emisora);
|
||||
}
|
||||
|
||||
// Importar emisoras custom
|
||||
final customRaw = data['emisorasCustom'] as List? ?? [];
|
||||
_emisorasCustom = customRaw
|
||||
.map((e) => Emisora.fromMap(Map<String, dynamic>.from(e as Map)))
|
||||
.toList();
|
||||
await _guardarEmisoresCustom();
|
||||
|
||||
// Importar presets EQ
|
||||
final presetsRaw = data['presetsEcualizador'] as Map? ?? {};
|
||||
_presetsEmisoraMap.clear();
|
||||
presetsRaw.forEach((uuid, presetJson) {
|
||||
_presetsEmisoraMap[uuid as String] =
|
||||
PresetEcualizador.desdeJson(Map<String, dynamic>.from(presetJson as Map));
|
||||
});
|
||||
|
||||
await cargarFavoritos();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
// ── Timer ─────────────────────────────────────────────────────────────────
|
||||
|
||||
void iniciarTimer(int minutos) {
|
||||
timer.iniciar(minutos);
|
||||
notifyListeners();
|
||||
|
||||
@@ -6,8 +6,6 @@ import 'servicios/servicio_audio.dart';
|
||||
Future<void> main() async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
// Inicializar audio_service para reproducción en background.
|
||||
// El handler se registra globalmente para que ServicioAudio lo use.
|
||||
final handler = await AudioService.init(
|
||||
builder: () => PluriWaveAudioHandler(),
|
||||
config: const AudioServiceConfig(
|
||||
|
||||
29
lib/modelos/preset_ecualizador.dart
Normal file
29
lib/modelos/preset_ecualizador.dart
Normal file
@@ -0,0 +1,29 @@
|
||||
/// Modelo de preset de ecualizador.
|
||||
/// 5 bandas: 60Hz, 250Hz, 1kHz, 4kHz, 16kHz
|
||||
class PresetEcualizador {
|
||||
final String nombre;
|
||||
final List<double> bandas; // 5 valores entre -12.0 y +12.0 dB
|
||||
|
||||
const PresetEcualizador({required this.nombre, required this.bandas})
|
||||
: assert(bandas.length == 5);
|
||||
|
||||
static final flat = PresetEcualizador(nombre: 'Flat', bandas: [0.0, 0.0, 0.0, 0.0, 0.0]);
|
||||
static final rock = PresetEcualizador(nombre: 'Rock', bandas: [2.0, 1.0, -1.0, 2.0, 3.0]);
|
||||
static final pop = PresetEcualizador(nombre: 'Pop', bandas: [1.0, 1.5, 0.5, 1.0, 1.5]);
|
||||
static final bassBoost = PresetEcualizador(nombre: 'Bass Boost', bandas: [5.0, 3.0, -1.0, 0.5, 0.0]);
|
||||
static final jazz = PresetEcualizador(nombre: 'Jazz', bandas: [3.0, -1.0, -1.5, 2.0, 4.0]);
|
||||
static final voz = PresetEcualizador(nombre: 'Voz', bandas: [-2.0, -1.0, 2.0, 3.0, 1.0]);
|
||||
|
||||
static final presets = [flat, rock, pop, bassBoost, jazz, voz];
|
||||
|
||||
factory PresetEcualizador.desdeJson(Map<String, dynamic> json) {
|
||||
final raw = (json['bandas'] as List?)?.map((e) => (e as num).toDouble()).toList() ?? <double>[];
|
||||
final bandas = List<double>.generate(5, (i) => i < raw.length ? raw[i] : 0.0);
|
||||
return PresetEcualizador(nombre: json['nombre'] as String? ?? 'Personalizado', bandas: bandas);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() => {'nombre': nombre, 'bandas': bandas};
|
||||
|
||||
PresetEcualizador copyWithBandas(List<double> bandas) =>
|
||||
PresetEcualizador(nombre: 'Personalizado', bandas: bandas);
|
||||
}
|
||||
@@ -1,42 +1,387 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:file_picker/file_picker.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:share_plus/share_plus.dart' show Share, XFile;
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:uuid/uuid.dart';
|
||||
import '../estado/estado_radio.dart';
|
||||
import '../modelos/emisora.dart';
|
||||
import '../modelos/preset_ecualizador.dart';
|
||||
import '../widgets/ecualizador_widget.dart';
|
||||
|
||||
/// Pantalla de ajustes — por ahora muestra info de la app.
|
||||
/// En Fase 3 se añadirá Export/Import config y gestión PRO.
|
||||
class PantallaAjustes extends StatelessWidget {
|
||||
const PantallaAjustes({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final estado = context.read<EstadoRadio>();
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('Ajustes')),
|
||||
body: ListView(
|
||||
children: const [
|
||||
_SeccionEcualizador(),
|
||||
Divider(),
|
||||
_SeccionEmisoras(),
|
||||
Divider(),
|
||||
_SeccionBackup(),
|
||||
Divider(),
|
||||
_SeccionInfo(),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Sección Ecualizador ───────────────────────────────────────────────────────
|
||||
|
||||
class _SeccionEcualizador extends StatelessWidget {
|
||||
const _SeccionEcualizador();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Consumer<EstadoRadio>(
|
||||
builder: (ctx, estado, _) {
|
||||
final disponible = estado.ecualizadorDisponible;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
const Icon(Icons.equalizer),
|
||||
const SizedBox(width: 12),
|
||||
Text('Ecualizador', style: Theme.of(ctx).textTheme.titleMedium),
|
||||
const Spacer(),
|
||||
if (!disponible)
|
||||
Chip(
|
||||
label: const Text('Reproduce una emisora para activar'),
|
||||
visualDensity: VisualDensity.compact,
|
||||
),
|
||||
],
|
||||
),
|
||||
if (disponible) ...[
|
||||
const SizedBox(height: 8),
|
||||
PresetsEcualizadorWidget(
|
||||
presetActual: estado.presetEcualizador,
|
||||
onSeleccionar: (p) => estado.cambiarPresetEcualizador(p),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
EcualizadorWidget(
|
||||
preset: estado.presetEcualizador,
|
||||
onCambio: (p) {
|
||||
for (int i = 0; i < p.bandas.length; i++) {
|
||||
estado.cambiarBandaEcualizador(i, p.bandas[i]);
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Sección Emisoras personalizadas ──────────────────────────────────────────
|
||||
|
||||
class _SeccionEmisoras extends StatelessWidget {
|
||||
const _SeccionEmisoras();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final estado = context.watch<EstadoRadio>();
|
||||
final custom = estado.emisorasCustom;
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 4),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.add_circle_outline),
|
||||
const SizedBox(width: 12),
|
||||
Text('Emisoras personalizadas',
|
||||
style: Theme.of(context).textTheme.titleMedium),
|
||||
const Spacer(),
|
||||
TextButton.icon(
|
||||
icon: const Icon(Icons.add),
|
||||
label: const Text('Añadir'),
|
||||
onPressed: () => _mostrarFormularioAnadir(context),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (custom.isEmpty)
|
||||
const Padding(
|
||||
padding: EdgeInsets.fromLTRB(16, 4, 16, 12),
|
||||
child: Text('No hay emisoras personalizadas.',
|
||||
style: TextStyle(color: Colors.grey)),
|
||||
)
|
||||
else
|
||||
for (final emisora in custom)
|
||||
ListTile(
|
||||
leading: const Icon(Icons.radio),
|
||||
title: Text(emisora.nombre),
|
||||
subtitle: Text(emisora.url, maxLines: 1, overflow: TextOverflow.ellipsis),
|
||||
trailing: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.play_arrow),
|
||||
tooltip: 'Reproducir',
|
||||
onPressed: () => context.read<EstadoRadio>().reproducir(emisora),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.delete_outline),
|
||||
tooltip: 'Eliminar',
|
||||
onPressed: () => context.read<EstadoRadio>().eliminarEmitoraCustom(emisora.uuid),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _mostrarFormularioAnadir(BuildContext context) async {
|
||||
await showModalBottomSheet(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
builder: (ctx) => const _FormularioEmisora(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _FormularioEmisora extends StatefulWidget {
|
||||
const _FormularioEmisora();
|
||||
|
||||
@override
|
||||
State<_FormularioEmisora> createState() => _FormularioEmisoraState();
|
||||
}
|
||||
|
||||
class _FormularioEmisoraState extends State<_FormularioEmisora> {
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
final _nombreCtrl = TextEditingController();
|
||||
final _urlCtrl = TextEditingController();
|
||||
final _paisCtrl = TextEditingController();
|
||||
bool _guardando = false;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_nombreCtrl.dispose();
|
||||
_urlCtrl.dispose();
|
||||
_paisCtrl.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _guardar() async {
|
||||
if (!_formKey.currentState!.validate()) return;
|
||||
setState(() => _guardando = true);
|
||||
|
||||
final emisora = Emisora(
|
||||
uuid: const Uuid().v4(),
|
||||
nombre: _nombreCtrl.text.trim(),
|
||||
url: _urlCtrl.text.trim(),
|
||||
pais: _paisCtrl.text.trim().isEmpty ? null : _paisCtrl.text.trim(),
|
||||
);
|
||||
|
||||
await context.read<EstadoRadio>().agregarEmitoraCustom(emisora);
|
||||
if (mounted) Navigator.pop(context);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final bottom = MediaQuery.of(context).viewInsets.bottom;
|
||||
return Padding(
|
||||
padding: EdgeInsets.fromLTRB(16, 16, 16, 16 + bottom),
|
||||
child: Form(
|
||||
key: _formKey,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Text('Añadir emisora', style: Theme.of(context).textTheme.titleLarge),
|
||||
const SizedBox(height: 16),
|
||||
TextFormField(
|
||||
controller: _nombreCtrl,
|
||||
decoration: const InputDecoration(labelText: 'Nombre *', border: OutlineInputBorder()),
|
||||
validator: (v) => v == null || v.trim().isEmpty ? 'Campo obligatorio' : null,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextFormField(
|
||||
controller: _urlCtrl,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'URL del stream *',
|
||||
hintText: 'http://stream.ejemplo.com:8000/radio',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
keyboardType: TextInputType.url,
|
||||
validator: (v) {
|
||||
if (v == null || v.trim().isEmpty) return 'Campo obligatorio';
|
||||
final uri = Uri.tryParse(v.trim());
|
||||
if (uri == null || !uri.hasScheme) return 'URL no válida';
|
||||
return null;
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextFormField(
|
||||
controller: _paisCtrl,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'País (opcional)',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
FilledButton(
|
||||
onPressed: _guardando ? null : _guardar,
|
||||
child: _guardando
|
||||
? const SizedBox(height: 20, width: 20, child: CircularProgressIndicator(strokeWidth: 2))
|
||||
: const Text('Guardar emisora'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Sección Backup ────────────────────────────────────────────────────────────
|
||||
|
||||
class _SeccionBackup extends StatelessWidget {
|
||||
const _SeccionBackup();
|
||||
|
||||
Future<void> _exportar(BuildContext context) async {
|
||||
try {
|
||||
final estado = context.read<EstadoRadio>();
|
||||
final config = await estado.exportarConfig();
|
||||
final json = const JsonEncoder.withIndent(' ').convert(config);
|
||||
|
||||
final dir = await getTemporaryDirectory();
|
||||
final file = File('${dir.path}/pluriwave-backup.json');
|
||||
await file.writeAsString(json);
|
||||
|
||||
await Share.shareXFiles(
|
||||
[XFile(file.path)],
|
||||
subject: 'PluriWave — copia de seguridad',
|
||||
text: 'Configuración de PluriWave exportada el ${DateTime.now().toLocal()}',
|
||||
);
|
||||
} catch (e) {
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Error al exportar: $e')),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _importar(BuildContext context) async {
|
||||
try {
|
||||
final result = await FilePicker.platform.pickFiles(
|
||||
type: FileType.custom,
|
||||
allowedExtensions: ['json'],
|
||||
);
|
||||
if (result == null || result.files.single.path == null) return;
|
||||
|
||||
final file = File(result.files.single.path!);
|
||||
final json = jsonDecode(await file.readAsString()) as Map<String, dynamic>;
|
||||
|
||||
if (context.mounted) {
|
||||
final confirmar = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: const Text('Importar configuración'),
|
||||
content: const Text(
|
||||
'Esto añadirá los favoritos, emisoras y presets del fichero. '
|
||||
'¿Continuar?'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx, false),
|
||||
child: const Text('Cancelar')),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.pop(ctx, true),
|
||||
child: const Text('Importar')),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (confirmar != true) return;
|
||||
if (context.mounted) {
|
||||
await context.read<EstadoRadio>().importarConfig(json);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Configuración importada correctamente')),
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Error al importar: $e')),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 4),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.backup_outlined),
|
||||
const SizedBox(width: 12),
|
||||
Text('Copia de seguridad',
|
||||
style: Theme.of(context).textTheme.titleMedium),
|
||||
],
|
||||
),
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.upload_outlined),
|
||||
title: const Text('Exportar configuración'),
|
||||
subtitle: const Text('Favoritos, emisoras custom y presets de EQ'),
|
||||
onTap: () => _exportar(context),
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.download_outlined),
|
||||
title: const Text('Importar configuración'),
|
||||
subtitle: const Text('Restaurar desde un fichero de copia de seguridad'),
|
||||
onTap: () => _importar(context),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Sección Info ──────────────────────────────────────────────────────────────
|
||||
|
||||
class _SeccionInfo extends StatelessWidget {
|
||||
const _SeccionInfo();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Consumer<EstadoRadio>(
|
||||
builder: (ctx, estado, _) => Column(
|
||||
children: [
|
||||
// Info app
|
||||
ListTile(
|
||||
leading: const Icon(Icons.info_outline),
|
||||
title: const Text('PluriWave'),
|
||||
subtitle: const Text('v0.3.0 — Radio mundial'),
|
||||
),
|
||||
const Divider(),
|
||||
// Favoritos
|
||||
FutureBuilder<int>(
|
||||
future: estado.favoritos.obtenerTodos().then((l) => l.length),
|
||||
builder: (ctx, snap) => ListTile(
|
||||
leading: const Icon(Icons.favorite_outline),
|
||||
title: const Text('Favoritos guardados'),
|
||||
trailing: Text(
|
||||
snap.data?.toString() ?? '—',
|
||||
style: theme.textTheme.bodyLarge,
|
||||
),
|
||||
trailing: Text(snap.data?.toString() ?? '—',
|
||||
style: Theme.of(ctx).textTheme.bodyLarge),
|
||||
),
|
||||
),
|
||||
const Divider(),
|
||||
// Filtro emisoras
|
||||
ListTile(
|
||||
leading: const Icon(Icons.verified_outlined),
|
||||
title: const Text('Filtro de emisoras'),
|
||||
@@ -44,39 +389,11 @@ class PantallaAjustes extends StatelessWidget {
|
||||
trailing: const Icon(Icons.check_circle, color: Colors.green),
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.music_off_outlined),
|
||||
leading: const Icon(Icons.music_note_outlined),
|
||||
title: const Text('Audio en background'),
|
||||
subtitle: const Text('Activo — continúa al apagar pantalla'),
|
||||
subtitle: const Text('Continúa al apagar la pantalla'),
|
||||
trailing: const Icon(Icons.check_circle, color: Colors.green),
|
||||
),
|
||||
const Divider(),
|
||||
// Próximamente
|
||||
const Padding(
|
||||
padding: EdgeInsets.fromLTRB(16, 12, 16, 4),
|
||||
child: Text('PRÓXIMAMENTE', style: TextStyle(fontSize: 12, letterSpacing: 1.2)),
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.upload_outlined),
|
||||
title: const Text('Exportar configuración'),
|
||||
subtitle: const Text('Favoritos, radios custom, presets EQ'),
|
||||
enabled: false,
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.download_outlined),
|
||||
title: const Text('Importar configuración'),
|
||||
enabled: false,
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.add_circle_outline),
|
||||
title: const Text('Añadir radio personalizada'),
|
||||
enabled: false,
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.equalizer_outlined),
|
||||
title: const Text('Ecualizador'),
|
||||
subtitle: const Text('5 bandas, presets por emisora'),
|
||||
enabled: false,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'package:audio_service/audio_service.dart';
|
||||
import 'package:just_audio/just_audio.dart';
|
||||
import '../modelos/emisora.dart';
|
||||
import '../modelos/preset_ecualizador.dart';
|
||||
|
||||
/// Estado de reproducción expuesto al UI.
|
||||
enum EstadoReproduccion { detenido, cargando, reproduciendo, pausado, error }
|
||||
@@ -10,20 +11,14 @@ enum EstadoReproduccion { detenido, cargando, reproduciendo, pausado, error }
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
PluriWaveAudioHandler? _handlerGlobal;
|
||||
|
||||
/// Registra el handler. Llamar desde main.dart tras AudioService.init.
|
||||
void registrarHandler(PluriWaveAudioHandler handler) {
|
||||
_handlerGlobal = handler;
|
||||
}
|
||||
|
||||
/// Wrapper de alto nivel para el UI.
|
||||
///
|
||||
/// Delega TODA la reproducción al [PluriWaveAudioHandler] para garantizar
|
||||
/// que el audio siga vivo en background con notificación foreground.
|
||||
class ServicioAudio {
|
||||
PluriWaveAudioHandler get _handler {
|
||||
assert(_handlerGlobal != null,
|
||||
'ServicioAudio: handler no registrado. '
|
||||
'Llama registrarHandler() en main.dart tras AudioService.init.');
|
||||
assert(_handlerGlobal != null, 'registrarHandler() no fue llamado en main.dart');
|
||||
return _handlerGlobal!;
|
||||
}
|
||||
|
||||
@@ -68,55 +63,52 @@ class ServicioAudio {
|
||||
}
|
||||
|
||||
Future<void> detener() => _handler.stop();
|
||||
|
||||
Future<void> setVolumen(double vol) => _handler.setVolume(vol.clamp(0.0, 1.0));
|
||||
|
||||
Future<void> setVolumen(double vol) => _handler.setVolumen(vol);
|
||||
double get volumen => _handler.volumen;
|
||||
bool get estaSonando => _handler.playbackState.value.playing;
|
||||
|
||||
/// No-op: el handler se limpia en main.dart al cerrar la app.
|
||||
Future<void> dispose() async {}
|
||||
|
||||
// ── Ecualizador ──────────────────────────────────────────────────────────
|
||||
AndroidEqualizer? get ecualizador => _handler.ecualizador;
|
||||
bool get ecualizadorDisponible => _handler.ecualizadorDisponible;
|
||||
PresetEcualizador get presetActual => _handler.presetActual;
|
||||
|
||||
Future<void> aplicarPreset(PresetEcualizador preset) =>
|
||||
_handler.aplicarPreset(preset);
|
||||
|
||||
Future<void> setBanda(int index, double db) =>
|
||||
_handler.setBanda(index, db);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// AudioHandler — núcleo del audio en background
|
||||
// AudioHandler
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
/// Handler de audio_service.
|
||||
///
|
||||
/// Gestiona la reproducción con `just_audio` y mantiene la notificación
|
||||
/// foreground activa mientras hay audio reproduciéndose.
|
||||
///
|
||||
/// ### Inicialización en main.dart
|
||||
/// ```dart
|
||||
/// final handler = await AudioService.init(
|
||||
/// builder: () => PluriWaveAudioHandler(),
|
||||
/// config: const AudioServiceConfig(
|
||||
/// androidNotificationChannelId: 'es.freetimelab.pluriwave.audio',
|
||||
/// androidNotificationChannelName: 'PluriWave Radio',
|
||||
/// androidNotificationOngoing: true,
|
||||
/// androidStopForegroundOnPause: true,
|
||||
/// androidNotificationIcon: 'drawable/ic_stat_radio',
|
||||
/// ),
|
||||
/// );
|
||||
/// registrarHandler(handler);
|
||||
/// ```
|
||||
class PluriWaveAudioHandler extends BaseAudioHandler with SeekHandler {
|
||||
final AudioPlayer _player = AudioPlayer();
|
||||
final AndroidEqualizer _eq = AndroidEqualizer();
|
||||
|
||||
late final AudioPlayer _player = AudioPlayer(
|
||||
audioPipeline: AudioPipeline(androidAudioEffects: [_eq]),
|
||||
);
|
||||
|
||||
Emisora? emisoraActual;
|
||||
double _volumen = 1.0;
|
||||
double get volumen => _volumen;
|
||||
|
||||
AndroidEqualizer? get ecualizador => _eq;
|
||||
bool _eqDisponible = false;
|
||||
bool get ecualizadorDisponible => _eqDisponible;
|
||||
|
||||
PresetEcualizador _presetActual = PresetEcualizador.flat;
|
||||
PresetEcualizador get presetActual => _presetActual;
|
||||
|
||||
PluriWaveAudioHandler() {
|
||||
_setupStreams();
|
||||
}
|
||||
|
||||
void _setupStreams() {
|
||||
// Propagar estado del player → playbackState (lo que ve la notificación)
|
||||
_player.playerStateStream.listen((state) {
|
||||
final playing = state.playing;
|
||||
final proc = state.processingState;
|
||||
|
||||
playbackState.add(playbackState.value.copyWith(
|
||||
controls: [
|
||||
if (playing) MediaControl.pause else MediaControl.play,
|
||||
@@ -131,7 +123,6 @@ class PluriWaveAudioHandler extends BaseAudioHandler with SeekHandler {
|
||||
));
|
||||
});
|
||||
|
||||
// Actualizar bufferedPosition
|
||||
_player.bufferedPositionStream.listen((pos) {
|
||||
playbackState.add(playbackState.value.copyWith(bufferedPosition: pos));
|
||||
});
|
||||
@@ -154,6 +145,8 @@ class PluriWaveAudioHandler extends BaseAudioHandler with SeekHandler {
|
||||
await _player.stop();
|
||||
await _player.setUrl(item.id);
|
||||
await _player.play();
|
||||
// Habilitar ecualizador tras reproducir (necesita audio activo)
|
||||
await _activarEcualizador();
|
||||
} on PlayerException catch (e) {
|
||||
playbackState.add(playbackState.value.copyWith(
|
||||
processingState: AudioProcessingState.error,
|
||||
@@ -164,6 +157,52 @@ class PluriWaveAudioHandler extends BaseAudioHandler with SeekHandler {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _activarEcualizador() async {
|
||||
try {
|
||||
final params = await _eq.parameters;
|
||||
_eqDisponible = params.bands.isNotEmpty;
|
||||
if (_eqDisponible) {
|
||||
await _eq.setEnabled(true);
|
||||
await aplicarPreset(_presetActual);
|
||||
}
|
||||
} catch (_) {
|
||||
_eqDisponible = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Aplica un preset al ecualizador nativo Android.
|
||||
Future<void> aplicarPreset(PresetEcualizador preset) async {
|
||||
_presetActual = preset;
|
||||
if (!_eqDisponible) return;
|
||||
try {
|
||||
final params = await _eq.parameters;
|
||||
for (int i = 0; i < params.bands.length && i < preset.bandas.length; i++) {
|
||||
await params.bands[i].setGain(preset.bandas[i]);
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
/// Ajusta una banda individual.
|
||||
Future<void> setBanda(int index, double db) async {
|
||||
if (!_eqDisponible) return;
|
||||
final bandas = List<double>.from(_presetActual.bandas);
|
||||
if (index >= 0 && index < bandas.length) {
|
||||
bandas[index] = db;
|
||||
_presetActual = _presetActual.copyWithBandas(bandas);
|
||||
}
|
||||
try {
|
||||
final params = await _eq.parameters;
|
||||
if (index < params.bands.length) {
|
||||
await params.bands[index].setGain(db);
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
Future<void> setVolumen(double vol) async {
|
||||
_volumen = vol.clamp(0.0, 1.0);
|
||||
await _player.setVolume(_volumen);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> play() => _player.play();
|
||||
|
||||
@@ -181,11 +220,6 @@ class PluriWaveAudioHandler extends BaseAudioHandler with SeekHandler {
|
||||
@override
|
||||
Future<void> seek(Duration position) => _player.seek(position);
|
||||
|
||||
Future<void> setVolume(double vol) async {
|
||||
_volumen = vol.clamp(0.0, 1.0);
|
||||
await _player.setVolume(_volumen);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> onTaskRemoved() async {
|
||||
await stop();
|
||||
|
||||
120
lib/widgets/ecualizador_widget.dart
Normal file
120
lib/widgets/ecualizador_widget.dart
Normal file
@@ -0,0 +1,120 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../modelos/preset_ecualizador.dart';
|
||||
|
||||
/// Widget de ecualizador con 5 sliders verticales.
|
||||
/// Basado en JaviHogar EcualizadorWidget, adaptado a Material You.
|
||||
class EcualizadorWidget extends StatefulWidget {
|
||||
final PresetEcualizador preset;
|
||||
final void Function(PresetEcualizador) onCambio;
|
||||
|
||||
const EcualizadorWidget({super.key, required this.preset, required this.onCambio});
|
||||
|
||||
@override
|
||||
State<EcualizadorWidget> createState() => _EcualizadorWidgetState();
|
||||
}
|
||||
|
||||
class _EcualizadorWidgetState extends State<EcualizadorWidget> {
|
||||
late List<double> _bandas;
|
||||
final List<String> _etiquetas = ['60Hz', '250Hz', '1kHz', '4kHz', '16kHz'];
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_bandas = List.from(widget.preset.bandas);
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(EcualizadorWidget old) {
|
||||
super.didUpdateWidget(old);
|
||||
if (old.preset.nombre != widget.preset.nombre) {
|
||||
setState(() => _bandas = List.from(widget.preset.bandas));
|
||||
}
|
||||
}
|
||||
|
||||
void _actualizarBanda(int index, double valor) {
|
||||
setState(() => _bandas[index] = valor);
|
||||
widget.onCambio(PresetEcualizador(nombre: 'Personalizado', bandas: List.from(_bandas)));
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Text('Ecualizador', style: theme.textTheme.titleMedium),
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||
children: [
|
||||
for (int i = 0; i < 5; i++)
|
||||
Expanded(
|
||||
child: Column(
|
||||
children: [
|
||||
SizedBox(
|
||||
height: 160,
|
||||
child: RotatedBox(
|
||||
quarterTurns: 3,
|
||||
child: Slider(
|
||||
value: _bandas[i],
|
||||
min: -12.0,
|
||||
max: 12.0,
|
||||
divisions: 24,
|
||||
onChanged: (v) => _actualizarBanda(i, v),
|
||||
),
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'${_bandas[i].toStringAsFixed(1)}dB',
|
||||
style: theme.textTheme.labelSmall,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
Text(
|
||||
_etiquetas[i],
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Chips de presets predefinidos.
|
||||
class PresetsEcualizadorWidget extends StatelessWidget {
|
||||
final PresetEcualizador presetActual;
|
||||
final void Function(PresetEcualizador) onSeleccionar;
|
||||
|
||||
const PresetsEcualizadorWidget({
|
||||
super.key,
|
||||
required this.presetActual,
|
||||
required this.onSeleccionar,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 4,
|
||||
children: PresetEcualizador.presets.map((p) {
|
||||
return ChoiceChip(
|
||||
label: Text(p.nombre),
|
||||
selected: p.nombre == presetActual.nombre,
|
||||
onSelected: (_) => onSeleccionar(p),
|
||||
);
|
||||
}).toList(),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,13 @@
|
||||
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.
|
||||
class TarjetaEmisora extends StatelessWidget {
|
||||
/// Incluye botón de favorito visible en ambos modos.
|
||||
class TarjetaEmisora extends StatefulWidget {
|
||||
final Emisora emisora;
|
||||
final VoidCallback? onTap;
|
||||
final bool esCompacta;
|
||||
@@ -16,48 +19,96 @@ class TarjetaEmisora extends StatelessWidget {
|
||||
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: onTap,
|
||||
child: esCompacta ? _buildCompacta(theme) : _buildCompleta(theme),
|
||||
onTap: widget.onTap,
|
||||
child: widget.esCompacta
|
||||
? _buildCompacta(theme)
|
||||
: _buildCompleta(theme),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildCompleta(ThemeData theme) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
return Stack(
|
||||
children: [
|
||||
AspectRatio(
|
||||
aspectRatio: 1,
|
||||
child: _logo(theme, 60),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(8),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
emisora.nombre,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.bold),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
if (emisora.pais != null)
|
||||
Text(
|
||||
emisora.pais!,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
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,
|
||||
),
|
||||
maxLines: 1,
|
||||
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),
|
||||
),
|
||||
],
|
||||
);
|
||||
@@ -67,22 +118,46 @@ class TarjetaEmisora extends StatelessWidget {
|
||||
return ListTile(
|
||||
leading: SizedBox(width: 48, height: 48, child: _logo(theme, 24)),
|
||||
title: Text(
|
||||
emisora.nombre,
|
||||
widget.emisora.nombre,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
subtitle: Text(
|
||||
[emisora.pais, emisora.idioma].where((s) => s != null).join(' · '),
|
||||
[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 (emisora.favicon != null && emisora.favicon!.isNotEmpty) {
|
||||
if (widget.emisora.favicon != null && widget.emisora.favicon!.isNotEmpty) {
|
||||
return CachedNetworkImage(
|
||||
imageUrl: emisora.favicon!,
|
||||
imageUrl: widget.emisora.favicon!,
|
||||
fit: BoxFit.cover,
|
||||
placeholder: (_, __) => _shimmer(theme),
|
||||
errorWidget: (_, __, ___) => _iconoFallback(theme, iconSize),
|
||||
|
||||
Reference in New Issue
Block a user