import 'dart:async'; 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 '../estado/estado_grabacion.dart'; import '../estado/estado_idioma.dart'; import '../estado/estado_radio.dart'; import '../l10n/gen/app_localizations.dart'; import '../servicios/musica_local_auto.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'; import '../widgets/pluri_push_scaffold.dart'; import 'ajustes/pantalla_ajustes_ecualizador.dart'; import 'ajustes/pantalla_ajustes_emisora_preferida.dart'; import 'ajustes/pantalla_ajustes_emisoras_personalizadas.dart'; import 'ajustes/pantalla_ajustes_grupos_favoritos.dart'; import 'ajustes/pantalla_ajustes_orden_listas.dart'; import 'ajustes/pantalla_ajustes_salida_audio.dart'; import 'ajustes/pantalla_ajustes_timer_sueno.dart'; import 'ajustes/widgets/fila_ajuste.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(), ), ], ); } } /// Design ADR-3: the AUDIO and EMISORAS groups are grouped nav rows only — /// each [FilaAjuste] pushes its own detail screen via /// `PluriPushScaffold.push`, carrying zero inline controls in the root. /// /// The remaining sections (GRABACIONES Y MÚSICA, APLICACIÓN) still render /// inline here pending WU3b, which decomposes them the same way. Sleep timer /// and backup/restore stay reachable throughout — nothing is dropped. class _AjustesContent extends StatelessWidget { const _AjustesContent(); @override Widget build(BuildContext context) { final l10n = AppLocalizations.of(context); return Column( children: [ GrupoAjustes( titulo: l10n.settingsGroupAudioTitle, filas: [ FilaAjuste( icon: Icons.equalizer_rounded, titulo: l10n.equalizerTitle, onTap: () => PluriPushScaffold.push( context, (_) => const PantallaAjustesEcualizador(), ), ), FilaAjuste( icon: Icons.devices_rounded, titulo: l10n.advancedEqSectionTitle, onTap: () => PluriPushScaffold.push( context, (_) => const PantallaAjustesSalidaAudio(), ), ), FilaAjuste( icon: Icons.bedtime_rounded, titulo: l10n.timerSectionTitle, onTap: () => PluriPushScaffold.push( context, (_) => const PantallaAjustesTimerSueno(), ), ), ], ), const SizedBox(height: 12), GrupoAjustes( titulo: l10n.settingsGroupStationsTitle, filas: [ FilaAjuste( icon: Icons.playlist_add_check_circle_rounded, titulo: l10n.favoriteGroupsTitle, onTap: () => PluriPushScaffold.push( context, (_) => const PantallaAjustesGruposFavoritos(), ), ), FilaAjuste( icon: Icons.radio_rounded, titulo: l10n.preferredStationTitle, onTap: () => PluriPushScaffold.push( context, (_) => const PantallaAjustesEmisoraPreferida(), ), ), FilaAjuste( icon: Icons.add_circle_outline_rounded, titulo: l10n.customStationsTitle, onTap: () => PluriPushScaffold.push( context, (_) => const PantallaAjustesEmisorasPersonalizadas(), ), ), FilaAjuste( icon: Icons.sort_rounded, titulo: l10n.stationOrderTitle, onTap: () => PluriPushScaffold.push( context, (_) => const PantallaAjustesOrdenListas(), ), ), ], ), const SizedBox(height: 12), const _SeccionGrabaciones(), const SizedBox(height: 12), const _SeccionMusicaLocal(), const SizedBox(height: 12), const _SeccionIdioma(), const SizedBox(height: 12), const _SeccionBackup(), const SizedBox(height: 12), const _SeccionInfo(), ], ); } } class _SeccionGrabaciones extends StatelessWidget { const _SeccionGrabaciones(); Future _seleccionarRuta(BuildContext context) async { final estado = context.read(); 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 _restaurarRuta(BuildContext context) async { final estado = context.read(); 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 _abrirCarpeta(BuildContext context) async { final estado = context.read(); 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 _editarTamanoMaximo(BuildContext context) async { final estado = context.read(); final l10n = AppLocalizations.of(context); final actualMb = _bytesAMegabytes(estado.maxBytes); final controller = TextEditingController(text: actualMb.toString()); final nuevoMb = await showModalBottomSheet( 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(); 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( 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, ), ], ), ); } } /// Local-music root-folder picker (android-auto-local-music task 9), /// mirroring [_SeccionGrabaciones]'s shape: `PluriGlassSurface` card, /// `FutureBuilder`-driven current-folder display, a single action button and /// snackbar feedback. Deliberately does NOT use `FilePicker.platform` (see /// tasks.md "Grounding corrections") — [FuenteMusicaLocalAutoImpl.elegirCarpeta] /// calls the NEW native `pickMusicFolder` channel method directly, since it /// needs a persistable-grant SAF tree URI, not a plain filesystem path. class _SeccionMusicaLocal extends StatefulWidget { const _SeccionMusicaLocal(); @override State<_SeccionMusicaLocal> createState() => _SeccionMusicaLocalState(); } class _SeccionMusicaLocalState extends State<_SeccionMusicaLocal> { final _fuente = FuenteMusicaLocalAutoImpl(); late Future _carpetaActual; @override void initState() { super.initState(); _carpetaActual = _fuente.carpetaActual(); } Future _elegirCarpeta(BuildContext context) async { final messenger = ScaffoldMessenger.of(context); final l10n = AppLocalizations.of(context); try { final uri = await _fuente.elegirCarpeta(); if (uri == null) return; // Cancelled — no snackbar, matches the SAF // picker's own "nothing changed" affordance. if (!context.mounted) return; setState(() { _carpetaActual = Future.value(uri); }); messenger.showSnackBar( SnackBar(content: Text(l10n.localMusicFolderUpdated)), ); } catch (e) { if (!context.mounted) return; messenger.showSnackBar( SnackBar(content: Text(l10n.localMusicFolderSaveError(e.toString()))), ); } } @override Widget build(BuildContext context) { final l10n = AppLocalizations.of(context); return PluriGlassSurface( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ const Icon(Icons.library_music_outlined), const SizedBox(width: 12), Text( l10n.localMusicSectionTitle, style: Theme.of(context).textTheme.titleMedium, ), ], ), const SizedBox(height: 8), Text( l10n.localMusicSectionDescription, style: Theme.of(context).textTheme.bodySmall, ), FutureBuilder( future: _carpetaActual, builder: (ctx, snap) { final carpeta = snap.data; return ListTile( contentPadding: EdgeInsets.zero, leading: const Icon(Icons.folder_outlined), title: Text(l10n.localMusicFolderTitle), subtitle: Text( (carpeta == null || carpeta.isEmpty) ? l10n.localMusicFolderNotConfigured : nombreCarpetaDesdeUri( carpeta, nombreGenerico: l10n.localMusicFolderGenericName, ), maxLines: 2, overflow: TextOverflow.ellipsis, ), ); }, ), const SizedBox(height: 8), FutureBuilder( future: _carpetaActual, builder: (ctx, snap) { final configurada = (snap.data ?? '').isNotEmpty; return Align( alignment: Alignment.centerLeft, child: OutlinedButton.icon( icon: const Icon(Icons.folder_open_rounded), label: Text( configurada ? l10n.localMusicChangePath : l10n.localMusicChoosePath, ), onPressed: () => _elegirCarpeta(context), ), ); }, ), ], ), ); } } 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(); 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( 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().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().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 _SeccionBackup extends StatelessWidget { const _SeccionBackup(); Future _exportar(BuildContext context) async { final l10n = AppLocalizations.of(context); try { final estado = context.read(); // 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 _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().parsearConfigJson(contenido); if (json == null) { throw const FormatException('invalid backup file'); } if (context.mounted) { final confirmar = await showDialog( 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(); 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( builder: (ctx, estado, _) => PluriGlassSurface( child: Column( children: [ FutureBuilder( 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( 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, ), ), ], ), ), ); } }