Moves the AUDIO group (Ecualizador, Salida de audio, Temporizador de
sueno) and the EMISORAS group (Grupos de favoritos, Emisora preferida,
Emisoras personalizadas, Orden de listas) out of pantalla_ajustes.dart
into 7 new lib/pantallas/ajustes/*.dart screens, each wrapped in
PluriPushScaffold. The root now reaches them through FilaAjuste rows
under two new GrupoAjustes cards (lib/pantallas/ajustes/widgets/
fila_ajuste.dart), per design ADR-3.
Verbatim-move rule applied throughout: only each section's panel header
(icon + title, sometimes a status chip) was removed, since the pushed
screen's own 56px header now carries the title. Two sections whose
header row carried a real action (Temporizador de sueno's "Add",
Grupos de favoritos' "Add list", Emisoras personalizadas' "Add") kept
that action in the body instead of dropping it.
size:exception (move-only diff, pre-recorded at design/tasks time):
34 files, ~4250 changed lines excluding the 13 auto-regenerated l10n
files (~90 more lines there) - higher than the 800-1000 estimate
because that estimate covered the 7 production screens but not the
matching 7 new test files (task 3a.2), one of which relocates ~10
pre-existing device-management test cases verbatim. Business logic is
untouched; app.dart's import of pantalla_ajustes.dart is unchanged.
Correction to tasks.md 3a.1/3a.8: those two lines describe the combined
WU3a+WU3b end state ("4 grouped nav lists", "<400 lines"), matching
design ADR-3's own aggregate blast-radius note - not a WU3a-only claim.
This commit converts only the 2 groups that are WU3a's job; the root
is 788 lines with 5 sections (Grabaciones, Musica local, Idioma,
Backup, Info) still inline, reachable, and unchanged, pending WU3b.
Two new ARB keys (settingsGroupAudioTitle, settingsGroupStationsTitle),
en/es only per the WU1 precedent - all 7 detail-screen titles reuse
existing keys. Discovered and worked around, without touching app
code: Directory.systemTemp hangs real dart:io writes in this sandbox,
and pumpAndSettle() cannot settle while a screen shows an indeterminate
CircularProgressIndicator - both are test-only concerns, documented
inline where hit.
Tests: 560 -> 579 (32 in this commit's scope, net +19 after retiring
13 relocated cases from the old combined pantalla_ajustes_test.dart).
flutter analyze: unchanged at 1 pre-existing info. git diff is empty
for navegacion_auto.dart, servicio_ecualizador.dart and
servicio_audio.dart; pantalla_alarma_sonando_dismiss_guard_test.dart
untouched.
789 lines
27 KiB
Dart
789 lines
27 KiB
Dart
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<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,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|