refactor(ajustes): split remaining Settings sections into pushed screens
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 => 'اللغة';
|
||||
|
||||
|
||||
@@ -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 => 'ভাষা';
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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 => 'भाषा';
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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 => '言語';
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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 => 'Язык';
|
||||
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,7 +39,7 @@
|
||||
| 1 | `feat(tokens): add design tokens, type scale, push scaffold, and root nav state` | — | 220-330 | Low | No |
|
||||
| 2 | `test(auto): confirm Android Auto tree matches the redesign, no code changes` | — | 0 | None | No |
|
||||
| 3a | `refactor(ajustes): split Settings AUDIO/EMISORAS into pushed detail screens` | 1 | ~~800-1000~~ → **REALIZED: 4,374** (2,760+ / 1,614-, 35 files) | High | **Yes — move-only diff** |
|
||||
| 3b | `refactor(ajustes): split remaining Settings sections into pushed screens` | 3a | **~2,500-3,200**\* | Medium-High | **Yes — move-only diff** |
|
||||
| 3b | `refactor(ajustes): split remaining Settings sections into pushed screens` | 3a | ~~2,500-3,200~~\* → **REALIZED: 2,190** (1,485+ / 705-, 28 files) | Medium-High | **Yes — move-only diff** |
|
||||
| 4 | `feat(favoritos): replace stacked group panels with chip-filtered reorderable list` | 1 | 300-400 | Medium | Monitor |
|
||||
| 5 | `feat(escuchar): replace discovery browser with embedded player and favorites grid` | 1, 4 | 350-450 | Medium | Monitor |
|
||||
| 6 | `feat(buscar): add discovery landing state, filter pills, counter, and sort` | 5 | 350-500 | Medium-High | Monitor |
|
||||
@@ -66,9 +66,10 @@ WU3b moves 5 sections rather than 7 and needs no new shared widget (`fila_ajuste
|
||||
~2,500-3,200. **Estimating lesson for every remaining work unit: a strict-TDD commit carries its test files, and any
|
||||
ARB touch drags 13 generated files with it. Estimates that count only `lib/` production code will read ~3-4x low.**
|
||||
|
||||
**Root line count, for reference**: `pantalla_ajustes.dart` went 1,897 → **788** in WU3a. The "under 400 lines"
|
||||
success criterion is the WU3a+WU3b **combined** end state, not WU3a alone — WU3a owns only 7 of the 12 detail
|
||||
screens. WU3b must take the remaining ~388 lines out.
|
||||
**Root line count, for reference**: `pantalla_ajustes.dart` went 1,897 → 788 in WU3a → **198** in WU3b. The "under
|
||||
400 lines" success criterion was the WU3a+WU3b **combined** end state — WU3a owned only 7 of the 12 detail screens.
|
||||
WU3b converted the remaining 5 (Grabaciones, Música local, Idioma, Backup, Info) and the root is now exactly 4
|
||||
`GrupoAjustes` cards, well under the 400-line target.
|
||||
† Record `size:exception` at apply time only if the realized diff exceeds ~500 lines; justification: "largest single
|
||||
alarm-card + hero + vacation-summary restyle, not divisible without breaking the one-commit-per-work-unit rule."
|
||||
‡ The proposal already isolates WU10 as "its own PR, never bundled" — splitting further would leave an unintegrated
|
||||
@@ -226,23 +227,45 @@ modified. Do not attempt to slice under 450.
|
||||
Backup, Info)
|
||||
**Modified tests**: `test/pantallas/pantalla_ajustes_test.dart` (extended)
|
||||
|
||||
- [ ] 3b.1 RED — extend detail-screen tests for the remaining 5 sections, asserting `PluriPushScaffold` usage and
|
||||
preserved controls.
|
||||
- [ ] 3b.2 GREEN — cut `_SeccionGrabaciones` (92-291), `_SeccionMusicaLocal` (291-399) into
|
||||
`pantalla_ajustes_grabaciones.dart`, `pantalla_ajustes_musica_local.dart`.
|
||||
- [ ] 3b.3 GREEN — cut `_SeccionIdioma` + `_IdiomaDisponible` (492-694), `_SeccionBackup` (1669-1791),
|
||||
`_SeccionInfo` (1791-1896) into `pantalla_ajustes_idioma.dart`, `pantalla_ajustes_backup.dart`,
|
||||
`pantalla_ajustes_info.dart`.
|
||||
- [ ] 3b.4 GREEN — in the root, replace the remaining sections with `FilaAjuste` rows under two more `GrupoAjustes`
|
||||
cards (GRABACIONES Y MÚSICA, APLICACIÓN); confirm sleep timer and backup rows are present — corollary ruling,
|
||||
nothing dropped.
|
||||
- [ ] 3b.5 REFACTOR — confirm `pantalla_ajustes.dart` now contains only 4 `GrupoAjustes` cards and is under 400
|
||||
lines; delete now-unused private section widgets.
|
||||
- [ ] 3b.6 Verify — full `pantalla_ajustes_test.dart` suite green; all 12 detail screens present and reachable; no
|
||||
service/state file touched.
|
||||
- [x] 3b.1 RED — extend detail-screen tests for the remaining 5 sections, asserting `PluriPushScaffold` usage and
|
||||
preserved controls. **Applied against the CURRENT 788-line file (re-read fresh at apply time, not the stale
|
||||
92-1896 line references below, which were computed against the pre-WU3a 1,897-line file).** Root test file:
|
||||
removed the now-false "Sections pending WU3b remain inline" and "Phase 7" groups, added a `WU3b — GRABACIONES Y
|
||||
MÚSICA and APLICACIÓN groups` group (5 scenarios: 2-row/3-row zero-inline-control checks, 2 push-navigation
|
||||
checks, one "root is exactly 4 GrupoAjustes cards" check). The 2 relocated Phase-7 friendly-folder-name
|
||||
scenarios moved verbatim into the new `ajustes/pantalla_ajustes_musica_local_test.dart`, targeting the isolated
|
||||
pushed screen directly instead of scrolling to find it inside the whole root.
|
||||
- [x] 3b.2 GREEN — cut `_SeccionGrabaciones`, `_SeccionMusicaLocal` (Stateful) into `pantalla_ajustes_grabaciones.dart`,
|
||||
`pantalla_ajustes_musica_local.dart` — verbatim, header row removed per the established rule.
|
||||
- [x] 3b.3 GREEN — cut `_SeccionIdioma` + `_IdiomaDisponible`, `_SeccionBackup`, `_SeccionInfo` into
|
||||
`pantalla_ajustes_idioma.dart`, `pantalla_ajustes_backup.dart`, `pantalla_ajustes_info.dart`. **Correction
|
||||
found at apply time**: unlike the other 4 sections, `_SeccionInfo` never had its own header icon+title row —
|
||||
its first tile (app name + version) already served that role — so there was no header to strip for that one
|
||||
screen; its body moved in full, unchanged. This is also why `infoSectionTitle` is a genuinely new ARB key (no
|
||||
existing in-body header string covered a bare "Info" label), unlike the other 4 screens which all reuse
|
||||
pre-existing strings.
|
||||
- [x] 3b.4 GREEN — in the root, replaced the remaining 5 sections with `FilaAjuste` rows under two more
|
||||
`GrupoAjustes` cards: RECORDINGS & MUSIC (Recordings, Local music) and APPLICATION (Language, Backup, Info).
|
||||
Confirmed sleep timer (WU3a) and backup/restore (this WU) rows are both present — corollary ruling, nothing
|
||||
dropped. **3 new ARB keys** (en/es only, matching WU1/WU3a's precedent of leaving the other 11 locales for
|
||||
WU18): `settingsGroupRecordingsTitle` ("RECORDINGS & MUSIC"/"GRABACIONES Y MÚSICA"),
|
||||
`settingsGroupApplicationTitle` ("APPLICATION"/"APLICACIÓN") for the two new group eyebrow labels, and
|
||||
`infoSectionTitle` ("Info"/"Información") for the one row with no pre-existing header string (see 3b.3). All
|
||||
other row titles reuse existing ARB keys.
|
||||
- [x] 3b.5 REFACTOR — confirmed `pantalla_ajustes.dart` now contains exactly 4 `GrupoAjustes` cards and is **198
|
||||
lines** (well under the 400-line target); deleted all 5 now-unused private section widgets plus every import
|
||||
that was only needed by their bodies (`dart:async`, `dart:io`, `file_picker`, `package_info_plus`,
|
||||
`path_provider`, `provider`, `share_plus`, `estado_grabacion.dart`, `estado_idioma.dart`, `estado_radio.dart`,
|
||||
`musica_local_auto.dart`, `pluri_glass_surface.dart`, `pluri_onboarding_dialog.dart` — none of these are read
|
||||
by the root anymore since it is pure navigation chrome).
|
||||
- [x] 3b.6 Verify — scoped suite (`pantalla_ajustes_test.dart` + `ajustes/`) green: 45/45. All 12 detail screens
|
||||
present and reachable. `git diff` touches only screen/test/ARB(+generated l10n) files — no service/state file
|
||||
touched. Full suite: 592/592 (up from 579). `flutter analyze`: 1 issue, identical to baseline.
|
||||
|
||||
**`size:exception` — "move-only diff".** ~500-700 changed lines (derived; see forecast table footnote). Same
|
||||
justification as WU3a.
|
||||
**`size:exception` — "move-only diff".** Realized: **2,190 changed lines** (1,485+ / 705-) across 28 files — under
|
||||
the ~2,500-3,200 estimate this time (Música local's compact Stateful body and this batch's conservative choice to
|
||||
verify reachability rather than tap through unmocked native-channel controls for Backup/Grabaciones' riskiest rows
|
||||
both kept it below forecast). Same "move-only diff" justification as WU3a.
|
||||
|
||||
## WU4 — Favoritos restyle
|
||||
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:pluriwave/estado/estado_radio.dart';
|
||||
import 'package:pluriwave/l10n/gen/app_localizations.dart';
|
||||
import 'package:pluriwave/pantallas/ajustes/pantalla_ajustes_backup.dart';
|
||||
import 'package:pluriwave/widgets/pluri_push_scaffold.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import '../../helpers/fakes.dart';
|
||||
import '../../helpers/fakes_alarmas.dart';
|
||||
|
||||
/// Pre-existing project constraint (see `pantalla_ajustes_test.dart`):
|
||||
/// PluriGlassSurface paints a background over ListTile's ink layer, which
|
||||
/// Flutter flags as a warning-level assertion, not a correctness bug.
|
||||
void _suppressListTileInkAssertion() {
|
||||
final original = FlutterError.onError;
|
||||
FlutterError.onError = (details) {
|
||||
if (details.exceptionAsString().contains(
|
||||
'ListTile background color or ink splashes may be invisible',
|
||||
)) {
|
||||
return;
|
||||
}
|
||||
original?.call(details);
|
||||
};
|
||||
addTearDown(() => FlutterError.onError = original);
|
||||
}
|
||||
|
||||
/// WU3b task 3b.1: the APLICACIÓN detail screen for "Copia de seguridad"
|
||||
/// renders inside a [PluriPushScaffold] and its moved controls (export /
|
||||
/// import rows) are still present and reachable exactly as they were inside
|
||||
/// the old `_SeccionBackup`.
|
||||
///
|
||||
/// Both rows call into native plugins (`share_plus`, `file_picker`) that
|
||||
/// this suite does not mock, so this file verifies reachability (title and
|
||||
/// both row labels present, with a live `onTap`) rather than tapping through
|
||||
/// the native share/pick flow — the same conservative choice this batch
|
||||
/// makes for any moved control that would otherwise depend on an unmocked
|
||||
/// platform channel.
|
||||
void main() {
|
||||
setUp(() {
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
});
|
||||
|
||||
Future<File> archivoCustomVacio() async => File(
|
||||
'${Directory.current.path}/test/fixtures/emisoras_custom_vacio.json',
|
||||
);
|
||||
|
||||
Future<EstadoRadio> crearEstado() async {
|
||||
return EstadoRadio(
|
||||
audio: FakeServicioAudio(),
|
||||
favoritos: FakeServicioFavoritos(),
|
||||
radio: FakeServicioRadio(),
|
||||
servicioEcualizador: FakeServicioEcualizador(),
|
||||
servicioGrabacion: FakeServicioGrabacionRadioInactiva(),
|
||||
resolverArchivoCustom: archivoCustomVacio,
|
||||
iniciarAutomaticamente: false,
|
||||
);
|
||||
}
|
||||
|
||||
Widget buildScreen(EstadoRadio estado) {
|
||||
return ChangeNotifierProvider<EstadoRadio>.value(
|
||||
value: estado,
|
||||
child: MaterialApp(
|
||||
locale: const Locale('en'),
|
||||
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||
supportedLocales: AppLocalizations.supportedLocales,
|
||||
home: const PantallaAjustesBackup(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
testWidgets('renders inside a PluriPushScaffold titled "Backup"', (
|
||||
tester,
|
||||
) async {
|
||||
_suppressListTileInkAssertion();
|
||||
final estado = await crearEstado();
|
||||
addTearDown(estado.dispose);
|
||||
|
||||
await tester.pumpWidget(buildScreen(estado));
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 100));
|
||||
|
||||
expect(find.byType(PluriPushScaffold), findsOneWidget);
|
||||
expect(find.text('Backup'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets(
|
||||
'moved controls still reachable: export and import rows both present',
|
||||
(tester) async {
|
||||
_suppressListTileInkAssertion();
|
||||
final estado = await crearEstado();
|
||||
addTearDown(estado.dispose);
|
||||
|
||||
await tester.pumpWidget(buildScreen(estado));
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 100));
|
||||
|
||||
final exportTile = find.widgetWithText(ListTile, 'Export configuration');
|
||||
final importTile = find.widgetWithText(ListTile, 'Import configuration');
|
||||
expect(exportTile, findsOneWidget);
|
||||
expect(importTile, findsOneWidget);
|
||||
expect(tester.widget<ListTile>(exportTile).onTap, isNotNull);
|
||||
expect(tester.widget<ListTile>(importTile).onTap, isNotNull);
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:pluriwave/estado/estado_grabacion.dart';
|
||||
import 'package:pluriwave/l10n/gen/app_localizations.dart';
|
||||
import 'package:pluriwave/pantallas/ajustes/pantalla_ajustes_grabaciones.dart';
|
||||
import 'package:pluriwave/widgets/pluri_push_scaffold.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import '../../helpers/fakes_alarmas.dart';
|
||||
|
||||
/// Pre-existing project constraint (see `pantalla_ajustes_test.dart`):
|
||||
/// PluriGlassSurface paints a background over ListTile's ink layer, which
|
||||
/// Flutter flags as a warning-level assertion, not a correctness bug.
|
||||
void _suppressListTileInkAssertion() {
|
||||
final original = FlutterError.onError;
|
||||
FlutterError.onError = (details) {
|
||||
if (details.exceptionAsString().contains(
|
||||
'ListTile background color or ink splashes may be invisible',
|
||||
)) {
|
||||
return;
|
||||
}
|
||||
original?.call(details);
|
||||
};
|
||||
addTearDown(() => FlutterError.onError = original);
|
||||
}
|
||||
|
||||
/// WU3b task 3b.1: the GRABACIONES Y MÚSICA detail screen for "Grabaciones"
|
||||
/// renders inside a [PluriPushScaffold] and its moved controls still respond
|
||||
/// exactly as they did inside the old `_SeccionGrabaciones`.
|
||||
///
|
||||
/// This file deliberately does NOT interact with "Maximum recording size"
|
||||
/// (`_editarTamanoMaximo`): that control has a pre-existing, out-of-scope
|
||||
/// controller-dispose race (documented in `pantalla_ajustes_grabaciones.dart`
|
||||
/// and, for the analogous `_editarGrupo` case, in
|
||||
/// `pantalla_ajustes_grupos_favoritos_test.dart`) that this move does not
|
||||
/// fix. "Restore default path" is exercised instead — a real, moved
|
||||
/// capability with no such hazard.
|
||||
void main() {
|
||||
setUp(() {
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
});
|
||||
|
||||
Widget buildScreen(EstadoGrabacion estado) {
|
||||
return ListenableProvider<EstadoGrabacion>.value(
|
||||
value: estado,
|
||||
child: MaterialApp(
|
||||
locale: const Locale('en'),
|
||||
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||
supportedLocales: AppLocalizations.supportedLocales,
|
||||
home: const PantallaAjustesGrabaciones(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
testWidgets('renders inside a PluriPushScaffold titled "Recordings"', (
|
||||
tester,
|
||||
) async {
|
||||
_suppressListTileInkAssertion();
|
||||
final estado = EstadoGrabacion(
|
||||
servicio: FakeServicioGrabacionRadioInactiva(),
|
||||
);
|
||||
addTearDown(estado.dispose);
|
||||
|
||||
await tester.pumpWidget(buildScreen(estado));
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 100));
|
||||
|
||||
expect(find.byType(PluriPushScaffold), findsOneWidget);
|
||||
expect(find.text('Recordings'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('moved control still responds: restore default path clears the '
|
||||
'configured directory', (tester) async {
|
||||
_suppressListTileInkAssertion();
|
||||
final estado = EstadoGrabacion(
|
||||
servicio: FakeServicioGrabacionRadioInactiva(),
|
||||
);
|
||||
addTearDown(estado.dispose);
|
||||
await estado.cambiarDirectorio('/tmp/custom-recordings');
|
||||
expect(estado.directorioConfigurado, '/tmp/custom-recordings');
|
||||
|
||||
await tester.pumpWidget(buildScreen(estado));
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 100));
|
||||
|
||||
await tester.tap(find.byIcon(Icons.restore_rounded));
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 100));
|
||||
|
||||
expect(estado.directorioConfigurado, isNull);
|
||||
expect(
|
||||
find.text('The default internal folder will be used'),
|
||||
findsOneWidget,
|
||||
);
|
||||
|
||||
// SnackBar's own dismiss Timer is not frame-scheduled — let it resolve
|
||||
// before teardown (WU3a batch discovery) instead of leaving a pending
|
||||
// Timer behind.
|
||||
await tester.pump(const Duration(seconds: 5));
|
||||
await tester.pumpAndSettle();
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:pluriwave/estado/estado_idioma.dart';
|
||||
import 'package:pluriwave/l10n/gen/app_localizations.dart';
|
||||
import 'package:pluriwave/pantallas/ajustes/pantalla_ajustes_idioma.dart';
|
||||
import 'package:pluriwave/widgets/pluri_push_scaffold.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
/// WU3b task 3b.1: the APLICACIÓN detail screen for "Idioma" renders inside
|
||||
/// a [PluriPushScaffold] and its moved control (the language dropdown)
|
||||
/// still responds exactly as it did inside the old `_SeccionIdioma`.
|
||||
void main() {
|
||||
setUp(() {
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
});
|
||||
|
||||
Widget buildScreen(EstadoIdioma estado) {
|
||||
return ChangeNotifierProvider<EstadoIdioma>.value(
|
||||
value: estado,
|
||||
child: MaterialApp(
|
||||
locale: const Locale('en'),
|
||||
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||
supportedLocales: AppLocalizations.supportedLocales,
|
||||
home: const PantallaAjustesIdioma(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
testWidgets('renders inside a PluriPushScaffold titled "Language"', (
|
||||
tester,
|
||||
) async {
|
||||
final estado = EstadoIdioma(
|
||||
sharedPreferences: await SharedPreferences.getInstance(),
|
||||
);
|
||||
addTearDown(estado.dispose);
|
||||
|
||||
await tester.pumpWidget(buildScreen(estado));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byType(PluriPushScaffold), findsOneWidget);
|
||||
// "Language" legitimately renders twice here too (pre-existing,
|
||||
// unmodified by this move): the pushed screen's title AND the
|
||||
// dropdown's own floating label share the same l10n string.
|
||||
expect(find.text('Language'), findsWidgets);
|
||||
});
|
||||
|
||||
testWidgets('moved control still responds: selecting a language persists '
|
||||
'it', (tester) async {
|
||||
final estado = EstadoIdioma(
|
||||
sharedPreferences: await SharedPreferences.getInstance(),
|
||||
);
|
||||
addTearDown(estado.dispose);
|
||||
|
||||
await tester.pumpWidget(buildScreen(estado));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(find.byType(DropdownButtonFormField<String>));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('Español').last);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(estado.localeSeleccionado, const Locale('es'));
|
||||
expect(find.text('Language updated: Español'), findsOneWidget);
|
||||
|
||||
// SnackBar's own dismiss Timer is not frame-scheduled — let it resolve
|
||||
// before teardown (WU3a batch discovery) instead of leaving a pending
|
||||
// Timer behind.
|
||||
await tester.pump(const Duration(seconds: 5));
|
||||
await tester.pumpAndSettle();
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:pluriwave/estado/estado_radio.dart';
|
||||
import 'package:pluriwave/l10n/gen/app_localizations.dart';
|
||||
import 'package:pluriwave/pantallas/ajustes/pantalla_ajustes_info.dart';
|
||||
import 'package:pluriwave/widgets/pluri_push_scaffold.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import '../../helpers/fakes.dart';
|
||||
import '../../helpers/fakes_alarmas.dart';
|
||||
|
||||
/// Pre-existing project constraint (see `pantalla_ajustes_test.dart`):
|
||||
/// PluriGlassSurface paints a background over ListTile's ink layer, which
|
||||
/// Flutter flags as a warning-level assertion, not a correctness bug.
|
||||
void _suppressListTileInkAssertion() {
|
||||
final original = FlutterError.onError;
|
||||
FlutterError.onError = (details) {
|
||||
if (details.exceptionAsString().contains(
|
||||
'ListTile background color or ink splashes may be invisible',
|
||||
)) {
|
||||
return;
|
||||
}
|
||||
original?.call(details);
|
||||
};
|
||||
addTearDown(() => FlutterError.onError = original);
|
||||
}
|
||||
|
||||
/// WU3b task 3b.1: the APLICACIÓN detail screen for "Info" renders inside a
|
||||
/// [PluriPushScaffold] and its moved controls (saved-favorites count, help
|
||||
/// row) still respond exactly as they did inside the old `_SeccionInfo`.
|
||||
/// Unlike the other four WU3b screens, this one never had its own header
|
||||
/// icon+title row to strip (see `pantalla_ajustes_info.dart`'s doc comment).
|
||||
void main() {
|
||||
setUp(() {
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
});
|
||||
|
||||
Future<File> archivoCustomVacio() async => File(
|
||||
'${Directory.current.path}/test/fixtures/emisoras_custom_vacio.json',
|
||||
);
|
||||
|
||||
Future<EstadoRadio> crearEstado() async {
|
||||
return EstadoRadio(
|
||||
audio: FakeServicioAudio(),
|
||||
favoritos: FakeServicioFavoritos(),
|
||||
radio: FakeServicioRadio(),
|
||||
servicioEcualizador: FakeServicioEcualizador(),
|
||||
servicioGrabacion: FakeServicioGrabacionRadioInactiva(),
|
||||
resolverArchivoCustom: archivoCustomVacio,
|
||||
iniciarAutomaticamente: false,
|
||||
);
|
||||
}
|
||||
|
||||
Widget buildScreen(EstadoRadio estado) {
|
||||
return ChangeNotifierProvider<EstadoRadio>.value(
|
||||
value: estado,
|
||||
child: MaterialApp(
|
||||
locale: const Locale('en'),
|
||||
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||
supportedLocales: AppLocalizations.supportedLocales,
|
||||
home: const PantallaAjustesInfo(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
testWidgets('renders inside a PluriPushScaffold titled "Info"', (
|
||||
tester,
|
||||
) async {
|
||||
_suppressListTileInkAssertion();
|
||||
final estado = await crearEstado();
|
||||
addTearDown(estado.dispose);
|
||||
|
||||
await tester.pumpWidget(buildScreen(estado));
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 100));
|
||||
|
||||
expect(find.byType(PluriPushScaffold), findsOneWidget);
|
||||
expect(find.text('Info'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets(
|
||||
'moved control still responds: saved favorites count reflects the '
|
||||
'favorites list',
|
||||
(tester) async {
|
||||
_suppressListTileInkAssertion();
|
||||
final estado = await crearEstado();
|
||||
addTearDown(estado.dispose);
|
||||
|
||||
await tester.pumpWidget(buildScreen(estado));
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 100));
|
||||
|
||||
expect(find.text('Saved favorites'), findsOneWidget);
|
||||
expect(find.text('0'), findsOneWidget);
|
||||
expect(find.text('Help and tutorial'), findsOneWidget);
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:pluriwave/l10n/gen/app_localizations.dart';
|
||||
import 'package:pluriwave/pantallas/ajustes/pantalla_ajustes_musica_local.dart';
|
||||
import 'package:pluriwave/widgets/pluri_push_scaffold.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
/// WU3b task 3b.1: the GRABACIONES Y MÚSICA detail screen for "Música local"
|
||||
/// renders inside a [PluriPushScaffold] and its moved controls (the
|
||||
/// android-auto-local-music-paging Phase 7 friendly-folder-name projection)
|
||||
/// still respond exactly as they did inside the old `_SeccionMusicaLocal`.
|
||||
///
|
||||
/// The two Phase 7 scenarios below are relocated verbatim from
|
||||
/// `pantalla_ajustes_test.dart`'s "_SeccionMusicaLocal — friendly folder
|
||||
/// name (Phase 7)" group, now targeting the isolated screen directly instead
|
||||
/// of scrolling to find it inside the whole Settings root.
|
||||
void main() {
|
||||
Widget buildScreen() {
|
||||
return const MaterialApp(
|
||||
locale: Locale('en'),
|
||||
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||
supportedLocales: AppLocalizations.supportedLocales,
|
||||
home: PantallaAjustesMusicaLocal(),
|
||||
);
|
||||
}
|
||||
|
||||
testWidgets('renders inside a PluriPushScaffold titled "Local music (Android '
|
||||
'Auto)"', (tester) async {
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
|
||||
await tester.pumpWidget(buildScreen());
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 100));
|
||||
|
||||
expect(find.byType(PluriPushScaffold), findsOneWidget);
|
||||
expect(find.text('Local music (Android Auto)'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets(
|
||||
'7.1-A: carpeta configurada muestra el nombre amigable derivado de la '
|
||||
'URI, nunca la URI cruda',
|
||||
(tester) async {
|
||||
SharedPreferences.setMockInitialValues({
|
||||
'musica_local_uri':
|
||||
'content://com.android.externalstorage.documents/tree/'
|
||||
'primary%3AMusic%2FMyFolder',
|
||||
});
|
||||
|
||||
await tester.pumpWidget(buildScreen());
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 100));
|
||||
|
||||
expect(find.text('MyFolder'), findsOneWidget);
|
||||
expect(find.textContaining('content://'), findsNothing);
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets('7.1-B: sin carpeta configurada mantiene el mensaje '
|
||||
'localMusicFolderNotConfigured', (tester) async {
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
|
||||
await tester.pumpWidget(buildScreen());
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 100));
|
||||
|
||||
expect(find.text('No folder selected'), findsOneWidget);
|
||||
});
|
||||
}
|
||||
@@ -88,15 +88,12 @@ void main() {
|
||||
|
||||
// ── WU3a: AUDIO + EMISORAS become grouped nav rows ─────────────────────────
|
||||
//
|
||||
// Design ADR-3: the root now carries zero inline controls for the 7
|
||||
// sections WU3a moved (Ecualizador, Salida de audio, Temporizador de sueño,
|
||||
// Grupos de favoritos, Emisora preferida, Emisoras personalizadas, Orden de
|
||||
// listas) — each is reached through a FilaAjuste row instead. The other 5
|
||||
// sections (Grabaciones, Música local, Idioma, Backup, Info) still render
|
||||
// inline here: WU3b decomposes GRABACIONES Y MÚSICA / APLICACIÓN the same
|
||||
// way, so the root is not yet under 400 lines nor fully "zero inline
|
||||
// controls" — that end state is WU3b's completion, not WU3a's (see the
|
||||
// apply-progress note on this discrepancy in tasks.md 3a.1/3a.8).
|
||||
// Design ADR-3: the root carries zero inline controls for the 7 sections
|
||||
// WU3a moved (Ecualizador, Salida de audio, Temporizador de sueño, Grupos
|
||||
// de favoritos, Emisora preferida, Emisoras personalizadas, Orden de
|
||||
// listas) — each is reached through a FilaAjuste row instead. WU3b (below)
|
||||
// completes the remaining 5 sections, so the root is now exactly 4
|
||||
// GrupoAjustes cards, under 400 lines.
|
||||
group('WU3a — AUDIO and EMISORAS groups', () {
|
||||
testWidgets('AUDIO group renders exactly 3 nav rows, no inline controls', (
|
||||
tester,
|
||||
@@ -190,9 +187,66 @@ void main() {
|
||||
});
|
||||
});
|
||||
|
||||
// ── Sections not yet converted (WU3b's job) stay reachable ─────────────────
|
||||
group('Sections pending WU3b remain inline and reachable', () {
|
||||
testWidgets('Grabaciones, Idioma, Backup and Info still render', (
|
||||
// ── WU3b: GRABACIONES Y MÚSICA + APLICACIÓN become grouped nav rows ───────
|
||||
//
|
||||
// Design ADR-3: the root now carries zero inline controls for the final 5
|
||||
// sections (Grabaciones, Música local, Idioma, Backup, Info) — each is
|
||||
// reached through a FilaAjuste row, matching WU3a's AUDIO/EMISORAS
|
||||
// treatment. All 12 sections are decomposed now; the root is exactly 4
|
||||
// GrupoAjustes cards and crosses under 400 lines (tasks.md 3b.5). The two
|
||||
// "Phase 7" friendly-folder-name scenarios that used to live in this file
|
||||
// are relocated verbatim to `ajustes/pantalla_ajustes_musica_local_test.dart`,
|
||||
// now targeting the isolated pushed screen directly.
|
||||
group('WU3b — GRABACIONES Y MÚSICA and APLICACIÓN groups', () {
|
||||
testWidgets(
|
||||
'RECORDINGS & MUSIC group renders exactly 2 nav rows, no inline '
|
||||
'controls',
|
||||
(tester) async {
|
||||
setLargeSurface(tester);
|
||||
_suppressListTileInkAssertion();
|
||||
final estado = await crearEstado();
|
||||
addTearDown(estado.dispose);
|
||||
|
||||
await tester.pumpWidget(buildAjustes(estado));
|
||||
await pumpStable(tester);
|
||||
|
||||
expect(find.text('RECORDINGS & MUSIC'), findsOneWidget);
|
||||
expect(find.text('Recordings'), findsOneWidget);
|
||||
expect(find.text('Local music (Android Auto)'), findsOneWidget);
|
||||
|
||||
// Zero inline controls: the folder-path row, path action buttons and
|
||||
// max-size row are gone from the root now.
|
||||
expect(find.text('Change path'), findsNothing);
|
||||
expect(find.text('Maximum recording size'), findsNothing);
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets(
|
||||
'APPLICATION group renders exactly 3 nav rows, no inline controls',
|
||||
(tester) async {
|
||||
setLargeSurface(tester);
|
||||
_suppressListTileInkAssertion();
|
||||
final estado = await crearEstado();
|
||||
addTearDown(estado.dispose);
|
||||
|
||||
await tester.pumpWidget(buildAjustes(estado));
|
||||
await pumpStable(tester);
|
||||
|
||||
expect(find.text('APPLICATION'), findsOneWidget);
|
||||
// "Language" now renders exactly once at the root (the FilaAjuste
|
||||
// row only) — the old inline dropdown's duplicate label is gone.
|
||||
expect(find.text('Language'), findsOneWidget);
|
||||
expect(find.text('Backup'), findsOneWidget);
|
||||
expect(find.text('Info'), findsOneWidget);
|
||||
|
||||
// Zero inline controls: the language dropdown and export/import rows
|
||||
// are gone from the root now.
|
||||
expect(find.byType(DropdownButtonFormField<String>), findsNothing);
|
||||
expect(find.text('Export configuration'), findsNothing);
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets('tapping the Grabaciones row pushes its detail screen', (
|
||||
tester,
|
||||
) async {
|
||||
setLargeSurface(tester);
|
||||
@@ -203,50 +257,16 @@ void main() {
|
||||
await tester.pumpWidget(buildAjustes(estado));
|
||||
await pumpStable(tester);
|
||||
|
||||
expect(find.text('Recordings'), findsOneWidget);
|
||||
// "Language" legitimately renders twice (pre-existing, unmodified by
|
||||
// WU3a): the section header AND the dropdown's own label share the
|
||||
// same l10n string.
|
||||
expect(find.text('Language'), findsWidgets);
|
||||
expect(find.text('Backup'), findsOneWidget);
|
||||
expect(find.text('Help and tutorial'), findsOneWidget);
|
||||
await tester.tap(find.text('Recordings'));
|
||||
await pumpStable(tester);
|
||||
|
||||
expect(find.byType(PluriPushScaffold), findsOneWidget);
|
||||
expect(find.text('Change path'), findsOneWidget);
|
||||
});
|
||||
});
|
||||
|
||||
// ── android-auto-local-music-paging Phase 7: friendly folder name ────────
|
||||
group('_SeccionMusicaLocal — friendly folder name (Phase 7)', () {
|
||||
testWidgets(
|
||||
'7.1-A: carpeta configurada muestra el nombre amigable derivado de '
|
||||
'la URI, nunca la URI cruda',
|
||||
(tester) async {
|
||||
SharedPreferences.setMockInitialValues({
|
||||
'musica_local_uri':
|
||||
'content://com.android.externalstorage.documents/tree/'
|
||||
'primary%3AMusic%2FMyFolder',
|
||||
});
|
||||
setLargeSurface(tester);
|
||||
_suppressListTileInkAssertion();
|
||||
final estado = await crearEstado();
|
||||
addTearDown(estado.dispose);
|
||||
|
||||
await tester.pumpWidget(buildAjustes(estado));
|
||||
await pumpStable(tester);
|
||||
|
||||
await tester.scrollUntilVisible(
|
||||
find.text('Local music folder'),
|
||||
300,
|
||||
scrollable: find.byType(Scrollable).first,
|
||||
);
|
||||
await pumpStable(tester);
|
||||
|
||||
expect(find.text('MyFolder'), findsOneWidget);
|
||||
expect(find.textContaining('content://'), findsNothing);
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets('7.1-B: sin carpeta configurada mantiene el mensaje '
|
||||
'localMusicFolderNotConfigured', (tester) async {
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
testWidgets('tapping the Info row pushes its detail screen', (
|
||||
tester,
|
||||
) async {
|
||||
setLargeSurface(tester);
|
||||
_suppressListTileInkAssertion();
|
||||
final estado = await crearEstado();
|
||||
@@ -255,14 +275,28 @@ void main() {
|
||||
await tester.pumpWidget(buildAjustes(estado));
|
||||
await pumpStable(tester);
|
||||
|
||||
await tester.scrollUntilVisible(
|
||||
find.text('Local music folder'),
|
||||
300,
|
||||
scrollable: find.byType(Scrollable).first,
|
||||
);
|
||||
await tester.tap(find.text('Info'));
|
||||
await pumpStable(tester);
|
||||
|
||||
expect(find.text('No folder selected'), findsOneWidget);
|
||||
expect(find.byType(PluriPushScaffold), findsOneWidget);
|
||||
expect(find.text('Help and tutorial'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('root now contains exactly 4 GrupoAjustes cards', (
|
||||
tester,
|
||||
) async {
|
||||
setLargeSurface(tester);
|
||||
_suppressListTileInkAssertion();
|
||||
final estado = await crearEstado();
|
||||
addTearDown(estado.dispose);
|
||||
|
||||
await tester.pumpWidget(buildAjustes(estado));
|
||||
await pumpStable(tester);
|
||||
|
||||
expect(find.text('AUDIO'), findsOneWidget);
|
||||
expect(find.text('STATIONS'), findsOneWidget);
|
||||
expect(find.text('RECORDINGS & MUSIC'), findsOneWidget);
|
||||
expect(find.text('APPLICATION'), findsOneWidget);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user