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

This commit is contained in:
2026-07-28 22:14:03 +02:00
parent 48ece948ff
commit c1903623be
29 changed files with 1538 additions and 735 deletions
+3
View File
@@ -55,6 +55,9 @@
"settingsSubtitle": "Fine-grained sound control, backups, and custom stations.",
"settingsGroupAudioTitle": "AUDIO",
"settingsGroupStationsTitle": "STATIONS",
"settingsGroupRecordingsTitle": "RECORDINGS & MUSIC",
"settingsGroupApplicationTitle": "APPLICATION",
"infoSectionTitle": "Info",
"languageSectionTitle": "Language",
"languageSectionDescription": "Choose how the app language is displayed.",
"languageSystemDefault": "System",
+3
View File
@@ -55,6 +55,9 @@
"settingsSubtitle": "Control fino de sonido, copias de seguridad y emisoras personalizadas.",
"settingsGroupAudioTitle": "AUDIO",
"settingsGroupStationsTitle": "EMISORAS",
"settingsGroupRecordingsTitle": "GRABACIONES Y MÚSICA",
"settingsGroupApplicationTitle": "APLICACIÓN",
"infoSectionTitle": "Información",
"languageSectionTitle": "Idioma",
"languageSectionDescription": "Elegí cómo se muestra el idioma de la app.",
"languageSystemDefault": "Sistema",
+18
View File
@@ -286,6 +286,24 @@ abstract class AppLocalizations {
/// **'EMISORAS'**
String get settingsGroupStationsTitle;
/// No description provided for @settingsGroupRecordingsTitle.
///
/// In es, this message translates to:
/// **'GRABACIONES Y MÚSICA'**
String get settingsGroupRecordingsTitle;
/// No description provided for @settingsGroupApplicationTitle.
///
/// In es, this message translates to:
/// **'APLICACIÓN'**
String get settingsGroupApplicationTitle;
/// No description provided for @infoSectionTitle.
///
/// In es, this message translates to:
/// **'Información'**
String get infoSectionTitle;
/// No description provided for @languageSectionTitle.
///
/// In es, this message translates to:
+9
View File
@@ -105,6 +105,15 @@ class AppLocalizationsAr extends AppLocalizations {
@override
String get settingsGroupStationsTitle => 'EMISORAS';
@override
String get settingsGroupRecordingsTitle => 'GRABACIONES Y MÚSICA';
@override
String get settingsGroupApplicationTitle => 'APLICACIÓN';
@override
String get infoSectionTitle => 'Información';
@override
String get languageSectionTitle => 'اللغة';
+9
View File
@@ -106,6 +106,15 @@ class AppLocalizationsBn extends AppLocalizations {
@override
String get settingsGroupStationsTitle => 'EMISORAS';
@override
String get settingsGroupRecordingsTitle => 'GRABACIONES Y MÚSICA';
@override
String get settingsGroupApplicationTitle => 'APLICACIÓN';
@override
String get infoSectionTitle => 'Información';
@override
String get languageSectionTitle => 'ভাষা';
+9
View File
@@ -105,6 +105,15 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get settingsGroupStationsTitle => 'EMISORAS';
@override
String get settingsGroupRecordingsTitle => 'GRABACIONES Y MÚSICA';
@override
String get settingsGroupApplicationTitle => 'APLICACIÓN';
@override
String get infoSectionTitle => 'Información';
@override
String get languageSectionTitle => 'Sprache';
+9
View File
@@ -105,6 +105,15 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get settingsGroupStationsTitle => 'STATIONS';
@override
String get settingsGroupRecordingsTitle => 'RECORDINGS & MUSIC';
@override
String get settingsGroupApplicationTitle => 'APPLICATION';
@override
String get infoSectionTitle => 'Info';
@override
String get languageSectionTitle => 'Language';
+9
View File
@@ -105,6 +105,15 @@ class AppLocalizationsEs extends AppLocalizations {
@override
String get settingsGroupStationsTitle => 'EMISORAS';
@override
String get settingsGroupRecordingsTitle => 'GRABACIONES Y MÚSICA';
@override
String get settingsGroupApplicationTitle => 'APLICACIÓN';
@override
String get infoSectionTitle => 'Información';
@override
String get languageSectionTitle => 'Idioma';
+9
View File
@@ -106,6 +106,15 @@ class AppLocalizationsFr extends AppLocalizations {
@override
String get settingsGroupStationsTitle => 'EMISORAS';
@override
String get settingsGroupRecordingsTitle => 'GRABACIONES Y MÚSICA';
@override
String get settingsGroupApplicationTitle => 'APLICACIÓN';
@override
String get infoSectionTitle => 'Información';
@override
String get languageSectionTitle => 'Langue';
+9
View File
@@ -105,6 +105,15 @@ class AppLocalizationsHi extends AppLocalizations {
@override
String get settingsGroupStationsTitle => 'EMISORAS';
@override
String get settingsGroupRecordingsTitle => 'GRABACIONES Y MÚSICA';
@override
String get settingsGroupApplicationTitle => 'APLICACIÓN';
@override
String get infoSectionTitle => 'Información';
@override
String get languageSectionTitle => 'भाषा';
+9
View File
@@ -106,6 +106,15 @@ class AppLocalizationsId extends AppLocalizations {
@override
String get settingsGroupStationsTitle => 'EMISORAS';
@override
String get settingsGroupRecordingsTitle => 'GRABACIONES Y MÚSICA';
@override
String get settingsGroupApplicationTitle => 'APLICACIÓN';
@override
String get infoSectionTitle => 'Información';
@override
String get languageSectionTitle => 'Bahasa';
+9
View File
@@ -105,6 +105,15 @@ class AppLocalizationsIt extends AppLocalizations {
@override
String get settingsGroupStationsTitle => 'EMISORAS';
@override
String get settingsGroupRecordingsTitle => 'GRABACIONES Y MÚSICA';
@override
String get settingsGroupApplicationTitle => 'APLICACIÓN';
@override
String get infoSectionTitle => 'Información';
@override
String get languageSectionTitle => 'Lingua';
+9
View File
@@ -103,6 +103,15 @@ class AppLocalizationsJa extends AppLocalizations {
@override
String get settingsGroupStationsTitle => 'EMISORAS';
@override
String get settingsGroupRecordingsTitle => 'GRABACIONES Y MÚSICA';
@override
String get settingsGroupApplicationTitle => 'APLICACIÓN';
@override
String get infoSectionTitle => 'Información';
@override
String get languageSectionTitle => '言語';
+9
View File
@@ -105,6 +105,15 @@ class AppLocalizationsPt extends AppLocalizations {
@override
String get settingsGroupStationsTitle => 'EMISORAS';
@override
String get settingsGroupRecordingsTitle => 'GRABACIONES Y MÚSICA';
@override
String get settingsGroupApplicationTitle => 'APLICACIÓN';
@override
String get infoSectionTitle => 'Información';
@override
String get languageSectionTitle => 'Idioma';
+9
View File
@@ -105,6 +105,15 @@ class AppLocalizationsRu extends AppLocalizations {
@override
String get settingsGroupStationsTitle => 'EMISORAS';
@override
String get settingsGroupRecordingsTitle => 'GRABACIONES Y MÚSICA';
@override
String get settingsGroupApplicationTitle => 'APLICACIÓN';
@override
String get infoSectionTitle => 'Información';
@override
String get languageSectionTitle => 'Язык';
+9
View File
@@ -103,6 +103,15 @@ class AppLocalizationsZh extends AppLocalizations {
@override
String get settingsGroupStationsTitle => 'EMISORAS';
@override
String get settingsGroupRecordingsTitle => 'GRABACIONES Y MÚSICA';
@override
String get settingsGroupApplicationTitle => 'APLICACIÓN';
@override
String get infoSectionTitle => 'Información';
@override
String get languageSectionTitle => '语言';
@@ -0,0 +1,145 @@
import 'dart:io';
import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart';
import 'package:path_provider/path_provider.dart';
import 'package:provider/provider.dart';
import 'package:share_plus/share_plus.dart' show Share, XFile;
import '../../estado/estado_radio.dart';
import '../../l10n/gen/app_localizations.dart';
import '../../widgets/pluri_glass_surface.dart';
import '../../widgets/pluri_layout.dart';
import '../../widgets/pluri_push_scaffold.dart';
/// APLICACIÓN group · "Copia de seguridad" (design ADR-3). Body moved
/// verbatim from the former `_SeccionBackup` in `pantalla_ajustes.dart` —
/// only the panel header's icon and title were removed (the pushed screen's
/// title now carries them); every method below is unchanged.
class PantallaAjustesBackup extends StatelessWidget {
const PantallaAjustesBackup({super.key});
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
return PluriPushScaffold(
title: l10n.backupSectionTitle,
body: ListView(
padding: PluriLayout.pageContentPadding,
children: const [_CuerpoBackup()],
),
);
}
}
class _CuerpoBackup extends StatelessWidget {
const _CuerpoBackup();
Future<void> _exportar(BuildContext context) async {
final l10n = AppLocalizations.of(context);
try {
final estado = context.read<EstadoRadio>();
// JSON serialization is owned by ServicioExportImport (S4-R4).
final json = await estado.exportarConfigJson();
final dir = await getTemporaryDirectory();
final file = File('${dir.path}/pluriwave-backup.json');
await file.writeAsString(json);
await Share.shareXFiles(
[XFile(file.path)],
subject: l10n.backupShareSubject,
text: l10n.backupShareText(DateTime.now().toLocal()),
);
} catch (e) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(l10n.backupExportError(e.toString()))),
);
}
}
}
Future<void> _importar(BuildContext context) async {
final l10n = AppLocalizations.of(context);
try {
final result = await FilePicker.platform.pickFiles(
type: FileType.custom,
allowedExtensions: ['json'],
);
if (result == null || result.files.single.path == null) return;
final file = File(result.files.single.path!);
final contenido = await file.readAsString();
if (!context.mounted) return;
// Parsing is owned by ServicioExportImport (S4-R4): null = malformed.
final json = context.read<EstadoRadio>().parsearConfigJson(contenido);
if (json == null) {
throw const FormatException('invalid backup file');
}
if (context.mounted) {
final confirmar = await showDialog<bool>(
context: context,
builder:
(ctx) => AlertDialog(
title: Text(AppLocalizations.of(ctx).backupImportTitle),
content: Text(
AppLocalizations.of(ctx).backupImportConfirmMessage,
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: Text(AppLocalizations.of(ctx).cancelAction),
),
FilledButton(
onPressed: () => Navigator.pop(ctx, true),
child: Text(AppLocalizations.of(ctx).backupImportTitle),
),
],
),
);
if (confirmar != true) return;
if (context.mounted) {
final estado = context.read<EstadoRadio>();
final messenger = ScaffoldMessenger.of(context);
await estado.importarConfig(json);
messenger.showSnackBar(
SnackBar(content: Text(l10n.backupImportSuccess)),
);
}
}
} catch (e) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(l10n.backupImportError(e.toString()))),
);
}
}
}
@override
Widget build(BuildContext context) {
return PluriGlassSurface(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ListTile(
contentPadding: EdgeInsets.zero,
leading: const Icon(Icons.upload_outlined),
title: Text(AppLocalizations.of(context).backupExportTitle),
subtitle: Text(AppLocalizations.of(context).backupExportSubtitle),
onTap: () => _exportar(context),
),
ListTile(
contentPadding: EdgeInsets.zero,
leading: const Icon(Icons.download_outlined),
title: Text(AppLocalizations.of(context).backupImportTitle),
subtitle: Text(AppLocalizations.of(context).backupImportSubtitle),
onTap: () => _importar(context),
),
],
),
);
}
}
@@ -0,0 +1,217 @@
import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../estado/estado_grabacion.dart';
import '../../l10n/gen/app_localizations.dart';
import '../../widgets/pluri_glass_surface.dart';
import '../../widgets/pluri_layout.dart';
import '../../widgets/pluri_push_scaffold.dart';
/// GRABACIONES Y MÚSICA group · "Grabaciones" (design ADR-3). Body moved
/// verbatim from the former `_SeccionGrabaciones` in `pantalla_ajustes.dart`
/// — only the panel header's icon and title were removed (the pushed
/// screen's title now carries them); every method below is unchanged.
///
/// Known pre-existing bug, deliberately NOT fixed here (out of scope, moved
/// verbatim, tracked separately): [_editarTamanoMaximo] disposes its
/// [TextEditingController] immediately after `showModalBottomSheet` resolves,
/// racing the sheet's own close animation — the same shape of bug already
/// documented for `_editarGrupo` in `pantalla_ajustes_grupos_favoritos.dart`.
class PantallaAjustesGrabaciones extends StatelessWidget {
const PantallaAjustesGrabaciones({super.key});
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
return PluriPushScaffold(
title: l10n.recordingsSectionTitle,
body: ListView(
padding: PluriLayout.pageContentPadding,
children: const [_CuerpoGrabaciones()],
),
);
}
}
class _CuerpoGrabaciones extends StatelessWidget {
const _CuerpoGrabaciones();
Future<void> _seleccionarRuta(BuildContext context) async {
final estado = context.read<EstadoGrabacion>();
final messenger = ScaffoldMessenger.of(context);
final l10n = AppLocalizations.of(context);
final ruta = await FilePicker.platform.getDirectoryPath(
dialogTitle: l10n.recordingsFolderDialogTitle,
);
if (ruta == null) return;
try {
await estado.cambiarDirectorio(ruta);
if (!context.mounted) return;
messenger.showSnackBar(
SnackBar(content: Text(l10n.recordingsPathUpdated)),
);
} catch (e) {
if (!context.mounted) return;
messenger.showSnackBar(
SnackBar(content: Text(l10n.recordingsPathSaveError(e.toString()))),
);
}
}
Future<void> _restaurarRuta(BuildContext context) async {
final estado = context.read<EstadoGrabacion>();
final messenger = ScaffoldMessenger.of(context);
final l10n = AppLocalizations.of(context);
await estado.restaurarDirectorio();
if (!context.mounted) return;
messenger.showSnackBar(
SnackBar(content: Text(l10n.recordingsDefaultFolderRestored)),
);
}
Future<void> _abrirCarpeta(BuildContext context) async {
final estado = context.read<EstadoGrabacion>();
final messenger = ScaffoldMessenger.of(context);
final l10n = AppLocalizations.of(context);
try {
final abierto = await estado.abrirDirectorio();
if (!context.mounted) return;
if (!abierto) {
messenger.showSnackBar(
SnackBar(content: Text(l10n.recordingsOpenFolderError(l10n.dash))),
);
}
} catch (e) {
if (!context.mounted) return;
messenger.showSnackBar(
SnackBar(content: Text(l10n.recordingsOpenFolderError(e.toString()))),
);
}
}
Future<void> _editarTamanoMaximo(BuildContext context) async {
final estado = context.read<EstadoGrabacion>();
final l10n = AppLocalizations.of(context);
final actualMb = _bytesAMegabytes(estado.maxBytes);
final controller = TextEditingController(text: actualMb.toString());
final nuevoMb = await showModalBottomSheet<int>(
context: context,
isScrollControlled: true,
showDragHandle: true,
builder: (ctx) {
final bottom = MediaQuery.viewInsetsOf(ctx).bottom;
return Padding(
padding: EdgeInsets.fromLTRB(20, 0, 20, bottom + 24),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
l10n.recordingsMaxSizeDialogTitle,
style: Theme.of(ctx).textTheme.titleLarge,
),
const SizedBox(height: 16),
TextField(
controller: controller,
autofocus: true,
keyboardType: TextInputType.number,
decoration: InputDecoration(
labelText: l10n.recordingsMaxSizeMbLabel,
border: const OutlineInputBorder(),
),
),
const SizedBox(height: 16),
FilledButton.icon(
onPressed: () {
final value = int.tryParse(controller.text.trim());
if (value == null || value <= 0) return;
Navigator.of(ctx).pop(value);
},
icon: const Icon(Icons.save_rounded),
label: Text(l10n.saveQuickAccessButton),
),
],
),
);
},
);
controller.dispose();
if (nuevoMb == null || !context.mounted) return;
await estado.cambiarMaxBytes(nuevoMb * 1024 * 1024);
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(l10n.recordingsMaxSizeSaved(nuevoMb))),
);
}
int _bytesAMegabytes(int bytes) =>
(bytes / (1024 * 1024)).round().clamp(1, 1048576);
@override
Widget build(BuildContext context) {
// Recording state lives in EstadoGrabacion (S4-R2): this section only
// rebuilds on recording changes, never on playback notifications.
final estado = context.watch<EstadoGrabacion>();
final l10n = AppLocalizations.of(context);
return PluriGlassSurface(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
FutureBuilder<String>(
future: estado.directorioEfectivo(),
builder:
(ctx, snap) => ListTile(
contentPadding: EdgeInsets.zero,
leading: const Icon(Icons.folder_outlined),
title: Text(l10n.recordingsFolderTitle),
subtitle: Text(
snap.data ?? l10n.recordingsPathCalculating,
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
),
),
Wrap(
spacing: 8,
runSpacing: 8,
children: [
OutlinedButton.icon(
icon: const Icon(Icons.folder_open_rounded),
label: Text(l10n.recordingsChangePath),
onPressed: () => _seleccionarRuta(context),
),
FilledButton.tonalIcon(
icon: const Icon(Icons.folder_copy_rounded),
label: Text(l10n.recordingsOpenFolder),
onPressed: () => _abrirCarpeta(context),
),
IconButton.filledTonal(
tooltip: l10n.recordingsUseDefaultPath,
icon: const Icon(Icons.restore_rounded),
onPressed: () => _restaurarRuta(context),
),
],
),
const SizedBox(height: 8),
ListTile(
contentPadding: EdgeInsets.zero,
leading: const Icon(Icons.sd_storage_rounded),
title: Text(l10n.recordingsMaxSizeTitle),
subtitle: Text(
l10n.recordingsMaxSizeSubtitle(_bytesAMegabytes(estado.maxBytes)),
),
onTap: () => _editarTamanoMaximo(context),
),
const SizedBox(height: 8),
Text(
l10n.recordingsOriginalStreamHint,
style: Theme.of(context).textTheme.bodySmall,
),
],
),
);
}
}
@@ -0,0 +1,131 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../estado/estado_idioma.dart';
import '../../l10n/gen/app_localizations.dart';
import '../../widgets/pluri_glass_surface.dart';
import '../../widgets/pluri_layout.dart';
import '../../widgets/pluri_push_scaffold.dart';
/// APLICACIÓN group · "Idioma" (design ADR-3). Body moved verbatim from the
/// former `_SeccionIdioma` (+ `_IdiomaDisponible`) in `pantalla_ajustes.dart`
/// — only the panel header's icon and title were removed (the pushed
/// screen's title now carries them); every method and the language list
/// below is unchanged.
class PantallaAjustesIdioma extends StatelessWidget {
const PantallaAjustesIdioma({super.key});
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
return PluriPushScaffold(
title: l10n.languageSectionTitle,
body: ListView(
padding: PluriLayout.pageContentPadding,
children: const [_CuerpoIdioma()],
),
);
}
}
class _CuerpoIdioma extends StatelessWidget {
const _CuerpoIdioma();
static const _codigoSistema = 'system';
static const _idiomas = [
_IdiomaDisponible(Locale('en'), 'English'),
_IdiomaDisponible(Locale('es'), 'Español'),
_IdiomaDisponible(Locale('zh'), '中文'),
_IdiomaDisponible(Locale('hi'), 'हिन्दी'),
_IdiomaDisponible(Locale('ar'), 'العربية'),
_IdiomaDisponible(Locale('pt'), 'Português'),
_IdiomaDisponible(Locale('fr'), 'Français'),
_IdiomaDisponible(Locale('ru'), 'Русский'),
_IdiomaDisponible(Locale('de'), 'Deutsch'),
_IdiomaDisponible(Locale('ja'), '日本語'),
_IdiomaDisponible(Locale('id'), 'Bahasa Indonesia'),
_IdiomaDisponible(Locale('bn'), 'বাংলা'),
_IdiomaDisponible(Locale('it'), 'Italiano'),
];
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final estadoIdioma = context.watch<EstadoIdioma>();
final locale = estadoIdioma.localeSeleccionado;
final valorActual = locale == null ? _codigoSistema : _codigoLocale(locale);
return PluriGlassSurface(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(height: 8),
Text(
l10n.languageSectionDescription,
style: Theme.of(context).textTheme.bodySmall,
),
const SizedBox(height: 12),
DropdownButtonFormField<String>(
initialValue: valorActual,
decoration: InputDecoration(
labelText: l10n.languageSectionTitle,
border: const OutlineInputBorder(),
),
items: [
DropdownMenuItem(
value: _codigoSistema,
child: Text(l10n.languageSystemDefault),
),
for (final idioma in _idiomas)
DropdownMenuItem(
value: _codigoLocale(idioma.locale),
child: Text(idioma.nombreNativo),
),
],
onChanged: (codigo) async {
if (codigo == null) return;
if (codigo == _codigoSistema) {
await context.read<EstadoIdioma>().seleccionarSistema();
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(l10n.languageUpdatedSystem)),
);
return;
}
final idioma = _idiomas.firstWhere(
(item) => _codigoLocale(item.locale) == codigo,
orElse: () => _idiomas.first,
);
await context.read<EstadoIdioma>().seleccionarLocale(
idioma.locale,
);
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(l10n.languageUpdated(idioma.nombreNativo)),
),
);
},
),
],
),
);
}
static String _codigoLocale(Locale locale) {
final countryCode = locale.countryCode;
if (countryCode == null || countryCode.isEmpty) {
return locale.languageCode;
}
return '${locale.languageCode}_$countryCode';
}
}
class _IdiomaDisponible {
const _IdiomaDisponible(this.locale, this.nombreNativo);
final Locale locale;
final String nombreNativo;
}
@@ -0,0 +1,120 @@
import 'package:flutter/material.dart';
import 'package:package_info_plus/package_info_plus.dart';
import 'package:provider/provider.dart';
import '../../estado/estado_radio.dart';
import '../../l10n/gen/app_localizations.dart';
import '../../widgets/pluri_glass_surface.dart';
import '../../widgets/pluri_icon.dart';
import '../../widgets/pluri_layout.dart';
import '../../widgets/pluri_onboarding_dialog.dart';
import '../../widgets/pluri_push_scaffold.dart';
/// APLICACIÓN group · "Info" (design ADR-3). Body moved verbatim from the
/// former `_SeccionInfo` in `pantalla_ajustes.dart`. Unlike the other four
/// sections in this batch, `_SeccionInfo` never had its own header
/// icon+title row — its first tile (app name + version) already served that
/// role — so there is no header row to strip here; the body below is
/// unchanged in full. `infoSectionTitle` is the one new ARB key this screen
/// needed, since no existing in-body header string covers a bare "Info"
/// label (see WU3b's apply-progress note).
class PantallaAjustesInfo extends StatelessWidget {
const PantallaAjustesInfo({super.key});
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
return PluriPushScaffold(
title: l10n.infoSectionTitle,
body: ListView(
padding: PluriLayout.pageContentPadding,
children: const [_CuerpoInfo()],
),
);
}
}
class _CuerpoInfo extends StatelessWidget {
const _CuerpoInfo();
@override
Widget build(BuildContext context) {
return Consumer<EstadoRadio>(
builder:
(ctx, estado, _) => PluriGlassSurface(
child: Column(
children: [
FutureBuilder<PackageInfo>(
future: PackageInfo.fromPlatform(),
builder: (ctx, snap) {
final version =
snap.hasData
? 'v${snap.data!.version}+${snap.data!.buildNumber}'
: AppLocalizations.of(ctx).appVersionLoading;
return ListTile(
contentPadding: EdgeInsets.zero,
leading: const PluriIcon(
glyph: PluriIconGlyph.settings,
variant: PluriIconVariant.filled,
),
title: Text(AppLocalizations.of(ctx).appTitle),
subtitle: Text(
AppLocalizations.of(ctx).appVersionSubtitle(version),
),
);
},
),
FutureBuilder<int>(
future: estado.favoritos.obtenerTodos().then((l) => l.length),
builder:
(ctx, snap) => ListTile(
contentPadding: EdgeInsets.zero,
leading: const Icon(Icons.favorite_outline_rounded),
title: Text(
AppLocalizations.of(ctx).savedFavoritesTitle,
),
trailing: Text(
snap.data?.toString() ??
AppLocalizations.of(ctx).dash,
style: Theme.of(ctx).textTheme.bodyLarge,
),
),
),
ListTile(
contentPadding: EdgeInsets.zero,
leading: const Icon(Icons.help_outline_rounded),
title: Text(AppLocalizations.of(ctx).helpTitle),
subtitle: Text(AppLocalizations.of(ctx).helpSubtitle),
trailing: const Icon(Icons.chevron_right_rounded),
onTap: () => PluriOnboardingDialog.mostrar(ctx),
),
ListTile(
contentPadding: EdgeInsets.zero,
leading: const Icon(Icons.verified_outlined),
title: Text(AppLocalizations.of(ctx).stationFilterTitle),
subtitle: Text(
AppLocalizations.of(ctx).stationFilterSubtitle,
),
trailing: Icon(
Icons.check_circle_rounded,
color: Theme.of(ctx).colorScheme.secondary,
),
),
ListTile(
contentPadding: EdgeInsets.zero,
leading: const Icon(Icons.music_note_outlined),
title: Text(AppLocalizations.of(ctx).backgroundAudioTitle),
subtitle: Text(
AppLocalizations.of(ctx).backgroundAudioSubtitle,
),
trailing: Icon(
Icons.check_circle_rounded,
color: Theme.of(ctx).colorScheme.secondary,
),
),
],
),
),
);
}
}
@@ -0,0 +1,129 @@
import 'package:flutter/material.dart';
import '../../l10n/gen/app_localizations.dart';
import '../../servicios/musica_local_auto.dart';
import '../../widgets/pluri_glass_surface.dart';
import '../../widgets/pluri_layout.dart';
import '../../widgets/pluri_push_scaffold.dart';
/// GRABACIONES Y MÚSICA group · "Música local" (design ADR-3). Body moved
/// verbatim from the former `_SeccionMusicaLocal` in `pantalla_ajustes.dart`
/// — only the panel header's icon and title were removed (the pushed
/// screen's title now carries them). Deliberately does NOT use
/// `FilePicker.platform` (see tasks.md "Grounding corrections") —
/// [FuenteMusicaLocalAutoImpl.elegirCarpeta] calls the native
/// `pickMusicFolder` channel method directly, since it needs a
/// persistable-grant SAF tree URI, not a plain filesystem path.
class PantallaAjustesMusicaLocal extends StatelessWidget {
const PantallaAjustesMusicaLocal({super.key});
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
return PluriPushScaffold(
title: l10n.localMusicSectionTitle,
body: ListView(
padding: PluriLayout.pageContentPadding,
children: const [_CuerpoMusicaLocal()],
),
);
}
}
class _CuerpoMusicaLocal extends StatefulWidget {
const _CuerpoMusicaLocal();
@override
State<_CuerpoMusicaLocal> createState() => _CuerpoMusicaLocalState();
}
class _CuerpoMusicaLocalState extends State<_CuerpoMusicaLocal> {
final _fuente = FuenteMusicaLocalAutoImpl();
late Future<String?> _carpetaActual;
@override
void initState() {
super.initState();
_carpetaActual = _fuente.carpetaActual();
}
Future<void> _elegirCarpeta(BuildContext context) async {
final messenger = ScaffoldMessenger.of(context);
final l10n = AppLocalizations.of(context);
try {
final uri = await _fuente.elegirCarpeta();
if (uri == null) return; // Cancelled — no snackbar, matches the SAF
// picker's own "nothing changed" affordance.
if (!context.mounted) return;
setState(() {
_carpetaActual = Future.value(uri);
});
messenger.showSnackBar(
SnackBar(content: Text(l10n.localMusicFolderUpdated)),
);
} catch (e) {
if (!context.mounted) return;
messenger.showSnackBar(
SnackBar(content: Text(l10n.localMusicFolderSaveError(e.toString()))),
);
}
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
return PluriGlassSurface(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(height: 8),
Text(
l10n.localMusicSectionDescription,
style: Theme.of(context).textTheme.bodySmall,
),
FutureBuilder<String?>(
future: _carpetaActual,
builder: (ctx, snap) {
final carpeta = snap.data;
return ListTile(
contentPadding: EdgeInsets.zero,
leading: const Icon(Icons.folder_outlined),
title: Text(l10n.localMusicFolderTitle),
subtitle: Text(
(carpeta == null || carpeta.isEmpty)
? l10n.localMusicFolderNotConfigured
: nombreCarpetaDesdeUri(
carpeta,
nombreGenerico: l10n.localMusicFolderGenericName,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
);
},
),
const SizedBox(height: 8),
FutureBuilder<String?>(
future: _carpetaActual,
builder: (ctx, snap) {
final configurada = (snap.data ?? '').isNotEmpty;
return Align(
alignment: Alignment.centerLeft,
child: OutlinedButton.icon(
icon: const Icon(Icons.folder_open_rounded),
label: Text(
configurada
? l10n.localMusicChangePath
: l10n.localMusicChoosePath,
),
onPressed: () => _elegirCarpeta(context),
),
);
},
),
],
),
);
}
}
+53 -643
View File
@@ -1,28 +1,19 @@
import 'dart:async';
import 'dart:io';
import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart';
import 'package:package_info_plus/package_info_plus.dart';
import 'package:path_provider/path_provider.dart';
import 'package:provider/provider.dart';
import 'package:share_plus/share_plus.dart' show Share, XFile;
import '../estado/estado_grabacion.dart';
import '../estado/estado_idioma.dart';
import '../estado/estado_radio.dart';
import '../l10n/gen/app_localizations.dart';
import '../servicios/musica_local_auto.dart';
import '../widgets/pluri_glass_surface.dart';
import '../widgets/pluri_icon.dart';
import '../widgets/pluri_layout.dart';
import '../widgets/pluri_onboarding_dialog.dart';
import '../widgets/pluri_premium_widgets.dart';
import '../widgets/pluri_push_scaffold.dart';
import 'ajustes/pantalla_ajustes_backup.dart';
import 'ajustes/pantalla_ajustes_ecualizador.dart';
import 'ajustes/pantalla_ajustes_emisora_preferida.dart';
import 'ajustes/pantalla_ajustes_emisoras_personalizadas.dart';
import 'ajustes/pantalla_ajustes_grabaciones.dart';
import 'ajustes/pantalla_ajustes_grupos_favoritos.dart';
import 'ajustes/pantalla_ajustes_idioma.dart';
import 'ajustes/pantalla_ajustes_info.dart';
import 'ajustes/pantalla_ajustes_musica_local.dart';
import 'ajustes/pantalla_ajustes_orden_listas.dart';
import 'ajustes/pantalla_ajustes_salida_audio.dart';
import 'ajustes/pantalla_ajustes_timer_sueno.dart';
@@ -56,13 +47,11 @@ class PantallaAjustes extends StatelessWidget {
}
}
/// Design ADR-3: the AUDIO and EMISORAS groups are grouped nav rows only —
/// each [FilaAjuste] pushes its own detail screen via
/// `PluriPushScaffold.push`, carrying zero inline controls in the root.
///
/// The remaining sections (GRABACIONES Y MÚSICA, APLICACIÓN) still render
/// inline here pending WU3b, which decomposes them the same way. Sleep timer
/// and backup/restore stay reachable throughout — nothing is dropped.
/// Design ADR-3: the Settings root is grouped nav rows only — each
/// [FilaAjuste] pushes its own detail screen via `PluriPushScaffold.push`,
/// carrying zero inline controls. All 12 sections are now decomposed across
/// WU3a (AUDIO, EMISORAS) and WU3b (GRABACIONES Y MÚSICA, APLICACIÓN); sleep
/// timer and backup/restore stay reachable throughout — nothing was dropped.
class _AjustesContent extends StatelessWidget {
const _AjustesContent();
@@ -147,642 +136,63 @@ class _AjustesContent extends StatelessWidget {
],
),
const SizedBox(height: 12),
const _SeccionGrabaciones(),
const SizedBox(height: 12),
const _SeccionMusicaLocal(),
const SizedBox(height: 12),
const _SeccionIdioma(),
const SizedBox(height: 12),
const _SeccionBackup(),
const SizedBox(height: 12),
const _SeccionInfo(),
],
);
}
}
class _SeccionGrabaciones extends StatelessWidget {
const _SeccionGrabaciones();
Future<void> _seleccionarRuta(BuildContext context) async {
final estado = context.read<EstadoGrabacion>();
final messenger = ScaffoldMessenger.of(context);
final l10n = AppLocalizations.of(context);
final ruta = await FilePicker.platform.getDirectoryPath(
dialogTitle: l10n.recordingsFolderDialogTitle,
);
if (ruta == null) return;
try {
await estado.cambiarDirectorio(ruta);
if (!context.mounted) return;
messenger.showSnackBar(
SnackBar(content: Text(l10n.recordingsPathUpdated)),
);
} catch (e) {
if (!context.mounted) return;
messenger.showSnackBar(
SnackBar(content: Text(l10n.recordingsPathSaveError(e.toString()))),
);
}
}
Future<void> _restaurarRuta(BuildContext context) async {
final estado = context.read<EstadoGrabacion>();
final messenger = ScaffoldMessenger.of(context);
final l10n = AppLocalizations.of(context);
await estado.restaurarDirectorio();
if (!context.mounted) return;
messenger.showSnackBar(
SnackBar(content: Text(l10n.recordingsDefaultFolderRestored)),
);
}
Future<void> _abrirCarpeta(BuildContext context) async {
final estado = context.read<EstadoGrabacion>();
final messenger = ScaffoldMessenger.of(context);
final l10n = AppLocalizations.of(context);
try {
final abierto = await estado.abrirDirectorio();
if (!context.mounted) return;
if (!abierto) {
messenger.showSnackBar(
SnackBar(content: Text(l10n.recordingsOpenFolderError(l10n.dash))),
);
}
} catch (e) {
if (!context.mounted) return;
messenger.showSnackBar(
SnackBar(content: Text(l10n.recordingsOpenFolderError(e.toString()))),
);
}
}
Future<void> _editarTamanoMaximo(BuildContext context) async {
final estado = context.read<EstadoGrabacion>();
final l10n = AppLocalizations.of(context);
final actualMb = _bytesAMegabytes(estado.maxBytes);
final controller = TextEditingController(text: actualMb.toString());
final nuevoMb = await showModalBottomSheet<int>(
context: context,
isScrollControlled: true,
showDragHandle: true,
builder: (ctx) {
final bottom = MediaQuery.viewInsetsOf(ctx).bottom;
return Padding(
padding: EdgeInsets.fromLTRB(20, 0, 20, bottom + 24),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
l10n.recordingsMaxSizeDialogTitle,
style: Theme.of(ctx).textTheme.titleLarge,
),
const SizedBox(height: 16),
TextField(
controller: controller,
autofocus: true,
keyboardType: TextInputType.number,
decoration: InputDecoration(
labelText: l10n.recordingsMaxSizeMbLabel,
border: const OutlineInputBorder(),
GrupoAjustes(
titulo: l10n.settingsGroupRecordingsTitle,
filas: [
FilaAjuste(
icon: Icons.radio_button_checked_rounded,
titulo: l10n.recordingsSectionTitle,
onTap:
() => PluriPushScaffold.push(
context,
(_) => const PantallaAjustesGrabaciones(),
),
),
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),
FilaAjuste(
icon: Icons.library_music_outlined,
titulo: l10n.localMusicSectionTitle,
onTap:
() => PluriPushScaffold.push(
context,
(_) => const PantallaAjustesMusicaLocal(),
),
),
],
),
);
},
);
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(),
GrupoAjustes(
titulo: l10n.settingsGroupApplicationTitle,
filas: [
FilaAjuste(
icon: Icons.language_rounded,
titulo: l10n.languageSectionTitle,
onTap:
() => PluriPushScaffold.push(
context,
(_) => const PantallaAjustesIdioma(),
),
items: [
DropdownMenuItem(
value: _codigoSistema,
child: Text(l10n.languageSystemDefault),
),
for (final idioma in _idiomas)
DropdownMenuItem(
value: _codigoLocale(idioma.locale),
child: Text(idioma.nombreNativo),
FilaAjuste(
icon: Icons.backup_outlined,
titulo: l10n.backupSectionTitle,
onTap:
() => PluriPushScaffold.push(
context,
(_) => const PantallaAjustesBackup(),
),
],
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)),
),
);
},
FilaAjuste(
icon: Icons.info_outline_rounded,
titulo: l10n.infoSectionTitle,
onTap:
() => PluriPushScaffold.push(
context,
(_) => const PantallaAjustesInfo(),
),
),
],
),
);
}
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,
),
),
],
),
),
);
}
}
+43 -20
View File
@@ -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);
});
}
+95 -61
View File
@@ -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,27 +257,34 @@ 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);
await tester.tap(find.text('Recordings'));
await pumpStable(tester);
expect(find.byType(PluriPushScaffold), findsOneWidget);
expect(find.text('Change path'), findsOneWidget);
});
testWidgets('tapping the Info row pushes its detail screen', (
tester,
) async {
setLargeSurface(tester);
_suppressListTileInkAssertion();
final estado = await crearEstado();
addTearDown(estado.dispose);
await tester.pumpWidget(buildAjustes(estado));
await pumpStable(tester);
await tester.tap(find.text('Info'));
await pumpStable(tester);
expect(find.byType(PluriPushScaffold), findsOneWidget);
expect(find.text('Help and tutorial'), 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',
});
testWidgets('root now contains exactly 4 GrupoAjustes cards', (
tester,
) async {
setLargeSurface(tester);
_suppressListTileInkAssertion();
final estado = await crearEstado();
@@ -232,37 +293,10 @@ 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 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({});
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('No folder selected'), findsOneWidget);
expect(find.text('AUDIO'), findsOneWidget);
expect(find.text('STATIONS'), findsOneWidget);
expect(find.text('RECORDINGS & MUSIC'), findsOneWidget);
expect(find.text('APPLICATION'), findsOneWidget);
});
});
}