refactor(ajustes): split remaining Settings sections into pushed screens

This commit is contained in:
2026-07-28 22:14:03 +02:00
parent 48ece948ff
commit c1903623be
29 changed files with 1538 additions and 735 deletions
+3
View File
@@ -55,6 +55,9 @@
"settingsSubtitle": "Fine-grained sound control, backups, and custom stations.",
"settingsGroupAudioTitle": "AUDIO",
"settingsGroupStationsTitle": "STATIONS",
"settingsGroupRecordingsTitle": "RECORDINGS & MUSIC",
"settingsGroupApplicationTitle": "APPLICATION",
"infoSectionTitle": "Info",
"languageSectionTitle": "Language",
"languageSectionDescription": "Choose how the app language is displayed.",
"languageSystemDefault": "System",
+3
View File
@@ -55,6 +55,9 @@
"settingsSubtitle": "Control fino de sonido, copias de seguridad y emisoras personalizadas.",
"settingsGroupAudioTitle": "AUDIO",
"settingsGroupStationsTitle": "EMISORAS",
"settingsGroupRecordingsTitle": "GRABACIONES Y MÚSICA",
"settingsGroupApplicationTitle": "APLICACIÓN",
"infoSectionTitle": "Información",
"languageSectionTitle": "Idioma",
"languageSectionDescription": "Elegí cómo se muestra el idioma de la app.",
"languageSystemDefault": "Sistema",
+18
View File
@@ -286,6 +286,24 @@ abstract class AppLocalizations {
/// **'EMISORAS'**
String get settingsGroupStationsTitle;
/// No description provided for @settingsGroupRecordingsTitle.
///
/// In es, this message translates to:
/// **'GRABACIONES Y MÚSICA'**
String get settingsGroupRecordingsTitle;
/// No description provided for @settingsGroupApplicationTitle.
///
/// In es, this message translates to:
/// **'APLICACIÓN'**
String get settingsGroupApplicationTitle;
/// No description provided for @infoSectionTitle.
///
/// In es, this message translates to:
/// **'Información'**
String get infoSectionTitle;
/// No description provided for @languageSectionTitle.
///
/// In es, this message translates to:
+9
View File
@@ -105,6 +105,15 @@ class AppLocalizationsAr extends AppLocalizations {
@override
String get settingsGroupStationsTitle => 'EMISORAS';
@override
String get settingsGroupRecordingsTitle => 'GRABACIONES Y MÚSICA';
@override
String get settingsGroupApplicationTitle => 'APLICACIÓN';
@override
String get infoSectionTitle => 'Información';
@override
String get languageSectionTitle => 'اللغة';
+9
View File
@@ -106,6 +106,15 @@ class AppLocalizationsBn extends AppLocalizations {
@override
String get settingsGroupStationsTitle => 'EMISORAS';
@override
String get settingsGroupRecordingsTitle => 'GRABACIONES Y MÚSICA';
@override
String get settingsGroupApplicationTitle => 'APLICACIÓN';
@override
String get infoSectionTitle => 'Información';
@override
String get languageSectionTitle => 'ভাষা';
+9
View File
@@ -105,6 +105,15 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get settingsGroupStationsTitle => 'EMISORAS';
@override
String get settingsGroupRecordingsTitle => 'GRABACIONES Y MÚSICA';
@override
String get settingsGroupApplicationTitle => 'APLICACIÓN';
@override
String get infoSectionTitle => 'Información';
@override
String get languageSectionTitle => 'Sprache';
+9
View File
@@ -105,6 +105,15 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get settingsGroupStationsTitle => 'STATIONS';
@override
String get settingsGroupRecordingsTitle => 'RECORDINGS & MUSIC';
@override
String get settingsGroupApplicationTitle => 'APPLICATION';
@override
String get infoSectionTitle => 'Info';
@override
String get languageSectionTitle => 'Language';
+9
View File
@@ -105,6 +105,15 @@ class AppLocalizationsEs extends AppLocalizations {
@override
String get settingsGroupStationsTitle => 'EMISORAS';
@override
String get settingsGroupRecordingsTitle => 'GRABACIONES Y MÚSICA';
@override
String get settingsGroupApplicationTitle => 'APLICACIÓN';
@override
String get infoSectionTitle => 'Información';
@override
String get languageSectionTitle => 'Idioma';
+9
View File
@@ -106,6 +106,15 @@ class AppLocalizationsFr extends AppLocalizations {
@override
String get settingsGroupStationsTitle => 'EMISORAS';
@override
String get settingsGroupRecordingsTitle => 'GRABACIONES Y MÚSICA';
@override
String get settingsGroupApplicationTitle => 'APLICACIÓN';
@override
String get infoSectionTitle => 'Información';
@override
String get languageSectionTitle => 'Langue';
+9
View File
@@ -105,6 +105,15 @@ class AppLocalizationsHi extends AppLocalizations {
@override
String get settingsGroupStationsTitle => 'EMISORAS';
@override
String get settingsGroupRecordingsTitle => 'GRABACIONES Y MÚSICA';
@override
String get settingsGroupApplicationTitle => 'APLICACIÓN';
@override
String get infoSectionTitle => 'Información';
@override
String get languageSectionTitle => 'भाषा';
+9
View File
@@ -106,6 +106,15 @@ class AppLocalizationsId extends AppLocalizations {
@override
String get settingsGroupStationsTitle => 'EMISORAS';
@override
String get settingsGroupRecordingsTitle => 'GRABACIONES Y MÚSICA';
@override
String get settingsGroupApplicationTitle => 'APLICACIÓN';
@override
String get infoSectionTitle => 'Información';
@override
String get languageSectionTitle => 'Bahasa';
+9
View File
@@ -105,6 +105,15 @@ class AppLocalizationsIt extends AppLocalizations {
@override
String get settingsGroupStationsTitle => 'EMISORAS';
@override
String get settingsGroupRecordingsTitle => 'GRABACIONES Y MÚSICA';
@override
String get settingsGroupApplicationTitle => 'APLICACIÓN';
@override
String get infoSectionTitle => 'Información';
@override
String get languageSectionTitle => 'Lingua';
+9
View File
@@ -103,6 +103,15 @@ class AppLocalizationsJa extends AppLocalizations {
@override
String get settingsGroupStationsTitle => 'EMISORAS';
@override
String get settingsGroupRecordingsTitle => 'GRABACIONES Y MÚSICA';
@override
String get settingsGroupApplicationTitle => 'APLICACIÓN';
@override
String get infoSectionTitle => 'Información';
@override
String get languageSectionTitle => '言語';
+9
View File
@@ -105,6 +105,15 @@ class AppLocalizationsPt extends AppLocalizations {
@override
String get settingsGroupStationsTitle => 'EMISORAS';
@override
String get settingsGroupRecordingsTitle => 'GRABACIONES Y MÚSICA';
@override
String get settingsGroupApplicationTitle => 'APLICACIÓN';
@override
String get infoSectionTitle => 'Información';
@override
String get languageSectionTitle => 'Idioma';
+9
View File
@@ -105,6 +105,15 @@ class AppLocalizationsRu extends AppLocalizations {
@override
String get settingsGroupStationsTitle => 'EMISORAS';
@override
String get settingsGroupRecordingsTitle => 'GRABACIONES Y MÚSICA';
@override
String get settingsGroupApplicationTitle => 'APLICACIÓN';
@override
String get infoSectionTitle => 'Información';
@override
String get languageSectionTitle => 'Язык';
+9
View File
@@ -103,6 +103,15 @@ class AppLocalizationsZh extends AppLocalizations {
@override
String get settingsGroupStationsTitle => 'EMISORAS';
@override
String get settingsGroupRecordingsTitle => 'GRABACIONES Y MÚSICA';
@override
String get settingsGroupApplicationTitle => 'APLICACIÓN';
@override
String get infoSectionTitle => 'Información';
@override
String get languageSectionTitle => '语言';
@@ -0,0 +1,145 @@
import 'dart:io';
import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.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_radio.dart';
import '../../l10n/gen/app_localizations.dart';
import '../../widgets/pluri_glass_surface.dart';
import '../../widgets/pluri_layout.dart';
import '../../widgets/pluri_push_scaffold.dart';
/// APLICACIÓN group · "Copia de seguridad" (design ADR-3). Body moved
/// verbatim from the former `_SeccionBackup` in `pantalla_ajustes.dart` —
/// only the panel header's icon and title were removed (the pushed screen's
/// title now carries them); every method below is unchanged.
class PantallaAjustesBackup extends StatelessWidget {
const PantallaAjustesBackup({super.key});
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
return PluriPushScaffold(
title: l10n.backupSectionTitle,
body: ListView(
padding: PluriLayout.pageContentPadding,
children: const [_CuerpoBackup()],
),
);
}
}
class _CuerpoBackup extends StatelessWidget {
const _CuerpoBackup();
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: [
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),
),
],
),
);
}
}
@@ -0,0 +1,217 @@
import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../estado/estado_grabacion.dart';
import '../../l10n/gen/app_localizations.dart';
import '../../widgets/pluri_glass_surface.dart';
import '../../widgets/pluri_layout.dart';
import '../../widgets/pluri_push_scaffold.dart';
/// GRABACIONES Y MÚSICA group · "Grabaciones" (design ADR-3). Body moved
/// verbatim from the former `_SeccionGrabaciones` in `pantalla_ajustes.dart`
/// — only the panel header's icon and title were removed (the pushed
/// screen's title now carries them); every method below is unchanged.
///
/// Known pre-existing bug, deliberately NOT fixed here (out of scope, moved
/// verbatim, tracked separately): [_editarTamanoMaximo] disposes its
/// [TextEditingController] immediately after `showModalBottomSheet` resolves,
/// racing the sheet's own close animation — the same shape of bug already
/// documented for `_editarGrupo` in `pantalla_ajustes_grupos_favoritos.dart`.
class PantallaAjustesGrabaciones extends StatelessWidget {
const PantallaAjustesGrabaciones({super.key});
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
return PluriPushScaffold(
title: l10n.recordingsSectionTitle,
body: ListView(
padding: PluriLayout.pageContentPadding,
children: const [_CuerpoGrabaciones()],
),
);
}
}
class _CuerpoGrabaciones extends StatelessWidget {
const _CuerpoGrabaciones();
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: [
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,
),
],
),
);
}
}
@@ -0,0 +1,131 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../estado/estado_idioma.dart';
import '../../l10n/gen/app_localizations.dart';
import '../../widgets/pluri_glass_surface.dart';
import '../../widgets/pluri_layout.dart';
import '../../widgets/pluri_push_scaffold.dart';
/// APLICACIÓN group · "Idioma" (design ADR-3). Body moved verbatim from the
/// former `_SeccionIdioma` (+ `_IdiomaDisponible`) in `pantalla_ajustes.dart`
/// — only the panel header's icon and title were removed (the pushed
/// screen's title now carries them); every method and the language list
/// below is unchanged.
class PantallaAjustesIdioma extends StatelessWidget {
const PantallaAjustesIdioma({super.key});
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
return PluriPushScaffold(
title: l10n.languageSectionTitle,
body: ListView(
padding: PluriLayout.pageContentPadding,
children: const [_CuerpoIdioma()],
),
);
}
}
class _CuerpoIdioma extends StatelessWidget {
const _CuerpoIdioma();
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: [
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;
}
@@ -0,0 +1,120 @@
import 'package:flutter/material.dart';
import 'package:package_info_plus/package_info_plus.dart';
import 'package:provider/provider.dart';
import '../../estado/estado_radio.dart';
import '../../l10n/gen/app_localizations.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_push_scaffold.dart';
/// APLICACIÓN group · "Info" (design ADR-3). Body moved verbatim from the
/// former `_SeccionInfo` in `pantalla_ajustes.dart`. Unlike the other four
/// sections in this batch, `_SeccionInfo` never had its own header
/// icon+title row — its first tile (app name + version) already served that
/// role — so there is no header row to strip here; the body below is
/// unchanged in full. `infoSectionTitle` is the one new ARB key this screen
/// needed, since no existing in-body header string covers a bare "Info"
/// label (see WU3b's apply-progress note).
class PantallaAjustesInfo extends StatelessWidget {
const PantallaAjustesInfo({super.key});
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
return PluriPushScaffold(
title: l10n.infoSectionTitle,
body: ListView(
padding: PluriLayout.pageContentPadding,
children: const [_CuerpoInfo()],
),
);
}
}
class _CuerpoInfo extends StatelessWidget {
const _CuerpoInfo();
@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,
),
),
],
),
),
);
}
}
@@ -0,0 +1,129 @@
import 'package:flutter/material.dart';
import '../../l10n/gen/app_localizations.dart';
import '../../servicios/musica_local_auto.dart';
import '../../widgets/pluri_glass_surface.dart';
import '../../widgets/pluri_layout.dart';
import '../../widgets/pluri_push_scaffold.dart';
/// GRABACIONES Y MÚSICA group · "Música local" (design ADR-3). Body moved
/// verbatim from the former `_SeccionMusicaLocal` in `pantalla_ajustes.dart`
/// — only the panel header's icon and title were removed (the pushed
/// screen's title now carries them). Deliberately does NOT use
/// `FilePicker.platform` (see tasks.md "Grounding corrections") —
/// [FuenteMusicaLocalAutoImpl.elegirCarpeta] calls the native
/// `pickMusicFolder` channel method directly, since it needs a
/// persistable-grant SAF tree URI, not a plain filesystem path.
class PantallaAjustesMusicaLocal extends StatelessWidget {
const PantallaAjustesMusicaLocal({super.key});
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
return PluriPushScaffold(
title: l10n.localMusicSectionTitle,
body: ListView(
padding: PluriLayout.pageContentPadding,
children: const [_CuerpoMusicaLocal()],
),
);
}
}
class _CuerpoMusicaLocal extends StatefulWidget {
const _CuerpoMusicaLocal();
@override
State<_CuerpoMusicaLocal> createState() => _CuerpoMusicaLocalState();
}
class _CuerpoMusicaLocalState extends State<_CuerpoMusicaLocal> {
final _fuente = FuenteMusicaLocalAutoImpl();
late Future<String?> _carpetaActual;
@override
void initState() {
super.initState();
_carpetaActual = _fuente.carpetaActual();
}
Future<void> _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: [
const SizedBox(height: 8),
Text(
l10n.localMusicSectionDescription,
style: Theme.of(context).textTheme.bodySmall,
),
FutureBuilder<String?>(
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<String?>(
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),
),
);
},
),
],
),
);
}
}
+65 -655
View File
@@ -1,28 +1,19 @@
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_backup.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_grabaciones.dart';
import 'ajustes/pantalla_ajustes_grupos_favoritos.dart';
import 'ajustes/pantalla_ajustes_idioma.dart';
import 'ajustes/pantalla_ajustes_info.dart';
import 'ajustes/pantalla_ajustes_musica_local.dart';
import 'ajustes/pantalla_ajustes_orden_listas.dart';
import 'ajustes/pantalla_ajustes_salida_audio.dart';
import 'ajustes/pantalla_ajustes_timer_sueno.dart';
@@ -56,13 +47,11 @@ class PantallaAjustes extends StatelessWidget {
}
}
/// 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.
/// Design ADR-3: the Settings root is grouped nav rows only — each
/// [FilaAjuste] pushes its own detail screen via `PluriPushScaffold.push`,
/// carrying zero inline controls. All 12 sections are now decomposed across
/// WU3a (AUDIO, EMISORAS) and WU3b (GRABACIONES Y MÚSICA, APLICACIÓN); sleep
/// timer and backup/restore stay reachable throughout — nothing was dropped.
class _AjustesContent extends StatelessWidget {
const _AjustesContent();
@@ -147,642 +136,63 @@ class _AjustesContent extends StatelessWidget {
],
),
const SizedBox(height: 12),
const _SeccionGrabaciones(),
GrupoAjustes(
titulo: l10n.settingsGroupRecordingsTitle,
filas: [
FilaAjuste(
icon: Icons.radio_button_checked_rounded,
titulo: l10n.recordingsSectionTitle,
onTap:
() => PluriPushScaffold.push(
context,
(_) => const PantallaAjustesGrabaciones(),
),
),
FilaAjuste(
icon: Icons.library_music_outlined,
titulo: l10n.localMusicSectionTitle,
onTap:
() => PluriPushScaffold.push(
context,
(_) => const PantallaAjustesMusicaLocal(),
),
),
],
),
const SizedBox(height: 12),
const _SeccionMusicaLocal(),
const SizedBox(height: 12),
const _SeccionIdioma(),
const SizedBox(height: 12),
const _SeccionBackup(),
const SizedBox(height: 12),
const _SeccionInfo(),
GrupoAjustes(
titulo: l10n.settingsGroupApplicationTitle,
filas: [
FilaAjuste(
icon: Icons.language_rounded,
titulo: l10n.languageSectionTitle,
onTap:
() => PluriPushScaffold.push(
context,
(_) => const PantallaAjustesIdioma(),
),
),
FilaAjuste(
icon: Icons.backup_outlined,
titulo: l10n.backupSectionTitle,
onTap:
() => PluriPushScaffold.push(
context,
(_) => const PantallaAjustesBackup(),
),
),
FilaAjuste(
icon: Icons.info_outline_rounded,
titulo: l10n.infoSectionTitle,
onTap:
() => PluriPushScaffold.push(
context,
(_) => const PantallaAjustesInfo(),
),
),
],
),
],
);
}
}
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,
),
],
),
);
}
}
/// 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<String?> _carpetaActual;
@override
void initState() {
super.initState();
_carpetaActual = _fuente.carpetaActual();
}
Future<void> _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<String?>(
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<String?>(
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<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 _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,
),
),
],
),
),
);
}
}