Fix multi-device EQ auto-switching by calling obtenerDispositivoActual() during cargarPersistido() to seed the initial device ID. Add device management modal with rename support, EQ preset editing, and connection status indicator. Translate device UI keys to all 13 locales.
1674 lines
55 KiB
Dart
1674 lines
55 KiB
Dart
import 'dart:io';
|
|
|
|
import 'package:file_picker/file_picker.dart';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:package_info_plus/package_info_plus.dart';
|
|
import 'package:path_provider/path_provider.dart';
|
|
import 'package:provider/provider.dart';
|
|
import 'package:share_plus/share_plus.dart' show Share, XFile;
|
|
import 'package:uuid/uuid.dart';
|
|
|
|
import '../estado/estado_ecualizador.dart';
|
|
import '../estado/estado_grabacion.dart';
|
|
import '../estado/estado_idioma.dart';
|
|
import '../estado/estado_radio.dart';
|
|
import '../l10n/display_names.dart';
|
|
import '../l10n/gen/app_localizations.dart';
|
|
import '../modelos/emisora.dart';
|
|
import '../modelos/grupo_favoritos.dart';
|
|
import '../modelos/preset_ecualizador.dart';
|
|
import '../widgets/ecualizador_widget.dart';
|
|
import '../widgets/pluri_glass_surface.dart';
|
|
import '../widgets/pluri_icon.dart';
|
|
import '../widgets/pluri_layout.dart';
|
|
import '../widgets/pluri_onboarding_dialog.dart';
|
|
import '../widgets/pluri_premium_widgets.dart';
|
|
|
|
class PantallaAjustes extends StatelessWidget {
|
|
const PantallaAjustes({super.key});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final l10n = AppLocalizations.of(context);
|
|
|
|
return ListView(
|
|
padding: PluriLayout.pageListPadding,
|
|
children: [
|
|
PluriScreenHeader(
|
|
title: l10n.settingsTitle,
|
|
subtitle: l10n.settingsSubtitle,
|
|
glyph: PluriIconGlyph.settings,
|
|
trailing: PluriStatusPill(
|
|
icon: Icons.security_rounded,
|
|
label: l10n.settingsSafeStatus,
|
|
),
|
|
),
|
|
const Padding(
|
|
padding: PluriLayout.pageContentPadding,
|
|
child: _AjustesContent(),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
class _AjustesContent extends StatelessWidget {
|
|
const _AjustesContent();
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Column(
|
|
children: const [
|
|
_SeccionEcualizador(),
|
|
SizedBox(height: 12),
|
|
_SeccionEcualizadorAvanzado(),
|
|
SizedBox(height: 12),
|
|
_SeccionGrabaciones(),
|
|
SizedBox(height: 12),
|
|
_SeccionTimerSueno(),
|
|
SizedBox(height: 12),
|
|
_SeccionIdioma(),
|
|
SizedBox(height: 12),
|
|
_SeccionOrdenListas(),
|
|
SizedBox(height: 12),
|
|
_SeccionGruposFavoritos(),
|
|
SizedBox(height: 12),
|
|
_SeccionEmisoraPreferida(),
|
|
SizedBox(height: 12),
|
|
_SeccionEmisoras(),
|
|
SizedBox(height: 12),
|
|
_SeccionBackup(),
|
|
SizedBox(height: 12),
|
|
_SeccionInfo(),
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
class _SeccionGrabaciones extends StatelessWidget {
|
|
const _SeccionGrabaciones();
|
|
|
|
Future<void> _seleccionarRuta(BuildContext context) async {
|
|
final estado = context.read<EstadoGrabacion>();
|
|
final messenger = ScaffoldMessenger.of(context);
|
|
final l10n = AppLocalizations.of(context);
|
|
final ruta = await FilePicker.platform.getDirectoryPath(
|
|
dialogTitle: l10n.recordingsFolderDialogTitle,
|
|
);
|
|
if (ruta == null) return;
|
|
try {
|
|
await estado.cambiarDirectorio(ruta);
|
|
if (!context.mounted) return;
|
|
messenger.showSnackBar(
|
|
SnackBar(content: Text(l10n.recordingsPathUpdated)),
|
|
);
|
|
} catch (e) {
|
|
if (!context.mounted) return;
|
|
messenger.showSnackBar(
|
|
SnackBar(content: Text(l10n.recordingsPathSaveError(e.toString()))),
|
|
);
|
|
}
|
|
}
|
|
|
|
Future<void> _restaurarRuta(BuildContext context) async {
|
|
final estado = context.read<EstadoGrabacion>();
|
|
final messenger = ScaffoldMessenger.of(context);
|
|
final l10n = AppLocalizations.of(context);
|
|
await estado.restaurarDirectorio();
|
|
if (!context.mounted) return;
|
|
messenger.showSnackBar(
|
|
SnackBar(content: Text(l10n.recordingsDefaultFolderRestored)),
|
|
);
|
|
}
|
|
|
|
Future<void> _abrirCarpeta(BuildContext context) async {
|
|
final estado = context.read<EstadoGrabacion>();
|
|
final messenger = ScaffoldMessenger.of(context);
|
|
final l10n = AppLocalizations.of(context);
|
|
try {
|
|
final abierto = await estado.abrirDirectorio();
|
|
if (!context.mounted) return;
|
|
if (!abierto) {
|
|
messenger.showSnackBar(
|
|
SnackBar(content: Text(l10n.recordingsOpenFolderError(l10n.dash))),
|
|
);
|
|
}
|
|
} catch (e) {
|
|
if (!context.mounted) return;
|
|
messenger.showSnackBar(
|
|
SnackBar(content: Text(l10n.recordingsOpenFolderError(e.toString()))),
|
|
);
|
|
}
|
|
}
|
|
|
|
Future<void> _editarTamanoMaximo(BuildContext context) async {
|
|
final estado = context.read<EstadoGrabacion>();
|
|
final l10n = AppLocalizations.of(context);
|
|
final actualMb = _bytesAMegabytes(estado.maxBytes);
|
|
final controller = TextEditingController(text: actualMb.toString());
|
|
|
|
final nuevoMb = await showModalBottomSheet<int>(
|
|
context: context,
|
|
isScrollControlled: true,
|
|
showDragHandle: true,
|
|
builder: (ctx) {
|
|
final bottom = MediaQuery.viewInsetsOf(ctx).bottom;
|
|
return Padding(
|
|
padding: EdgeInsets.fromLTRB(20, 0, 20, bottom + 24),
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
l10n.recordingsMaxSizeDialogTitle,
|
|
style: Theme.of(ctx).textTheme.titleLarge,
|
|
),
|
|
const SizedBox(height: 16),
|
|
TextField(
|
|
controller: controller,
|
|
autofocus: true,
|
|
keyboardType: TextInputType.number,
|
|
decoration: InputDecoration(
|
|
labelText: l10n.recordingsMaxSizeMbLabel,
|
|
border: const OutlineInputBorder(),
|
|
),
|
|
),
|
|
const SizedBox(height: 16),
|
|
FilledButton.icon(
|
|
onPressed: () {
|
|
final value = int.tryParse(controller.text.trim());
|
|
if (value == null || value <= 0) return;
|
|
Navigator.of(ctx).pop(value);
|
|
},
|
|
icon: const Icon(Icons.save_rounded),
|
|
label: Text(l10n.saveQuickAccessButton),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
},
|
|
);
|
|
controller.dispose();
|
|
if (nuevoMb == null || !context.mounted) return;
|
|
await estado.cambiarMaxBytes(nuevoMb * 1024 * 1024);
|
|
if (!context.mounted) return;
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(content: Text(l10n.recordingsMaxSizeSaved(nuevoMb))),
|
|
);
|
|
}
|
|
|
|
int _bytesAMegabytes(int bytes) =>
|
|
(bytes / (1024 * 1024)).round().clamp(1, 1048576);
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
// Recording state lives in EstadoGrabacion (S4-R2): this section only
|
|
// rebuilds on recording changes, never on playback notifications.
|
|
final estado = context.watch<EstadoGrabacion>();
|
|
final l10n = AppLocalizations.of(context);
|
|
|
|
return PluriGlassSurface(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Row(
|
|
children: [
|
|
const Icon(Icons.radio_button_checked_rounded),
|
|
const SizedBox(width: 12),
|
|
Text(
|
|
l10n.recordingsSectionTitle,
|
|
style: Theme.of(context).textTheme.titleMedium,
|
|
),
|
|
],
|
|
),
|
|
FutureBuilder<String>(
|
|
future: estado.directorioEfectivo(),
|
|
builder:
|
|
(ctx, snap) => ListTile(
|
|
contentPadding: EdgeInsets.zero,
|
|
leading: const Icon(Icons.folder_outlined),
|
|
title: Text(l10n.recordingsFolderTitle),
|
|
subtitle: Text(
|
|
snap.data ?? l10n.recordingsPathCalculating,
|
|
maxLines: 2,
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
),
|
|
),
|
|
Wrap(
|
|
spacing: 8,
|
|
runSpacing: 8,
|
|
children: [
|
|
OutlinedButton.icon(
|
|
icon: const Icon(Icons.folder_open_rounded),
|
|
label: Text(l10n.recordingsChangePath),
|
|
onPressed: () => _seleccionarRuta(context),
|
|
),
|
|
FilledButton.tonalIcon(
|
|
icon: const Icon(Icons.folder_copy_rounded),
|
|
label: Text(l10n.recordingsOpenFolder),
|
|
onPressed: () => _abrirCarpeta(context),
|
|
),
|
|
IconButton.filledTonal(
|
|
tooltip: l10n.recordingsUseDefaultPath,
|
|
icon: const Icon(Icons.restore_rounded),
|
|
onPressed: () => _restaurarRuta(context),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 8),
|
|
ListTile(
|
|
contentPadding: EdgeInsets.zero,
|
|
leading: const Icon(Icons.sd_storage_rounded),
|
|
title: Text(l10n.recordingsMaxSizeTitle),
|
|
subtitle: Text(
|
|
l10n.recordingsMaxSizeSubtitle(_bytesAMegabytes(estado.maxBytes)),
|
|
),
|
|
onTap: () => _editarTamanoMaximo(context),
|
|
),
|
|
const SizedBox(height: 8),
|
|
Text(
|
|
l10n.recordingsOriginalStreamHint,
|
|
style: Theme.of(context).textTheme.bodySmall,
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _SeccionTimerSueno extends StatelessWidget {
|
|
const _SeccionTimerSueno();
|
|
|
|
Future<void> _anadirPreset(BuildContext context) async {
|
|
final l10n = AppLocalizations.of(context);
|
|
|
|
final duracion = await showModalBottomSheet<Duration>(
|
|
context: context,
|
|
isScrollControlled: true,
|
|
showDragHandle: true,
|
|
builder: (_) => const _FormularioDuracionTimer(),
|
|
);
|
|
if (duracion == null || !context.mounted) return;
|
|
await context.read<EstadoRadio>().agregarTimerSuenoPreset(duracion);
|
|
if (!context.mounted) return;
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(
|
|
content: Text(
|
|
'${l10n.saveQuickAccessButton}: ${_formatearDuracionTimer(l10n, duracion)}',
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final l10n = AppLocalizations.of(context);
|
|
|
|
// S4-R5: scoped select — rebuilds only when the presets list changes.
|
|
final presets = context.select<EstadoRadio, List<int>>(
|
|
(e) => e.timerSuenoPresetsSegundos,
|
|
);
|
|
return PluriGlassSurface(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Row(
|
|
children: [
|
|
const Icon(Icons.bedtime_rounded),
|
|
const SizedBox(width: 12),
|
|
Text(
|
|
l10n.timerSectionTitle,
|
|
style: Theme.of(context).textTheme.titleMedium,
|
|
),
|
|
const Spacer(),
|
|
TextButton.icon(
|
|
icon: const Icon(Icons.add_rounded),
|
|
label: Text(l10n.timerSectionAdd),
|
|
onPressed: () => _anadirPreset(context),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 8),
|
|
Text(
|
|
l10n.timerSectionDescription,
|
|
style: Theme.of(context).textTheme.bodySmall,
|
|
),
|
|
const SizedBox(height: 12),
|
|
Wrap(
|
|
spacing: 8,
|
|
runSpacing: 8,
|
|
children: [
|
|
for (final segundos in presets)
|
|
InputChip(
|
|
label: Text(
|
|
_formatearDuracionTimer(l10n, Duration(seconds: segundos)),
|
|
),
|
|
onDeleted:
|
|
presets.length <= 1
|
|
? null
|
|
: () => context
|
|
.read<EstadoRadio>()
|
|
.eliminarTimerSuenoPreset(segundos),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 8),
|
|
Align(
|
|
alignment: Alignment.centerLeft,
|
|
child: TextButton.icon(
|
|
icon: const Icon(Icons.restore_rounded),
|
|
label: Text(l10n.timerSectionRestoreRecommended),
|
|
onPressed:
|
|
() =>
|
|
context.read<EstadoRadio>().restaurarTimerSuenoPresets(),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _SeccionIdioma extends StatelessWidget {
|
|
const _SeccionIdioma();
|
|
|
|
static const _codigoSistema = 'system';
|
|
static const _idiomas = [
|
|
_IdiomaDisponible(Locale('en'), 'English'),
|
|
_IdiomaDisponible(Locale('es'), 'Español'),
|
|
_IdiomaDisponible(Locale('zh'), '中文'),
|
|
_IdiomaDisponible(Locale('hi'), 'हिन्दी'),
|
|
_IdiomaDisponible(Locale('ar'), 'العربية'),
|
|
_IdiomaDisponible(Locale('pt'), 'Português'),
|
|
_IdiomaDisponible(Locale('fr'), 'Français'),
|
|
_IdiomaDisponible(Locale('ru'), 'Русский'),
|
|
_IdiomaDisponible(Locale('de'), 'Deutsch'),
|
|
_IdiomaDisponible(Locale('ja'), '日本語'),
|
|
_IdiomaDisponible(Locale('id'), 'Bahasa Indonesia'),
|
|
_IdiomaDisponible(Locale('bn'), 'বাংলা'),
|
|
_IdiomaDisponible(Locale('it'), 'Italiano'),
|
|
];
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final l10n = AppLocalizations.of(context);
|
|
final estadoIdioma = context.watch<EstadoIdioma>();
|
|
final locale = estadoIdioma.localeSeleccionado;
|
|
final valorActual = locale == null ? _codigoSistema : _codigoLocale(locale);
|
|
|
|
return PluriGlassSurface(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Row(
|
|
children: [
|
|
const Icon(Icons.language_rounded),
|
|
const SizedBox(width: 12),
|
|
Text(
|
|
l10n.languageSectionTitle,
|
|
style: Theme.of(context).textTheme.titleMedium,
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 8),
|
|
Text(
|
|
l10n.languageSectionDescription,
|
|
style: Theme.of(context).textTheme.bodySmall,
|
|
),
|
|
const SizedBox(height: 12),
|
|
DropdownButtonFormField<String>(
|
|
initialValue: valorActual,
|
|
decoration: InputDecoration(
|
|
labelText: l10n.languageSectionTitle,
|
|
border: const OutlineInputBorder(),
|
|
),
|
|
items: [
|
|
DropdownMenuItem(
|
|
value: _codigoSistema,
|
|
child: Text(l10n.languageSystemDefault),
|
|
),
|
|
for (final idioma in _idiomas)
|
|
DropdownMenuItem(
|
|
value: _codigoLocale(idioma.locale),
|
|
child: Text(idioma.nombreNativo),
|
|
),
|
|
],
|
|
onChanged: (codigo) async {
|
|
if (codigo == null) return;
|
|
if (codigo == _codigoSistema) {
|
|
await context.read<EstadoIdioma>().seleccionarSistema();
|
|
if (!context.mounted) return;
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(content: Text(l10n.languageUpdatedSystem)),
|
|
);
|
|
return;
|
|
}
|
|
|
|
final idioma = _idiomas.firstWhere(
|
|
(item) => _codigoLocale(item.locale) == codigo,
|
|
orElse: () => _idiomas.first,
|
|
);
|
|
await context.read<EstadoIdioma>().seleccionarLocale(
|
|
idioma.locale,
|
|
);
|
|
if (!context.mounted) return;
|
|
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(
|
|
content: Text(l10n.languageUpdated(idioma.nombreNativo)),
|
|
),
|
|
);
|
|
},
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
static String _codigoLocale(Locale locale) {
|
|
final countryCode = locale.countryCode;
|
|
if (countryCode == null || countryCode.isEmpty) {
|
|
return locale.languageCode;
|
|
}
|
|
return '${locale.languageCode}_$countryCode';
|
|
}
|
|
}
|
|
|
|
class _IdiomaDisponible {
|
|
const _IdiomaDisponible(this.locale, this.nombreNativo);
|
|
|
|
final Locale locale;
|
|
final String nombreNativo;
|
|
}
|
|
|
|
class _FormularioDuracionTimer extends StatefulWidget {
|
|
const _FormularioDuracionTimer();
|
|
|
|
@override
|
|
State<_FormularioDuracionTimer> createState() =>
|
|
_FormularioDuracionTimerState();
|
|
}
|
|
|
|
class _FormularioDuracionTimerState extends State<_FormularioDuracionTimer> {
|
|
final _horasCtrl = TextEditingController();
|
|
final _minutosCtrl = TextEditingController(text: '15');
|
|
final _segundosCtrl = TextEditingController();
|
|
|
|
@override
|
|
void dispose() {
|
|
_horasCtrl.dispose();
|
|
_minutosCtrl.dispose();
|
|
_segundosCtrl.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
int _leer(TextEditingController ctrl) => int.tryParse(ctrl.text.trim()) ?? 0;
|
|
|
|
void _guardar() {
|
|
final l10n = AppLocalizations.of(context);
|
|
|
|
final duracion = Duration(
|
|
hours: _leer(_horasCtrl),
|
|
minutes: _leer(_minutosCtrl),
|
|
seconds: _leer(_segundosCtrl),
|
|
);
|
|
if (duracion <= Duration.zero) {
|
|
ScaffoldMessenger.of(
|
|
context,
|
|
).showSnackBar(SnackBar(content: Text(l10n.durationGreaterThanZero)));
|
|
return;
|
|
}
|
|
Navigator.pop(context, duracion);
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final l10n = AppLocalizations.of(context);
|
|
|
|
final bottom = MediaQuery.viewInsetsOf(context).bottom;
|
|
return SafeArea(
|
|
child: Padding(
|
|
padding: EdgeInsets.fromLTRB(18, 0, 18, 18 + bottom),
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: [
|
|
Text(
|
|
l10n.newQuickAccessTitle,
|
|
style: Theme.of(context).textTheme.titleLarge,
|
|
),
|
|
const SizedBox(height: 12),
|
|
Row(
|
|
children: [
|
|
Expanded(child: _campo(_horasCtrl, l10n.hoursLabel)),
|
|
const SizedBox(width: 8),
|
|
Expanded(child: _campo(_minutosCtrl, l10n.minutesLabel)),
|
|
const SizedBox(width: 8),
|
|
Expanded(child: _campo(_segundosCtrl, l10n.secondsLabel)),
|
|
],
|
|
),
|
|
const SizedBox(height: 16),
|
|
FilledButton.icon(
|
|
icon: const Icon(Icons.save_rounded),
|
|
label: Text(l10n.saveQuickAccessButton),
|
|
onPressed: _guardar,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _campo(TextEditingController controller, String label) {
|
|
return TextField(
|
|
controller: controller,
|
|
keyboardType: TextInputType.number,
|
|
decoration: InputDecoration(
|
|
labelText: label,
|
|
border: const OutlineInputBorder(),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _SeccionEcualizador extends StatelessWidget {
|
|
const _SeccionEcualizador();
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
// EQ state comes from EstadoEcualizador (S4-R1/S4-R5); EstadoRadio is
|
|
// only consulted for the current station + favorite flag.
|
|
return Consumer2<EstadoRadio, EstadoEcualizador>(
|
|
builder: (ctx, estado, eq, _) {
|
|
final disponible = eq.disponible;
|
|
final l10n = AppLocalizations.of(ctx);
|
|
final emisoraActual = estado.emisoraActual;
|
|
final mostrarModoPorEmisora =
|
|
emisoraActual != null && estado.emisoraActualEsFavorita;
|
|
final usandoEqPropio = eq.emisoraActualTienePresetPropio;
|
|
|
|
return PluriGlassSurface(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: [
|
|
Row(
|
|
children: [
|
|
const Icon(Icons.equalizer_rounded),
|
|
const SizedBox(width: 12),
|
|
Text(
|
|
l10n.equalizerTitle,
|
|
style: Theme.of(ctx).textTheme.titleMedium,
|
|
),
|
|
const Spacer(),
|
|
Chip(
|
|
label: Text(
|
|
eq.activo ? l10n.equalizerActive : l10n.equalizerDisabled,
|
|
),
|
|
visualDensity: VisualDensity.compact,
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 8),
|
|
SwitchListTile.adaptive(
|
|
contentPadding: EdgeInsets.zero,
|
|
title: Text(l10n.equalizerEnable),
|
|
subtitle: Text(
|
|
disponible
|
|
? l10n.equalizerRealtimeSubtitle
|
|
: l10n.equalizerPendingSubtitle,
|
|
),
|
|
value: eq.activo,
|
|
onChanged: eq.cambiarActivo,
|
|
),
|
|
if (mostrarModoPorEmisora) ...[
|
|
const SizedBox(height: 8),
|
|
SwitchListTile.adaptive(
|
|
contentPadding: EdgeInsets.zero,
|
|
title: Text(l10n.equalizerPerStationTitle),
|
|
subtitle: Text(
|
|
usandoEqPropio
|
|
? l10n.equalizerPerStationActive(emisoraActual.nombre)
|
|
: l10n.equalizerPerStationMain(emisoraActual.nombre),
|
|
),
|
|
value: usandoEqPropio,
|
|
onChanged:
|
|
(usarPropio) =>
|
|
eq.cambiarModoEmisoraActual(usarPropio: usarPropio),
|
|
),
|
|
],
|
|
const SizedBox(height: 8),
|
|
PresetsEcualizadorWidget(
|
|
presetActual: eq.presetActual,
|
|
onSeleccionar: (p) => eq.cambiarPreset(p),
|
|
),
|
|
const SizedBox(height: 12),
|
|
EcualizadorWidget(
|
|
preset: eq.presetActual,
|
|
onCambio: (p) => eq.cambiarPreset(p),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
},
|
|
);
|
|
}
|
|
}
|
|
|
|
/// "Advanced Equalization Options" settings section (Phase 7, multi-device EQ).
|
|
///
|
|
/// Always shows the feature toggle so the user can discover it. When the
|
|
/// toggle is OFF, the device list is completely absent (not just invisible),
|
|
/// matching the spec scenario "Settings section is absent when toggle is off".
|
|
class _SeccionEcualizadorAvanzado extends StatelessWidget {
|
|
const _SeccionEcualizadorAvanzado();
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final l10n = AppLocalizations.of(context);
|
|
final eq = context.watch<EstadoEcualizador>();
|
|
final multiDeviceEnabled = eq.eqMultiDeviceEnabled;
|
|
final presetsDispositivo = eq.presetsDispositivo;
|
|
|
|
return PluriGlassSurface(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Row(
|
|
children: [
|
|
const Icon(Icons.devices_rounded),
|
|
const SizedBox(width: 12),
|
|
Expanded(
|
|
child: Text(
|
|
l10n.advancedEqSectionTitle,
|
|
style: Theme.of(context).textTheme.titleMedium,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
// The toggle uses GestureDetector + custom row instead of
|
|
// SwitchListTile to avoid Material ink assertion inside
|
|
// PluriGlassSurface's DecoratedBox. The visual result is identical
|
|
// to SwitchListTile.
|
|
Padding(
|
|
padding: const EdgeInsets.symmetric(vertical: 8),
|
|
child: Row(
|
|
children: [
|
|
Expanded(
|
|
child: GestureDetector(
|
|
behavior: HitTestBehavior.translucent,
|
|
onTap:
|
|
() => eq.cambiarMultiDeviceEnabled(!multiDeviceEnabled),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
l10n.advancedEqEnableToggle,
|
|
style: Theme.of(context).textTheme.bodyLarge,
|
|
),
|
|
const SizedBox(height: 2),
|
|
Text(
|
|
l10n.advancedEqEnableToggleSubtitle,
|
|
style: Theme.of(context).textTheme.bodySmall,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
Switch.adaptive(
|
|
value: multiDeviceEnabled,
|
|
onChanged:
|
|
(habilitado) => eq.cambiarMultiDeviceEnabled(habilitado),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
if (multiDeviceEnabled) ...[
|
|
const SizedBox(height: 8),
|
|
Text(
|
|
l10n.advancedEqKnownDevicesTitle,
|
|
style: Theme.of(context).textTheme.labelLarge,
|
|
),
|
|
const SizedBox(height: 4),
|
|
if (presetsDispositivo.isEmpty)
|
|
Padding(
|
|
padding: const EdgeInsets.symmetric(vertical: 8),
|
|
child: Text(
|
|
l10n.advancedEqKnownDevicesEmpty,
|
|
style: Theme.of(context).textTheme.bodySmall,
|
|
),
|
|
)
|
|
else
|
|
for (final entry in presetsDispositivo.entries)
|
|
_FilaDispositivo(
|
|
deviceId: entry.key,
|
|
preset: entry.value,
|
|
),
|
|
],
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// A single device row in the known-devices list.
|
|
///
|
|
/// Shows a connection indicator (green dot) when [deviceId] matches the
|
|
/// currently active device. Tapping the edit icon opens [_DialogoEdicionDispositivo].
|
|
class _FilaDispositivo extends StatelessWidget {
|
|
const _FilaDispositivo({required this.deviceId, required this.preset});
|
|
|
|
final String deviceId;
|
|
final PresetEcualizador preset;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final l10n = AppLocalizations.of(context);
|
|
final eq = context.watch<EstadoEcualizador>();
|
|
final isActive = eq.dispositivoActualId == deviceId;
|
|
final displayName = eq.nombreVisible(deviceId, '');
|
|
|
|
return Padding(
|
|
padding: const EdgeInsets.symmetric(vertical: 6),
|
|
child: Row(
|
|
children: [
|
|
if (isActive)
|
|
const Icon(Icons.circle, size: 10, color: Colors.green)
|
|
else
|
|
const SizedBox(width: 10),
|
|
const SizedBox(width: 8),
|
|
const Icon(Icons.headphones_rounded, size: 20),
|
|
const SizedBox(width: 12),
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
displayName,
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: Theme.of(context).textTheme.bodyMedium,
|
|
),
|
|
Text(
|
|
l10n.advancedEqDevicePresetLabel(preset.nombre),
|
|
style: Theme.of(context).textTheme.bodySmall,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
IconButton(
|
|
icon: const Icon(Icons.edit_rounded, size: 20),
|
|
tooltip: l10n.eqDeviceEditTitle,
|
|
onPressed: () => _abrirModal(context),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Future<void> _abrirModal(BuildContext context) async {
|
|
await showModalBottomSheet<void>(
|
|
context: context,
|
|
isScrollControlled: true,
|
|
showDragHandle: true,
|
|
builder: (ctx) => _DialogoEdicionDispositivo(
|
|
deviceId: deviceId,
|
|
preset: preset,
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Bottom sheet for editing a device's custom name and EQ preset.
|
|
class _DialogoEdicionDispositivo extends StatefulWidget {
|
|
const _DialogoEdicionDispositivo({
|
|
required this.deviceId,
|
|
required this.preset,
|
|
});
|
|
|
|
final String deviceId;
|
|
final PresetEcualizador preset;
|
|
|
|
@override
|
|
State<_DialogoEdicionDispositivo> createState() =>
|
|
_DialogoEdicionDispositivoState();
|
|
}
|
|
|
|
class _DialogoEdicionDispositivoState
|
|
extends State<_DialogoEdicionDispositivo> {
|
|
late final TextEditingController _nombreCtrl;
|
|
late PresetEcualizador _presetActual;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
final eq = context.read<EstadoEcualizador>();
|
|
final displayName = eq.nombreVisible(widget.deviceId, '');
|
|
_nombreCtrl = TextEditingController(text: displayName);
|
|
_presetActual = widget.preset;
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_nombreCtrl.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
Future<void> _guardar() async {
|
|
final eq = context.read<EstadoEcualizador>();
|
|
await eq.renombrarDispositivo(widget.deviceId, _nombreCtrl.text);
|
|
if (_presetActual != widget.preset) {
|
|
await eq.guardarPresetDispositivo(widget.deviceId, _presetActual);
|
|
}
|
|
if (mounted) Navigator.of(context).pop();
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final l10n = AppLocalizations.of(context);
|
|
final bottom = MediaQuery.viewInsetsOf(context).bottom;
|
|
|
|
return SingleChildScrollView(
|
|
child: Padding(
|
|
padding: EdgeInsets.fromLTRB(20, 0, 20, bottom + 24),
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
l10n.eqDeviceEditTitle,
|
|
style: Theme.of(context).textTheme.titleLarge,
|
|
),
|
|
const SizedBox(height: 16),
|
|
TextField(
|
|
controller: _nombreCtrl,
|
|
autofocus: true,
|
|
decoration: InputDecoration(
|
|
labelText: l10n.eqDeviceNameLabel,
|
|
hintText: l10n.eqDeviceNameHint,
|
|
border: const OutlineInputBorder(),
|
|
),
|
|
),
|
|
const SizedBox(height: 16),
|
|
EcualizadorWidget(
|
|
preset: _presetActual,
|
|
onCambio: (p) => setState(() => _presetActual = p),
|
|
),
|
|
const SizedBox(height: 16),
|
|
FilledButton.icon(
|
|
onPressed: _guardar,
|
|
icon: const Icon(Icons.save_rounded),
|
|
label: Text(l10n.eqDeviceNameConfirm),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _SeccionOrdenListas extends StatelessWidget {
|
|
const _SeccionOrdenListas();
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
// S4-R5: scoped select — rebuilds only when the ordering changes.
|
|
final orden = context.select<EstadoRadio, OrdenEmisoras>(
|
|
(e) => e.ordenListas,
|
|
);
|
|
final l10n = AppLocalizations.of(context);
|
|
return PluriGlassSurface(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Row(
|
|
children: [
|
|
const Icon(Icons.sort_rounded),
|
|
const SizedBox(width: 12),
|
|
Text(
|
|
l10n.stationOrderTitle,
|
|
style: Theme.of(context).textTheme.titleMedium,
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 8),
|
|
SegmentedButton<OrdenEmisoras>(
|
|
segments: [
|
|
ButtonSegment(
|
|
value: OrdenEmisoras.nombre,
|
|
icon: const Icon(Icons.sort_by_alpha_rounded),
|
|
label: Text(l10n.stationOrderByName),
|
|
),
|
|
ButtonSegment(
|
|
value: OrdenEmisoras.calidad,
|
|
icon: const Icon(Icons.hd_rounded),
|
|
label: Text(l10n.stationOrderByQuality),
|
|
),
|
|
],
|
|
selected: {orden},
|
|
onSelectionChanged: (value) {
|
|
context.read<EstadoRadio>().cambiarOrdenListas(value.first);
|
|
},
|
|
),
|
|
const SizedBox(height: 8),
|
|
Text(
|
|
l10n.stationOrderScopeDescription,
|
|
style: Theme.of(context).textTheme.bodySmall,
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _SeccionGruposFavoritos extends StatelessWidget {
|
|
const _SeccionGruposFavoritos();
|
|
|
|
Future<void> _editarGrupo(
|
|
BuildContext context, [
|
|
GrupoFavoritos? grupo,
|
|
]) async {
|
|
final l10n = AppLocalizations.of(context);
|
|
final controller = TextEditingController(text: grupo?.nombre ?? '');
|
|
final nombre = await showModalBottomSheet<String>(
|
|
context: context,
|
|
isScrollControlled: true,
|
|
showDragHandle: true,
|
|
builder: (ctx) {
|
|
final bottom = MediaQuery.viewInsetsOf(ctx).bottom;
|
|
return Padding(
|
|
padding: EdgeInsets.fromLTRB(20, 0, 20, bottom + 24),
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
grupo == null
|
|
? l10n.favoriteGroupsAdd
|
|
: l10n.favoriteGroupsEdit,
|
|
style: Theme.of(ctx).textTheme.titleLarge,
|
|
),
|
|
const SizedBox(height: 16),
|
|
TextField(
|
|
controller: controller,
|
|
autofocus: true,
|
|
maxLength: 28,
|
|
decoration: InputDecoration(
|
|
labelText: l10n.favoriteGroupsNameLabel,
|
|
helperText: l10n.favoriteGroupsNameTooLong,
|
|
border: const OutlineInputBorder(),
|
|
),
|
|
),
|
|
const SizedBox(height: 12),
|
|
FilledButton.icon(
|
|
icon: const Icon(Icons.save_rounded),
|
|
label: Text(AppLocalizations.of(ctx).saveQuickAccessButton),
|
|
onPressed: () {
|
|
final value = controller.text.trim();
|
|
if (value.isEmpty || value.length > 28) return;
|
|
Navigator.pop(ctx, value);
|
|
},
|
|
),
|
|
],
|
|
),
|
|
);
|
|
},
|
|
);
|
|
controller.dispose();
|
|
if (nombre == null || !context.mounted) return;
|
|
final estado = context.read<EstadoRadio>();
|
|
if (grupo == null) {
|
|
await estado.crearGrupoFavoritos(nombre);
|
|
} else {
|
|
await estado.renombrarGrupoFavoritos(grupo.id, nombre);
|
|
}
|
|
if (!context.mounted) return;
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(
|
|
content: Text(
|
|
grupo == null
|
|
? l10n.favoriteGroupsCreated
|
|
: l10n.favoriteGroupsUpdated,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Future<void> _eliminarGrupo(
|
|
BuildContext context,
|
|
GrupoFavoritos grupo,
|
|
) async {
|
|
final l10n = AppLocalizations.of(context);
|
|
await context.read<EstadoRadio>().eliminarGrupoFavoritos(grupo.id);
|
|
if (!context.mounted) return;
|
|
ScaffoldMessenger.of(
|
|
context,
|
|
).showSnackBar(SnackBar(content: Text(l10n.favoriteGroupsDeleted)));
|
|
}
|
|
|
|
String _nombreVisible(AppLocalizations l10n, GrupoFavoritos grupo) =>
|
|
grupo.esSinAsignar ? l10n.favoriteGroupsUnassigned : grupo.nombre;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final l10n = AppLocalizations.of(context);
|
|
// S4-R5: scoped select — rebuilds only when the groups list changes.
|
|
final grupos = context.select<EstadoRadio, List<GrupoFavoritos>>(
|
|
(e) => e.gruposFavoritos,
|
|
);
|
|
return PluriGlassSurface(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Row(
|
|
children: [
|
|
const Icon(Icons.playlist_add_check_circle_rounded),
|
|
const SizedBox(width: 12),
|
|
Expanded(
|
|
child: Text(
|
|
l10n.favoriteGroupsTitle,
|
|
style: Theme.of(context).textTheme.titleMedium,
|
|
),
|
|
),
|
|
TextButton.icon(
|
|
icon: const Icon(Icons.add_rounded),
|
|
label: Text(l10n.favoriteGroupsAdd),
|
|
onPressed: () => _editarGrupo(context),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 4),
|
|
Text(l10n.favoriteGroupsDescription),
|
|
const SizedBox(height: 8),
|
|
for (final grupo in grupos)
|
|
ListTile(
|
|
contentPadding: EdgeInsets.zero,
|
|
leading: Icon(
|
|
grupo.esSinAsignar ? Icons.lock_rounded : Icons.folder_rounded,
|
|
),
|
|
title: Text(_nombreVisible(l10n, grupo)),
|
|
subtitle:
|
|
grupo.esSinAsignar
|
|
? Text(l10n.favoriteGroupsProtectedHint)
|
|
: null,
|
|
trailing:
|
|
grupo.esSinAsignar
|
|
? null
|
|
: Wrap(
|
|
spacing: 4,
|
|
children: [
|
|
IconButton(
|
|
tooltip: l10n.favoriteGroupsEdit,
|
|
icon: const Icon(Icons.edit_rounded),
|
|
onPressed: () => _editarGrupo(context, grupo),
|
|
),
|
|
IconButton(
|
|
tooltip: l10n.favoriteGroupsDelete,
|
|
icon: const Icon(Icons.delete_outline_rounded),
|
|
onPressed: () => _eliminarGrupo(context, grupo),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _SeccionEmisoraPreferida extends StatelessWidget {
|
|
const _SeccionEmisoraPreferida();
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final l10n = AppLocalizations.of(context);
|
|
// S4-R5: scoped selects over identity-memoized getters.
|
|
final favoritas = context.select<EstadoRadio, List<Emisora>>(
|
|
(e) => e.listaFavoritos,
|
|
);
|
|
final disponibles = context.select<EstadoRadio, List<Emisora>>(
|
|
(e) => e.emisorasDisponiblesPreferencia,
|
|
);
|
|
final preferida = context.select<EstadoRadio, Emisora?>(
|
|
(e) => e.emisoraPreferida,
|
|
);
|
|
final opciones = _opciones(favoritas, disponibles, preferida);
|
|
|
|
return PluriGlassSurface(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Row(
|
|
children: [
|
|
const Icon(Icons.radio_rounded),
|
|
const SizedBox(width: 12),
|
|
Text(
|
|
l10n.preferredStationTitle,
|
|
style: Theme.of(context).textTheme.titleMedium,
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 8),
|
|
Text(
|
|
l10n.preferredStationDescription,
|
|
style: Theme.of(context).textTheme.bodySmall,
|
|
),
|
|
const SizedBox(height: 12),
|
|
if (opciones.isEmpty)
|
|
ListTile(
|
|
contentPadding: EdgeInsets.zero,
|
|
leading: const Icon(Icons.info_outline_rounded),
|
|
title: Text(l10n.preferredStationNoStationsTitle),
|
|
subtitle: Text(l10n.preferredStationNoStationsSubtitle),
|
|
)
|
|
else
|
|
DropdownButtonFormField<String>(
|
|
initialValue: preferida?.uuid,
|
|
decoration: InputDecoration(
|
|
labelText:
|
|
favoritas.isEmpty
|
|
? l10n.preferredStationAutomaticFallback
|
|
: l10n.preferredStationDefaultFavorite,
|
|
),
|
|
items: [
|
|
for (final emisora in opciones)
|
|
DropdownMenuItem<String>(
|
|
value: emisora.uuid,
|
|
child: Text(
|
|
localizedStationName(l10n, emisora.nombre),
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
),
|
|
],
|
|
onChanged: (uuid) async {
|
|
final seleccion = opciones.firstWhere((e) => e.uuid == uuid);
|
|
await context.read<EstadoRadio>().cambiarEmisoraPreferida(
|
|
seleccion,
|
|
);
|
|
},
|
|
),
|
|
if (preferida != null) ...[
|
|
const SizedBox(height: 8),
|
|
Text(
|
|
favoritas.any((e) => e.uuid == preferida.uuid)
|
|
? l10n.preferredStationCurrent(
|
|
localizedStationName(l10n, preferida.nombre),
|
|
)
|
|
: l10n.preferredStationAutoUsing(
|
|
localizedStationName(l10n, preferida.nombre),
|
|
),
|
|
),
|
|
const SizedBox(height: 8),
|
|
Align(
|
|
alignment: Alignment.centerLeft,
|
|
child: FilledButton.tonalIcon(
|
|
icon: const Icon(Icons.play_arrow_rounded),
|
|
label: Text(l10n.preferredStationPlay),
|
|
onPressed:
|
|
() =>
|
|
context
|
|
.read<EstadoRadio>()
|
|
.reproducirEmisoraPreferida(),
|
|
),
|
|
),
|
|
],
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
List<Emisora> _opciones(
|
|
List<Emisora> favoritas,
|
|
List<Emisora> disponibles,
|
|
Emisora? preferida,
|
|
) {
|
|
final base = favoritas.isNotEmpty ? favoritas : disponibles;
|
|
final mapa = <String, Emisora>{
|
|
for (final emisora in base) emisora.uuid: emisora,
|
|
};
|
|
if (preferida != null) {
|
|
mapa[preferida.uuid] = preferida;
|
|
}
|
|
return mapa.values.toList();
|
|
}
|
|
}
|
|
|
|
class _SeccionEmisoras extends StatelessWidget {
|
|
const _SeccionEmisoras();
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
// S4-R5: scoped select — rebuilds only when the custom list changes.
|
|
final custom = context.select<EstadoRadio, List<Emisora>>(
|
|
(e) => e.emisorasCustom,
|
|
);
|
|
|
|
return PluriGlassSurface(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Row(
|
|
children: [
|
|
const Icon(Icons.add_circle_outline_rounded),
|
|
const SizedBox(width: 12),
|
|
Text(
|
|
AppLocalizations.of(context).customStationsTitle,
|
|
style: Theme.of(context).textTheme.titleMedium,
|
|
),
|
|
const Spacer(),
|
|
TextButton.icon(
|
|
icon: const Icon(Icons.add_rounded),
|
|
label: Text(AppLocalizations.of(context).customStationsAdd),
|
|
onPressed: () => _mostrarFormularioAnadir(context),
|
|
),
|
|
],
|
|
),
|
|
if (custom.isEmpty)
|
|
Padding(
|
|
padding: const EdgeInsets.only(top: 8),
|
|
child: Text(
|
|
AppLocalizations.of(context).customStationsEmpty,
|
|
style: TextStyle(
|
|
color: Theme.of(
|
|
context,
|
|
).colorScheme.onSurface.withValues(alpha: 0.6),
|
|
),
|
|
),
|
|
)
|
|
else
|
|
for (final emisora in custom)
|
|
ListTile(
|
|
contentPadding: EdgeInsets.zero,
|
|
leading: const Icon(Icons.radio_rounded),
|
|
title: Text(
|
|
localizedStationName(
|
|
AppLocalizations.of(context),
|
|
emisora.nombre,
|
|
),
|
|
),
|
|
subtitle: Text(
|
|
emisora.url,
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
trailing: Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
IconButton(
|
|
icon: const Icon(Icons.play_arrow_rounded),
|
|
tooltip: AppLocalizations.of(context).playAction,
|
|
onPressed:
|
|
() => context.read<EstadoRadio>().reproducir(emisora),
|
|
),
|
|
IconButton(
|
|
icon: const Icon(Icons.delete_outline_rounded),
|
|
tooltip: AppLocalizations.of(context).deleteAction,
|
|
onPressed:
|
|
() => context
|
|
.read<EstadoRadio>()
|
|
.eliminarEmitoraCustom(emisora.uuid),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Future<void> _mostrarFormularioAnadir(BuildContext context) async {
|
|
await showModalBottomSheet(
|
|
context: context,
|
|
isScrollControlled: true,
|
|
useSafeArea: true,
|
|
showDragHandle: 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 l10n = AppLocalizations.of(context);
|
|
final bottom = MediaQuery.of(context).viewInsets.bottom;
|
|
return Padding(
|
|
padding: EdgeInsets.fromLTRB(
|
|
PluriLayout.horizontal,
|
|
PluriLayout.horizontal,
|
|
PluriLayout.horizontal,
|
|
PluriLayout.horizontal + bottom,
|
|
),
|
|
child: Form(
|
|
key: _formKey,
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: [
|
|
Text(
|
|
l10n.addStationTitle,
|
|
style: Theme.of(context).textTheme.titleLarge,
|
|
),
|
|
const SizedBox(height: 16),
|
|
TextFormField(
|
|
controller: _nombreCtrl,
|
|
decoration: InputDecoration(
|
|
labelText: AppLocalizations.of(context).stationNameLabel,
|
|
border: const OutlineInputBorder(),
|
|
),
|
|
validator:
|
|
(v) =>
|
|
v == null || v.trim().isEmpty
|
|
? AppLocalizations.of(context).requiredField
|
|
: null,
|
|
),
|
|
const SizedBox(height: 12),
|
|
TextFormField(
|
|
controller: _urlCtrl,
|
|
decoration: InputDecoration(
|
|
labelText: AppLocalizations.of(context).streamUrlLabel,
|
|
hintText: AppLocalizations.of(context).streamUrlHint,
|
|
border: const OutlineInputBorder(),
|
|
),
|
|
keyboardType: TextInputType.url,
|
|
validator: (v) {
|
|
if (v == null || v.trim().isEmpty) {
|
|
return l10n.requiredField;
|
|
}
|
|
final uri = Uri.tryParse(v.trim());
|
|
if (uri == null || !uri.hasScheme) return l10n.invalidUrl;
|
|
return null;
|
|
},
|
|
),
|
|
const SizedBox(height: 12),
|
|
TextFormField(
|
|
controller: _paisCtrl,
|
|
decoration: InputDecoration(
|
|
labelText: l10n.countryOptionalLabel,
|
|
border: const OutlineInputBorder(),
|
|
),
|
|
),
|
|
const SizedBox(height: 20),
|
|
FilledButton(
|
|
onPressed: _guardando ? null : _guardar,
|
|
child:
|
|
_guardando
|
|
? const SizedBox(
|
|
height: 20,
|
|
width: 20,
|
|
child: CircularProgressIndicator(strokeWidth: 2),
|
|
)
|
|
: Text(AppLocalizations.of(context).saveStation),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _SeccionBackup extends StatelessWidget {
|
|
const _SeccionBackup();
|
|
|
|
Future<void> _exportar(BuildContext context) async {
|
|
final l10n = AppLocalizations.of(context);
|
|
try {
|
|
final estado = context.read<EstadoRadio>();
|
|
// JSON serialization is owned by ServicioExportImport (S4-R4).
|
|
final json = await estado.exportarConfigJson();
|
|
|
|
final dir = await getTemporaryDirectory();
|
|
final file = File('${dir.path}/pluriwave-backup.json');
|
|
await file.writeAsString(json);
|
|
|
|
await Share.shareXFiles(
|
|
[XFile(file.path)],
|
|
subject: l10n.backupShareSubject,
|
|
text: l10n.backupShareText(DateTime.now().toLocal()),
|
|
);
|
|
} catch (e) {
|
|
if (context.mounted) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(content: Text(l10n.backupExportError(e.toString()))),
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
Future<void> _importar(BuildContext context) async {
|
|
final l10n = AppLocalizations.of(context);
|
|
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 contenido = await file.readAsString();
|
|
if (!context.mounted) return;
|
|
// Parsing is owned by ServicioExportImport (S4-R4): null = malformed.
|
|
final json = context.read<EstadoRadio>().parsearConfigJson(contenido);
|
|
if (json == null) {
|
|
throw const FormatException('invalid backup file');
|
|
}
|
|
|
|
if (context.mounted) {
|
|
final confirmar = await showDialog<bool>(
|
|
context: context,
|
|
builder:
|
|
(ctx) => AlertDialog(
|
|
title: Text(AppLocalizations.of(ctx).backupImportTitle),
|
|
content: Text(
|
|
AppLocalizations.of(ctx).backupImportConfirmMessage,
|
|
),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () => Navigator.pop(ctx, false),
|
|
child: Text(AppLocalizations.of(ctx).cancelAction),
|
|
),
|
|
FilledButton(
|
|
onPressed: () => Navigator.pop(ctx, true),
|
|
child: Text(AppLocalizations.of(ctx).backupImportTitle),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
if (confirmar != true) return;
|
|
if (context.mounted) {
|
|
final estado = context.read<EstadoRadio>();
|
|
final messenger = ScaffoldMessenger.of(context);
|
|
await estado.importarConfig(json);
|
|
messenger.showSnackBar(
|
|
SnackBar(content: Text(l10n.backupImportSuccess)),
|
|
);
|
|
}
|
|
}
|
|
} catch (e) {
|
|
if (context.mounted) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(content: Text(l10n.backupImportError(e.toString()))),
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return PluriGlassSurface(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Row(
|
|
children: [
|
|
const Icon(Icons.backup_outlined),
|
|
const SizedBox(width: 12),
|
|
Text(
|
|
AppLocalizations.of(context).backupSectionTitle,
|
|
style: Theme.of(context).textTheme.titleMedium,
|
|
),
|
|
],
|
|
),
|
|
ListTile(
|
|
contentPadding: EdgeInsets.zero,
|
|
leading: const Icon(Icons.upload_outlined),
|
|
title: Text(AppLocalizations.of(context).backupExportTitle),
|
|
subtitle: Text(AppLocalizations.of(context).backupExportSubtitle),
|
|
onTap: () => _exportar(context),
|
|
),
|
|
ListTile(
|
|
contentPadding: EdgeInsets.zero,
|
|
leading: const Icon(Icons.download_outlined),
|
|
title: Text(AppLocalizations.of(context).backupImportTitle),
|
|
subtitle: Text(AppLocalizations.of(context).backupImportSubtitle),
|
|
onTap: () => _importar(context),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _SeccionInfo extends StatelessWidget {
|
|
const _SeccionInfo();
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Consumer<EstadoRadio>(
|
|
builder:
|
|
(ctx, estado, _) => PluriGlassSurface(
|
|
child: Column(
|
|
children: [
|
|
FutureBuilder<PackageInfo>(
|
|
future: PackageInfo.fromPlatform(),
|
|
builder: (ctx, snap) {
|
|
final version =
|
|
snap.hasData
|
|
? 'v${snap.data!.version}+${snap.data!.buildNumber}'
|
|
: AppLocalizations.of(ctx).appVersionLoading;
|
|
return ListTile(
|
|
contentPadding: EdgeInsets.zero,
|
|
leading: const PluriIcon(
|
|
glyph: PluriIconGlyph.settings,
|
|
variant: PluriIconVariant.filled,
|
|
),
|
|
title: Text(AppLocalizations.of(ctx).appTitle),
|
|
subtitle: Text(
|
|
AppLocalizations.of(ctx).appVersionSubtitle(version),
|
|
),
|
|
);
|
|
},
|
|
),
|
|
FutureBuilder<int>(
|
|
future: estado.favoritos.obtenerTodos().then((l) => l.length),
|
|
builder:
|
|
(ctx, snap) => ListTile(
|
|
contentPadding: EdgeInsets.zero,
|
|
leading: const Icon(Icons.favorite_outline_rounded),
|
|
title: Text(
|
|
AppLocalizations.of(ctx).savedFavoritesTitle,
|
|
),
|
|
trailing: Text(
|
|
snap.data?.toString() ??
|
|
AppLocalizations.of(ctx).dash,
|
|
style: Theme.of(ctx).textTheme.bodyLarge,
|
|
),
|
|
),
|
|
),
|
|
ListTile(
|
|
contentPadding: EdgeInsets.zero,
|
|
leading: const Icon(Icons.help_outline_rounded),
|
|
title: Text(AppLocalizations.of(ctx).helpTitle),
|
|
subtitle: Text(AppLocalizations.of(ctx).helpSubtitle),
|
|
trailing: const Icon(Icons.chevron_right_rounded),
|
|
onTap: () => PluriOnboardingDialog.mostrar(ctx),
|
|
),
|
|
ListTile(
|
|
contentPadding: EdgeInsets.zero,
|
|
leading: const Icon(Icons.verified_outlined),
|
|
title: Text(AppLocalizations.of(ctx).stationFilterTitle),
|
|
subtitle: Text(
|
|
AppLocalizations.of(ctx).stationFilterSubtitle,
|
|
),
|
|
trailing: Icon(
|
|
Icons.check_circle_rounded,
|
|
color: Theme.of(ctx).colorScheme.secondary,
|
|
),
|
|
),
|
|
ListTile(
|
|
contentPadding: EdgeInsets.zero,
|
|
leading: const Icon(Icons.music_note_outlined),
|
|
title: Text(AppLocalizations.of(ctx).backgroundAudioTitle),
|
|
subtitle: Text(
|
|
AppLocalizations.of(ctx).backgroundAudioSubtitle,
|
|
),
|
|
trailing: Icon(
|
|
Icons.check_circle_rounded,
|
|
color: Theme.of(ctx).colorScheme.secondary,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
String _formatearDuracionTimer(AppLocalizations l10n, Duration duracion) {
|
|
final horas = duracion.inHours;
|
|
final minutos = duracion.inMinutes.remainder(60);
|
|
final segundos = duracion.inSeconds.remainder(60);
|
|
if (horas > 0) {
|
|
return l10n.durationHoursMinutesSeconds(
|
|
horas,
|
|
minutos.toString().padLeft(2, '0'),
|
|
segundos.toString().padLeft(2, '0'),
|
|
);
|
|
}
|
|
if (minutos > 0) {
|
|
return segundos == 0
|
|
? l10n.durationMinutesOnly(minutos)
|
|
: l10n.durationMinutesSeconds(
|
|
minutos,
|
|
segundos.toString().padLeft(2, '0'),
|
|
);
|
|
}
|
|
return l10n.durationSecondsOnly(segundos);
|
|
}
|