Files
pluriwave/lib/pantallas/pantalla_ajustes.dart
T
FreeTLab 896349ad5f
Build & Deploy Pluriwave / Análisis de código (push) Successful in 21s
Build & Deploy Pluriwave / Build APK + AAB release (push) Failing after 1m6s
feat(app): add onboarding and harden alarms
2026-05-23 01:22:49 +02:00

1394 lines
46 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import 'dart:convert';
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_idioma.dart';
import '../estado/estado_radio.dart';
import '../l10n/gen/app_localizations.dart';
import '../modelos/emisora.dart';
import '../modelos/grupo_favoritos.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),
_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<EstadoRadio>();
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.cambiarDirectorioGrabacion(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<EstadoRadio>();
final messenger = ScaffoldMessenger.of(context);
final l10n = AppLocalizations.of(context);
await estado.restaurarDirectorioGrabacion();
if (!context.mounted) return;
messenger.showSnackBar(
SnackBar(content: Text(l10n.recordingsDefaultFolderRestored)),
);
}
Future<void> _abrirCarpeta(BuildContext context) async {
final estado = context.read<EstadoRadio>();
final messenger = ScaffoldMessenger.of(context);
final l10n = AppLocalizations.of(context);
try {
final abierto = await estado.abrirDirectorioGrabacion();
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<EstadoRadio>();
final l10n = AppLocalizations.of(context);
final actualMb = _bytesAMegabytes(estado.maxBytesGrabacion);
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.cambiarMaxBytesGrabacion(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) {
final estado = context.watch<EstadoRadio>();
final l10n = AppLocalizations.of(context);
return PluriGlassSurface(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const Icon(Icons.radio_button_checked),
const SizedBox(width: 12),
Text(
l10n.recordingsSectionTitle,
style: Theme.of(context).textTheme.titleMedium,
),
],
),
FutureBuilder<String>(
future: estado.directorioGrabacionEfectivo(),
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.maxBytesGrabacion),
),
),
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(duracion)}',
),
),
);
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final estado = context.watch<EstadoRadio>();
final presets = estado.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(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) {
return Consumer<EstadoRadio>(
builder: (ctx, estado, _) {
final disponible = estado.ecualizadorDisponible;
final l10n = AppLocalizations.of(ctx);
final emisoraActual = estado.emisoraActual;
final mostrarModoPorEmisora =
emisoraActual != null && estado.emisoraActualEsFavorita;
final usandoEqPropio = estado.emisoraActualTienePresetPropio;
return PluriGlassSurface(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
children: [
const Icon(Icons.equalizer),
const SizedBox(width: 12),
Text(
l10n.equalizerTitle,
style: Theme.of(ctx).textTheme.titleMedium,
),
const Spacer(),
Chip(
label: Text(
estado.ecualizadorActivo
? 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: estado.ecualizadorActivo,
onChanged: estado.cambiarEcualizadorActivo,
),
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) =>
estado.cambiarModoEcualizadorEmisoraActual(
usarPropio: usarPropio,
),
),
],
const SizedBox(height: 8),
PresetsEcualizadorWidget(
presetActual: estado.presetEcualizador,
onSeleccionar: (p) => estado.cambiarPresetEcualizador(p),
),
const SizedBox(height: 12),
EcualizadorWidget(
preset: estado.presetEcualizador,
onCambio: (p) => estado.cambiarPresetEcualizador(p),
),
],
),
);
},
);
}
}
class _SeccionOrdenListas extends StatelessWidget {
const _SeccionOrdenListas();
@override
Widget build(BuildContext context) {
final estado = context.watch<EstadoRadio>();
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: {estado.ordenListas},
onSelectionChanged: (value) {
estado.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 estado = context.watch<EstadoRadio>();
final l10n = AppLocalizations.of(context);
final grupos = estado.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 estado = context.watch<EstadoRadio>();
final l10n = AppLocalizations.of(context);
final favoritas = estado.listaFavoritos;
final preferida = estado.emisoraPreferida;
final opciones = _opciones(estado, 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(
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(preferida.nombre)
: l10n.preferredStationAutoUsing(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(EstadoRadio estado, Emisora? preferida) {
final base =
estado.listaFavoritos.isNotEmpty
? estado.listaFavoritos
: estado.emisorasDisponiblesPreferencia;
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) {
final estado = context.watch<EstadoRadio>();
final custom = estado.emisorasCustom;
return PluriGlassSurface(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const Icon(Icons.add_circle_outline),
const SizedBox(width: 12),
Text(
AppLocalizations.of(context).customStationsTitle,
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)
Padding(
padding: const EdgeInsets.only(top: 8),
child: Text(
AppLocalizations.of(context).customStationsEmpty,
style: const TextStyle(color: Colors.grey),
),
)
else
for (final emisora in custom)
ListTile(
contentPadding: EdgeInsets.zero,
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: AppLocalizations.of(context).playAction,
onPressed:
() => context.read<EstadoRadio>().reproducir(emisora),
),
IconButton(
icon: const Icon(Icons.delete_outline),
tooltip: AppLocalizations.of(context).deleteAction,
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(
PluriLayout.horizontal,
PluriLayout.horizontal,
PluriLayout.horizontal,
PluriLayout.horizontal + 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: 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: 'http://stream.ejemplo.com:8000/radio',
border: const OutlineInputBorder(),
),
keyboardType: TextInputType.url,
validator: (v) {
if (v == null || v.trim().isEmpty) {
return AppLocalizations.of(context).requiredField;
}
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),
)
: Text(AppLocalizations.of(context).saveStation),
),
],
),
),
);
}
}
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(
AppLocalizations.of(context).backupExportError(e.toString()),
),
),
);
}
}
}
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: 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(
const SnackBar(
content: Text('Configuración importada correctamente'),
),
);
}
}
} catch (e) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
AppLocalizations.of(context).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: const Text('Exportar configuración'),
subtitle: Text(AppLocalizations.of(context).backupExportSubtitle),
onTap: () => _exportar(context),
),
ListTile(
contentPadding: EdgeInsets.zero,
leading: const Icon(Icons.download_outlined),
title: const Text('Importar configuración'),
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}'
: 'Cargando versión...';
return ListTile(
contentPadding: EdgeInsets.zero,
leading: const PluriIcon(
glyph: PluriIconGlyph.settings,
variant: PluriIconVariant.filled,
),
title: const Text('PluriWave'),
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),
title: Text(
AppLocalizations.of(ctx).savedFavoritesTitle,
),
trailing: Text(
snap.data?.toString() ?? '',
style: Theme.of(ctx).textTheme.bodyLarge,
),
),
),
ListTile(
contentPadding: EdgeInsets.zero,
leading: const Icon(Icons.help_outline_rounded),
title: Text(_helpTitle(ctx)),
subtitle: Text(_helpSubtitle(ctx)),
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: const Icon(Icons.check_circle, color: Colors.green),
),
const ListTile(
contentPadding: EdgeInsets.zero,
leading: Icon(Icons.music_note_outlined),
title: Text('Audio en background'),
subtitle: Text('Continúa al apagar la pantalla'),
trailing: Icon(Icons.check_circle, color: Colors.green),
),
],
),
),
);
}
}
String _helpTitle(BuildContext context) => switch (Localizations.localeOf(
context,
).languageCode) {
'es' => 'Ayuda y tutorial',
'fr' => 'Aide et tutoriel',
'de' => 'Hilfe und Tutorial',
'it' => 'Aiuto e tutorial',
'pt' => 'Ajuda e tutorial',
_ => 'Help and tutorial',
};
String _helpSubtitle(BuildContext context) => switch (Localizations.localeOf(
context,
).languageCode) {
'es' => 'Repasá funciones, consejos y novedades de PluriWave.',
'fr' => 'Revoyez les fonctions, conseils et nouveautés de PluriWave.',
'de' => 'Funktionen, Tipps und Neuigkeiten von PluriWave ansehen.',
'it' => 'Rivedi funzioni, consigli e novità di PluriWave.',
'pt' => 'Revê funções, dicas e novidades do PluriWave.',
_ => 'Review PluriWave features, tips and whats new.',
};
String _formatearDuracionTimer(Duration duracion) {
final horas = duracion.inHours;
final minutos = duracion.inMinutes.remainder(60);
final segundos = duracion.inSeconds.remainder(60);
if (horas > 0) {
return '${horas}h ${minutos.toString().padLeft(2, '0')}m ${segundos.toString().padLeft(2, '0')}s';
}
if (minutos > 0) {
return segundos == 0 ? '$minutos min' : '${minutos}m ${segundos}s';
}
return '$segundos s';
}