diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 95c7dcb..1b9b21e 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -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", diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb index 17a62a8..09826ab 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -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", diff --git a/lib/l10n/gen/app_localizations.dart b/lib/l10n/gen/app_localizations.dart index 34ced1b..6c56401 100644 --- a/lib/l10n/gen/app_localizations.dart +++ b/lib/l10n/gen/app_localizations.dart @@ -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: diff --git a/lib/l10n/gen/app_localizations_ar.dart b/lib/l10n/gen/app_localizations_ar.dart index a5b5777..3ecdd4b 100644 --- a/lib/l10n/gen/app_localizations_ar.dart +++ b/lib/l10n/gen/app_localizations_ar.dart @@ -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 => 'اللغة'; diff --git a/lib/l10n/gen/app_localizations_bn.dart b/lib/l10n/gen/app_localizations_bn.dart index e896c5a..ff4cc07 100644 --- a/lib/l10n/gen/app_localizations_bn.dart +++ b/lib/l10n/gen/app_localizations_bn.dart @@ -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 => 'ভাষা'; diff --git a/lib/l10n/gen/app_localizations_de.dart b/lib/l10n/gen/app_localizations_de.dart index a2555fd..7968137 100644 --- a/lib/l10n/gen/app_localizations_de.dart +++ b/lib/l10n/gen/app_localizations_de.dart @@ -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'; diff --git a/lib/l10n/gen/app_localizations_en.dart b/lib/l10n/gen/app_localizations_en.dart index 64910bc..d4222bb 100644 --- a/lib/l10n/gen/app_localizations_en.dart +++ b/lib/l10n/gen/app_localizations_en.dart @@ -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'; diff --git a/lib/l10n/gen/app_localizations_es.dart b/lib/l10n/gen/app_localizations_es.dart index 1e0ba66..a3f2e8d 100644 --- a/lib/l10n/gen/app_localizations_es.dart +++ b/lib/l10n/gen/app_localizations_es.dart @@ -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'; diff --git a/lib/l10n/gen/app_localizations_fr.dart b/lib/l10n/gen/app_localizations_fr.dart index d27c462..4f95446 100644 --- a/lib/l10n/gen/app_localizations_fr.dart +++ b/lib/l10n/gen/app_localizations_fr.dart @@ -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'; diff --git a/lib/l10n/gen/app_localizations_hi.dart b/lib/l10n/gen/app_localizations_hi.dart index bd58f52..45985d3 100644 --- a/lib/l10n/gen/app_localizations_hi.dart +++ b/lib/l10n/gen/app_localizations_hi.dart @@ -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 => 'भाषा'; diff --git a/lib/l10n/gen/app_localizations_id.dart b/lib/l10n/gen/app_localizations_id.dart index 51d5ade..885377e 100644 --- a/lib/l10n/gen/app_localizations_id.dart +++ b/lib/l10n/gen/app_localizations_id.dart @@ -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'; diff --git a/lib/l10n/gen/app_localizations_it.dart b/lib/l10n/gen/app_localizations_it.dart index 6771201..1c9c5f0 100644 --- a/lib/l10n/gen/app_localizations_it.dart +++ b/lib/l10n/gen/app_localizations_it.dart @@ -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'; diff --git a/lib/l10n/gen/app_localizations_ja.dart b/lib/l10n/gen/app_localizations_ja.dart index 356d762..59db2ec 100644 --- a/lib/l10n/gen/app_localizations_ja.dart +++ b/lib/l10n/gen/app_localizations_ja.dart @@ -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 => '言語'; diff --git a/lib/l10n/gen/app_localizations_pt.dart b/lib/l10n/gen/app_localizations_pt.dart index f4f30b2..05b9fd6 100644 --- a/lib/l10n/gen/app_localizations_pt.dart +++ b/lib/l10n/gen/app_localizations_pt.dart @@ -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'; diff --git a/lib/l10n/gen/app_localizations_ru.dart b/lib/l10n/gen/app_localizations_ru.dart index 969c1cb..c05396f 100644 --- a/lib/l10n/gen/app_localizations_ru.dart +++ b/lib/l10n/gen/app_localizations_ru.dart @@ -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 => 'Язык'; diff --git a/lib/l10n/gen/app_localizations_zh.dart b/lib/l10n/gen/app_localizations_zh.dart index 6c09485..f5fd910 100644 --- a/lib/l10n/gen/app_localizations_zh.dart +++ b/lib/l10n/gen/app_localizations_zh.dart @@ -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 => '语言'; diff --git a/lib/pantallas/ajustes/pantalla_ajustes_backup.dart b/lib/pantallas/ajustes/pantalla_ajustes_backup.dart new file mode 100644 index 0000000..c78be90 --- /dev/null +++ b/lib/pantallas/ajustes/pantalla_ajustes_backup.dart @@ -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 _exportar(BuildContext context) async { + final l10n = AppLocalizations.of(context); + try { + final estado = context.read(); + // JSON serialization is owned by ServicioExportImport (S4-R4). + final json = await estado.exportarConfigJson(); + + final dir = await getTemporaryDirectory(); + final file = File('${dir.path}/pluriwave-backup.json'); + await file.writeAsString(json); + + await Share.shareXFiles( + [XFile(file.path)], + subject: l10n.backupShareSubject, + text: l10n.backupShareText(DateTime.now().toLocal()), + ); + } catch (e) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(l10n.backupExportError(e.toString()))), + ); + } + } + } + + Future _importar(BuildContext context) async { + final l10n = AppLocalizations.of(context); + try { + final result = await FilePicker.platform.pickFiles( + type: FileType.custom, + allowedExtensions: ['json'], + ); + if (result == null || result.files.single.path == null) return; + + final file = File(result.files.single.path!); + final contenido = await file.readAsString(); + if (!context.mounted) return; + // Parsing is owned by ServicioExportImport (S4-R4): null = malformed. + final json = context.read().parsearConfigJson(contenido); + if (json == null) { + throw const FormatException('invalid backup file'); + } + + if (context.mounted) { + final confirmar = await showDialog( + context: context, + builder: + (ctx) => AlertDialog( + title: Text(AppLocalizations.of(ctx).backupImportTitle), + content: Text( + AppLocalizations.of(ctx).backupImportConfirmMessage, + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx, false), + child: Text(AppLocalizations.of(ctx).cancelAction), + ), + FilledButton( + onPressed: () => Navigator.pop(ctx, true), + child: Text(AppLocalizations.of(ctx).backupImportTitle), + ), + ], + ), + ); + if (confirmar != true) return; + if (context.mounted) { + final estado = context.read(); + final messenger = ScaffoldMessenger.of(context); + await estado.importarConfig(json); + messenger.showSnackBar( + SnackBar(content: Text(l10n.backupImportSuccess)), + ); + } + } + } catch (e) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(l10n.backupImportError(e.toString()))), + ); + } + } + } + + @override + Widget build(BuildContext context) { + return PluriGlassSurface( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + 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), + ), + ], + ), + ); + } +} diff --git a/lib/pantallas/ajustes/pantalla_ajustes_grabaciones.dart b/lib/pantallas/ajustes/pantalla_ajustes_grabaciones.dart new file mode 100644 index 0000000..bc4dc79 --- /dev/null +++ b/lib/pantallas/ajustes/pantalla_ajustes_grabaciones.dart @@ -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 _seleccionarRuta(BuildContext context) async { + final estado = context.read(); + final messenger = ScaffoldMessenger.of(context); + final l10n = AppLocalizations.of(context); + final ruta = await FilePicker.platform.getDirectoryPath( + dialogTitle: l10n.recordingsFolderDialogTitle, + ); + if (ruta == null) return; + try { + await estado.cambiarDirectorio(ruta); + if (!context.mounted) return; + messenger.showSnackBar( + SnackBar(content: Text(l10n.recordingsPathUpdated)), + ); + } catch (e) { + if (!context.mounted) return; + messenger.showSnackBar( + SnackBar(content: Text(l10n.recordingsPathSaveError(e.toString()))), + ); + } + } + + Future _restaurarRuta(BuildContext context) async { + final estado = context.read(); + final messenger = ScaffoldMessenger.of(context); + final l10n = AppLocalizations.of(context); + await estado.restaurarDirectorio(); + if (!context.mounted) return; + messenger.showSnackBar( + SnackBar(content: Text(l10n.recordingsDefaultFolderRestored)), + ); + } + + Future _abrirCarpeta(BuildContext context) async { + final estado = context.read(); + final messenger = ScaffoldMessenger.of(context); + final l10n = AppLocalizations.of(context); + try { + final abierto = await estado.abrirDirectorio(); + if (!context.mounted) return; + if (!abierto) { + messenger.showSnackBar( + SnackBar(content: Text(l10n.recordingsOpenFolderError(l10n.dash))), + ); + } + } catch (e) { + if (!context.mounted) return; + messenger.showSnackBar( + SnackBar(content: Text(l10n.recordingsOpenFolderError(e.toString()))), + ); + } + } + + Future _editarTamanoMaximo(BuildContext context) async { + final estado = context.read(); + final l10n = AppLocalizations.of(context); + final actualMb = _bytesAMegabytes(estado.maxBytes); + final controller = TextEditingController(text: actualMb.toString()); + + final nuevoMb = await showModalBottomSheet( + context: context, + isScrollControlled: true, + showDragHandle: true, + builder: (ctx) { + final bottom = MediaQuery.viewInsetsOf(ctx).bottom; + return Padding( + padding: EdgeInsets.fromLTRB(20, 0, 20, bottom + 24), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + l10n.recordingsMaxSizeDialogTitle, + style: Theme.of(ctx).textTheme.titleLarge, + ), + const SizedBox(height: 16), + TextField( + controller: controller, + autofocus: true, + keyboardType: TextInputType.number, + decoration: InputDecoration( + labelText: l10n.recordingsMaxSizeMbLabel, + border: const OutlineInputBorder(), + ), + ), + const SizedBox(height: 16), + FilledButton.icon( + onPressed: () { + final value = int.tryParse(controller.text.trim()); + if (value == null || value <= 0) return; + Navigator.of(ctx).pop(value); + }, + icon: const Icon(Icons.save_rounded), + label: Text(l10n.saveQuickAccessButton), + ), + ], + ), + ); + }, + ); + controller.dispose(); + if (nuevoMb == null || !context.mounted) return; + await estado.cambiarMaxBytes(nuevoMb * 1024 * 1024); + if (!context.mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(l10n.recordingsMaxSizeSaved(nuevoMb))), + ); + } + + int _bytesAMegabytes(int bytes) => + (bytes / (1024 * 1024)).round().clamp(1, 1048576); + + @override + Widget build(BuildContext context) { + // Recording state lives in EstadoGrabacion (S4-R2): this section only + // rebuilds on recording changes, never on playback notifications. + final estado = context.watch(); + final l10n = AppLocalizations.of(context); + + return PluriGlassSurface( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + FutureBuilder( + future: estado.directorioEfectivo(), + builder: + (ctx, snap) => ListTile( + contentPadding: EdgeInsets.zero, + leading: const Icon(Icons.folder_outlined), + title: Text(l10n.recordingsFolderTitle), + subtitle: Text( + snap.data ?? l10n.recordingsPathCalculating, + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ), + ), + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + OutlinedButton.icon( + icon: const Icon(Icons.folder_open_rounded), + label: Text(l10n.recordingsChangePath), + onPressed: () => _seleccionarRuta(context), + ), + FilledButton.tonalIcon( + icon: const Icon(Icons.folder_copy_rounded), + label: Text(l10n.recordingsOpenFolder), + onPressed: () => _abrirCarpeta(context), + ), + IconButton.filledTonal( + tooltip: l10n.recordingsUseDefaultPath, + icon: const Icon(Icons.restore_rounded), + onPressed: () => _restaurarRuta(context), + ), + ], + ), + const SizedBox(height: 8), + ListTile( + contentPadding: EdgeInsets.zero, + leading: const Icon(Icons.sd_storage_rounded), + title: Text(l10n.recordingsMaxSizeTitle), + subtitle: Text( + l10n.recordingsMaxSizeSubtitle(_bytesAMegabytes(estado.maxBytes)), + ), + onTap: () => _editarTamanoMaximo(context), + ), + const SizedBox(height: 8), + Text( + l10n.recordingsOriginalStreamHint, + style: Theme.of(context).textTheme.bodySmall, + ), + ], + ), + ); + } +} diff --git a/lib/pantallas/ajustes/pantalla_ajustes_idioma.dart b/lib/pantallas/ajustes/pantalla_ajustes_idioma.dart new file mode 100644 index 0000000..6f15166 --- /dev/null +++ b/lib/pantallas/ajustes/pantalla_ajustes_idioma.dart @@ -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(); + 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( + initialValue: valorActual, + decoration: InputDecoration( + labelText: l10n.languageSectionTitle, + border: const OutlineInputBorder(), + ), + items: [ + DropdownMenuItem( + value: _codigoSistema, + child: Text(l10n.languageSystemDefault), + ), + for (final idioma in _idiomas) + DropdownMenuItem( + value: _codigoLocale(idioma.locale), + child: Text(idioma.nombreNativo), + ), + ], + onChanged: (codigo) async { + if (codigo == null) return; + if (codigo == _codigoSistema) { + await context.read().seleccionarSistema(); + if (!context.mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(l10n.languageUpdatedSystem)), + ); + return; + } + + final idioma = _idiomas.firstWhere( + (item) => _codigoLocale(item.locale) == codigo, + orElse: () => _idiomas.first, + ); + await context.read().seleccionarLocale( + idioma.locale, + ); + if (!context.mounted) return; + + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(l10n.languageUpdated(idioma.nombreNativo)), + ), + ); + }, + ), + ], + ), + ); + } + + static String _codigoLocale(Locale locale) { + final countryCode = locale.countryCode; + if (countryCode == null || countryCode.isEmpty) { + return locale.languageCode; + } + return '${locale.languageCode}_$countryCode'; + } +} + +class _IdiomaDisponible { + const _IdiomaDisponible(this.locale, this.nombreNativo); + + final Locale locale; + final String nombreNativo; +} diff --git a/lib/pantallas/ajustes/pantalla_ajustes_info.dart b/lib/pantallas/ajustes/pantalla_ajustes_info.dart new file mode 100644 index 0000000..6447709 --- /dev/null +++ b/lib/pantallas/ajustes/pantalla_ajustes_info.dart @@ -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( + builder: + (ctx, estado, _) => PluriGlassSurface( + child: Column( + children: [ + FutureBuilder( + future: PackageInfo.fromPlatform(), + builder: (ctx, snap) { + final version = + snap.hasData + ? 'v${snap.data!.version}+${snap.data!.buildNumber}' + : AppLocalizations.of(ctx).appVersionLoading; + return ListTile( + contentPadding: EdgeInsets.zero, + leading: const PluriIcon( + glyph: PluriIconGlyph.settings, + variant: PluriIconVariant.filled, + ), + title: Text(AppLocalizations.of(ctx).appTitle), + subtitle: Text( + AppLocalizations.of(ctx).appVersionSubtitle(version), + ), + ); + }, + ), + FutureBuilder( + future: estado.favoritos.obtenerTodos().then((l) => l.length), + builder: + (ctx, snap) => ListTile( + contentPadding: EdgeInsets.zero, + leading: const Icon(Icons.favorite_outline_rounded), + title: Text( + AppLocalizations.of(ctx).savedFavoritesTitle, + ), + trailing: Text( + snap.data?.toString() ?? + AppLocalizations.of(ctx).dash, + style: Theme.of(ctx).textTheme.bodyLarge, + ), + ), + ), + ListTile( + contentPadding: EdgeInsets.zero, + leading: const Icon(Icons.help_outline_rounded), + title: Text(AppLocalizations.of(ctx).helpTitle), + subtitle: Text(AppLocalizations.of(ctx).helpSubtitle), + trailing: const Icon(Icons.chevron_right_rounded), + onTap: () => PluriOnboardingDialog.mostrar(ctx), + ), + ListTile( + contentPadding: EdgeInsets.zero, + leading: const Icon(Icons.verified_outlined), + title: Text(AppLocalizations.of(ctx).stationFilterTitle), + subtitle: Text( + AppLocalizations.of(ctx).stationFilterSubtitle, + ), + trailing: Icon( + Icons.check_circle_rounded, + color: Theme.of(ctx).colorScheme.secondary, + ), + ), + ListTile( + contentPadding: EdgeInsets.zero, + leading: const Icon(Icons.music_note_outlined), + title: Text(AppLocalizations.of(ctx).backgroundAudioTitle), + subtitle: Text( + AppLocalizations.of(ctx).backgroundAudioSubtitle, + ), + trailing: Icon( + Icons.check_circle_rounded, + color: Theme.of(ctx).colorScheme.secondary, + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/pantallas/ajustes/pantalla_ajustes_musica_local.dart b/lib/pantallas/ajustes/pantalla_ajustes_musica_local.dart new file mode 100644 index 0000000..c34324a --- /dev/null +++ b/lib/pantallas/ajustes/pantalla_ajustes_musica_local.dart @@ -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 _carpetaActual; + + @override + void initState() { + super.initState(); + _carpetaActual = _fuente.carpetaActual(); + } + + Future _elegirCarpeta(BuildContext context) async { + final messenger = ScaffoldMessenger.of(context); + final l10n = AppLocalizations.of(context); + try { + final uri = await _fuente.elegirCarpeta(); + if (uri == null) return; // Cancelled — no snackbar, matches the SAF + // picker's own "nothing changed" affordance. + if (!context.mounted) return; + setState(() { + _carpetaActual = Future.value(uri); + }); + messenger.showSnackBar( + SnackBar(content: Text(l10n.localMusicFolderUpdated)), + ); + } catch (e) { + if (!context.mounted) return; + messenger.showSnackBar( + SnackBar(content: Text(l10n.localMusicFolderSaveError(e.toString()))), + ); + } + } + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context); + + return PluriGlassSurface( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox(height: 8), + Text( + l10n.localMusicSectionDescription, + style: Theme.of(context).textTheme.bodySmall, + ), + FutureBuilder( + future: _carpetaActual, + builder: (ctx, snap) { + final carpeta = snap.data; + return ListTile( + contentPadding: EdgeInsets.zero, + leading: const Icon(Icons.folder_outlined), + title: Text(l10n.localMusicFolderTitle), + subtitle: Text( + (carpeta == null || carpeta.isEmpty) + ? l10n.localMusicFolderNotConfigured + : nombreCarpetaDesdeUri( + carpeta, + nombreGenerico: l10n.localMusicFolderGenericName, + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ); + }, + ), + const SizedBox(height: 8), + FutureBuilder( + future: _carpetaActual, + builder: (ctx, snap) { + final configurada = (snap.data ?? '').isNotEmpty; + return Align( + alignment: Alignment.centerLeft, + child: OutlinedButton.icon( + icon: const Icon(Icons.folder_open_rounded), + label: Text( + configurada + ? l10n.localMusicChangePath + : l10n.localMusicChoosePath, + ), + onPressed: () => _elegirCarpeta(context), + ), + ); + }, + ), + ], + ), + ); + } +} diff --git a/lib/pantallas/pantalla_ajustes.dart b/lib/pantallas/pantalla_ajustes.dart index 2bc8741..7cea685 100644 --- a/lib/pantallas/pantalla_ajustes.dart +++ b/lib/pantallas/pantalla_ajustes.dart @@ -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 _seleccionarRuta(BuildContext context) async { - final estado = context.read(); - final messenger = ScaffoldMessenger.of(context); - final l10n = AppLocalizations.of(context); - final ruta = await FilePicker.platform.getDirectoryPath( - dialogTitle: l10n.recordingsFolderDialogTitle, - ); - if (ruta == null) return; - try { - await estado.cambiarDirectorio(ruta); - if (!context.mounted) return; - messenger.showSnackBar( - SnackBar(content: Text(l10n.recordingsPathUpdated)), - ); - } catch (e) { - if (!context.mounted) return; - messenger.showSnackBar( - SnackBar(content: Text(l10n.recordingsPathSaveError(e.toString()))), - ); - } - } - - Future _restaurarRuta(BuildContext context) async { - final estado = context.read(); - final messenger = ScaffoldMessenger.of(context); - final l10n = AppLocalizations.of(context); - await estado.restaurarDirectorio(); - if (!context.mounted) return; - messenger.showSnackBar( - SnackBar(content: Text(l10n.recordingsDefaultFolderRestored)), - ); - } - - Future _abrirCarpeta(BuildContext context) async { - final estado = context.read(); - final messenger = ScaffoldMessenger.of(context); - final l10n = AppLocalizations.of(context); - try { - final abierto = await estado.abrirDirectorio(); - if (!context.mounted) return; - if (!abierto) { - messenger.showSnackBar( - SnackBar(content: Text(l10n.recordingsOpenFolderError(l10n.dash))), - ); - } - } catch (e) { - if (!context.mounted) return; - messenger.showSnackBar( - SnackBar(content: Text(l10n.recordingsOpenFolderError(e.toString()))), - ); - } - } - - Future _editarTamanoMaximo(BuildContext context) async { - final estado = context.read(); - final l10n = AppLocalizations.of(context); - final actualMb = _bytesAMegabytes(estado.maxBytes); - final controller = TextEditingController(text: actualMb.toString()); - - final nuevoMb = await showModalBottomSheet( - context: context, - isScrollControlled: true, - showDragHandle: true, - builder: (ctx) { - final bottom = MediaQuery.viewInsetsOf(ctx).bottom; - return Padding( - padding: EdgeInsets.fromLTRB(20, 0, 20, bottom + 24), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - l10n.recordingsMaxSizeDialogTitle, - style: Theme.of(ctx).textTheme.titleLarge, - ), - const SizedBox(height: 16), - TextField( - controller: controller, - autofocus: true, - keyboardType: TextInputType.number, - decoration: InputDecoration( - labelText: l10n.recordingsMaxSizeMbLabel, - border: const OutlineInputBorder(), - ), - ), - const SizedBox(height: 16), - FilledButton.icon( - onPressed: () { - final value = int.tryParse(controller.text.trim()); - if (value == null || value <= 0) return; - Navigator.of(ctx).pop(value); - }, - icon: const Icon(Icons.save_rounded), - label: Text(l10n.saveQuickAccessButton), - ), - ], - ), - ); - }, - ); - controller.dispose(); - if (nuevoMb == null || !context.mounted) return; - await estado.cambiarMaxBytes(nuevoMb * 1024 * 1024); - if (!context.mounted) return; - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(l10n.recordingsMaxSizeSaved(nuevoMb))), - ); - } - - int _bytesAMegabytes(int bytes) => - (bytes / (1024 * 1024)).round().clamp(1, 1048576); - - @override - Widget build(BuildContext context) { - // Recording state lives in EstadoGrabacion (S4-R2): this section only - // rebuilds on recording changes, never on playback notifications. - final estado = context.watch(); - final l10n = AppLocalizations.of(context); - - return PluriGlassSurface( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - const Icon(Icons.radio_button_checked_rounded), - const SizedBox(width: 12), - Text( - l10n.recordingsSectionTitle, - style: Theme.of(context).textTheme.titleMedium, - ), - ], - ), - FutureBuilder( - future: estado.directorioEfectivo(), - builder: - (ctx, snap) => ListTile( - contentPadding: EdgeInsets.zero, - leading: const Icon(Icons.folder_outlined), - title: Text(l10n.recordingsFolderTitle), - subtitle: Text( - snap.data ?? l10n.recordingsPathCalculating, - maxLines: 2, - overflow: TextOverflow.ellipsis, - ), - ), - ), - Wrap( - spacing: 8, - runSpacing: 8, - children: [ - OutlinedButton.icon( - icon: const Icon(Icons.folder_open_rounded), - label: Text(l10n.recordingsChangePath), - onPressed: () => _seleccionarRuta(context), - ), - FilledButton.tonalIcon( - icon: const Icon(Icons.folder_copy_rounded), - label: Text(l10n.recordingsOpenFolder), - onPressed: () => _abrirCarpeta(context), - ), - IconButton.filledTonal( - tooltip: l10n.recordingsUseDefaultPath, - icon: const Icon(Icons.restore_rounded), - onPressed: () => _restaurarRuta(context), - ), - ], - ), - const SizedBox(height: 8), - ListTile( - contentPadding: EdgeInsets.zero, - leading: const Icon(Icons.sd_storage_rounded), - title: Text(l10n.recordingsMaxSizeTitle), - subtitle: Text( - l10n.recordingsMaxSizeSubtitle(_bytesAMegabytes(estado.maxBytes)), - ), - onTap: () => _editarTamanoMaximo(context), - ), - const SizedBox(height: 8), - Text( - l10n.recordingsOriginalStreamHint, - style: Theme.of(context).textTheme.bodySmall, - ), - ], - ), - ); - } -} - -/// Local-music root-folder picker (android-auto-local-music task 9), -/// mirroring [_SeccionGrabaciones]'s shape: `PluriGlassSurface` card, -/// `FutureBuilder`-driven current-folder display, a single action button and -/// snackbar feedback. Deliberately does NOT use `FilePicker.platform` (see -/// tasks.md "Grounding corrections") — [FuenteMusicaLocalAutoImpl.elegirCarpeta] -/// calls the NEW native `pickMusicFolder` channel method directly, since it -/// needs a persistable-grant SAF tree URI, not a plain filesystem path. -class _SeccionMusicaLocal extends StatefulWidget { - const _SeccionMusicaLocal(); - - @override - State<_SeccionMusicaLocal> createState() => _SeccionMusicaLocalState(); -} - -class _SeccionMusicaLocalState extends State<_SeccionMusicaLocal> { - final _fuente = FuenteMusicaLocalAutoImpl(); - late Future _carpetaActual; - - @override - void initState() { - super.initState(); - _carpetaActual = _fuente.carpetaActual(); - } - - Future _elegirCarpeta(BuildContext context) async { - final messenger = ScaffoldMessenger.of(context); - final l10n = AppLocalizations.of(context); - try { - final uri = await _fuente.elegirCarpeta(); - if (uri == null) return; // Cancelled — no snackbar, matches the SAF - // picker's own "nothing changed" affordance. - if (!context.mounted) return; - setState(() { - _carpetaActual = Future.value(uri); - }); - messenger.showSnackBar( - SnackBar(content: Text(l10n.localMusicFolderUpdated)), - ); - } catch (e) { - if (!context.mounted) return; - messenger.showSnackBar( - SnackBar(content: Text(l10n.localMusicFolderSaveError(e.toString()))), - ); - } - } - - @override - Widget build(BuildContext context) { - final l10n = AppLocalizations.of(context); - - return PluriGlassSurface( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - const Icon(Icons.library_music_outlined), - const SizedBox(width: 12), - Text( - l10n.localMusicSectionTitle, - style: Theme.of(context).textTheme.titleMedium, - ), - ], - ), - const SizedBox(height: 8), - Text( - l10n.localMusicSectionDescription, - style: Theme.of(context).textTheme.bodySmall, - ), - FutureBuilder( - future: _carpetaActual, - builder: (ctx, snap) { - final carpeta = snap.data; - return ListTile( - contentPadding: EdgeInsets.zero, - leading: const Icon(Icons.folder_outlined), - title: Text(l10n.localMusicFolderTitle), - subtitle: Text( - (carpeta == null || carpeta.isEmpty) - ? l10n.localMusicFolderNotConfigured - : nombreCarpetaDesdeUri( - carpeta, - nombreGenerico: l10n.localMusicFolderGenericName, - ), - maxLines: 2, - overflow: TextOverflow.ellipsis, - ), - ); - }, - ), - const SizedBox(height: 8), - FutureBuilder( - future: _carpetaActual, - builder: (ctx, snap) { - final configurada = (snap.data ?? '').isNotEmpty; - return Align( - alignment: Alignment.centerLeft, - child: OutlinedButton.icon( - icon: const Icon(Icons.folder_open_rounded), - label: Text( - configurada - ? l10n.localMusicChangePath - : l10n.localMusicChoosePath, - ), - onPressed: () => _elegirCarpeta(context), - ), - ); - }, - ), - ], - ), - ); - } -} - -class _SeccionIdioma extends StatelessWidget { - const _SeccionIdioma(); - - static const _codigoSistema = 'system'; - static const _idiomas = [ - _IdiomaDisponible(Locale('en'), 'English'), - _IdiomaDisponible(Locale('es'), 'Español'), - _IdiomaDisponible(Locale('zh'), '中文'), - _IdiomaDisponible(Locale('hi'), 'हिन्दी'), - _IdiomaDisponible(Locale('ar'), 'العربية'), - _IdiomaDisponible(Locale('pt'), 'Português'), - _IdiomaDisponible(Locale('fr'), 'Français'), - _IdiomaDisponible(Locale('ru'), 'Русский'), - _IdiomaDisponible(Locale('de'), 'Deutsch'), - _IdiomaDisponible(Locale('ja'), '日本語'), - _IdiomaDisponible(Locale('id'), 'Bahasa Indonesia'), - _IdiomaDisponible(Locale('bn'), 'বাংলা'), - _IdiomaDisponible(Locale('it'), 'Italiano'), - ]; - - @override - Widget build(BuildContext context) { - final l10n = AppLocalizations.of(context); - final estadoIdioma = context.watch(); - final locale = estadoIdioma.localeSeleccionado; - final valorActual = locale == null ? _codigoSistema : _codigoLocale(locale); - - return PluriGlassSurface( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - const Icon(Icons.language_rounded), - const SizedBox(width: 12), - Text( - l10n.languageSectionTitle, - style: Theme.of(context).textTheme.titleMedium, - ), - ], - ), - const SizedBox(height: 8), - Text( - l10n.languageSectionDescription, - style: Theme.of(context).textTheme.bodySmall, - ), - const SizedBox(height: 12), - DropdownButtonFormField( - initialValue: valorActual, - decoration: InputDecoration( - labelText: l10n.languageSectionTitle, - border: const OutlineInputBorder(), - ), - items: [ - DropdownMenuItem( - value: _codigoSistema, - child: Text(l10n.languageSystemDefault), - ), - for (final idioma in _idiomas) - DropdownMenuItem( - value: _codigoLocale(idioma.locale), - child: Text(idioma.nombreNativo), - ), - ], - onChanged: (codigo) async { - if (codigo == null) return; - if (codigo == _codigoSistema) { - await context.read().seleccionarSistema(); - if (!context.mounted) return; - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(l10n.languageUpdatedSystem)), - ); - return; - } - - final idioma = _idiomas.firstWhere( - (item) => _codigoLocale(item.locale) == codigo, - orElse: () => _idiomas.first, - ); - await context.read().seleccionarLocale( - idioma.locale, - ); - if (!context.mounted) return; - - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(l10n.languageUpdated(idioma.nombreNativo)), - ), - ); - }, - ), - ], - ), - ); - } - - static String _codigoLocale(Locale locale) { - final countryCode = locale.countryCode; - if (countryCode == null || countryCode.isEmpty) { - return locale.languageCode; - } - return '${locale.languageCode}_$countryCode'; - } -} - -class _IdiomaDisponible { - const _IdiomaDisponible(this.locale, this.nombreNativo); - - final Locale locale; - final String nombreNativo; -} - -class _SeccionBackup extends StatelessWidget { - const _SeccionBackup(); - - Future _exportar(BuildContext context) async { - final l10n = AppLocalizations.of(context); - try { - final estado = context.read(); - // JSON serialization is owned by ServicioExportImport (S4-R4). - final json = await estado.exportarConfigJson(); - - final dir = await getTemporaryDirectory(); - final file = File('${dir.path}/pluriwave-backup.json'); - await file.writeAsString(json); - - await Share.shareXFiles( - [XFile(file.path)], - subject: l10n.backupShareSubject, - text: l10n.backupShareText(DateTime.now().toLocal()), - ); - } catch (e) { - if (context.mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(l10n.backupExportError(e.toString()))), - ); - } - } - } - - Future _importar(BuildContext context) async { - final l10n = AppLocalizations.of(context); - try { - final result = await FilePicker.platform.pickFiles( - type: FileType.custom, - allowedExtensions: ['json'], - ); - if (result == null || result.files.single.path == null) return; - - final file = File(result.files.single.path!); - final contenido = await file.readAsString(); - if (!context.mounted) return; - // Parsing is owned by ServicioExportImport (S4-R4): null = malformed. - final json = context.read().parsearConfigJson(contenido); - if (json == null) { - throw const FormatException('invalid backup file'); - } - - if (context.mounted) { - final confirmar = await showDialog( - context: context, - builder: - (ctx) => AlertDialog( - title: Text(AppLocalizations.of(ctx).backupImportTitle), - content: Text( - AppLocalizations.of(ctx).backupImportConfirmMessage, - ), - actions: [ - TextButton( - onPressed: () => Navigator.pop(ctx, false), - child: Text(AppLocalizations.of(ctx).cancelAction), - ), - FilledButton( - onPressed: () => Navigator.pop(ctx, true), - child: Text(AppLocalizations.of(ctx).backupImportTitle), - ), - ], - ), - ); - if (confirmar != true) return; - if (context.mounted) { - final estado = context.read(); - final messenger = ScaffoldMessenger.of(context); - await estado.importarConfig(json); - messenger.showSnackBar( - SnackBar(content: Text(l10n.backupImportSuccess)), - ); - } - } - } catch (e) { - if (context.mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(l10n.backupImportError(e.toString()))), - ); - } - } - } - - @override - Widget build(BuildContext context) { - return PluriGlassSurface( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - const Icon(Icons.backup_outlined), - const SizedBox(width: 12), - Text( - AppLocalizations.of(context).backupSectionTitle, - style: Theme.of(context).textTheme.titleMedium, - ), - ], - ), - ListTile( - contentPadding: EdgeInsets.zero, - leading: const Icon(Icons.upload_outlined), - title: Text(AppLocalizations.of(context).backupExportTitle), - subtitle: Text(AppLocalizations.of(context).backupExportSubtitle), - onTap: () => _exportar(context), - ), - ListTile( - contentPadding: EdgeInsets.zero, - leading: const Icon(Icons.download_outlined), - title: Text(AppLocalizations.of(context).backupImportTitle), - subtitle: Text(AppLocalizations.of(context).backupImportSubtitle), - onTap: () => _importar(context), - ), - ], - ), - ); - } -} - -class _SeccionInfo extends StatelessWidget { - const _SeccionInfo(); - - @override - Widget build(BuildContext context) { - return Consumer( - builder: - (ctx, estado, _) => PluriGlassSurface( - child: Column( - children: [ - FutureBuilder( - future: PackageInfo.fromPlatform(), - builder: (ctx, snap) { - final version = - snap.hasData - ? 'v${snap.data!.version}+${snap.data!.buildNumber}' - : AppLocalizations.of(ctx).appVersionLoading; - return ListTile( - contentPadding: EdgeInsets.zero, - leading: const PluriIcon( - glyph: PluriIconGlyph.settings, - variant: PluriIconVariant.filled, - ), - title: Text(AppLocalizations.of(ctx).appTitle), - subtitle: Text( - AppLocalizations.of(ctx).appVersionSubtitle(version), - ), - ); - }, - ), - FutureBuilder( - future: estado.favoritos.obtenerTodos().then((l) => l.length), - builder: - (ctx, snap) => ListTile( - contentPadding: EdgeInsets.zero, - leading: const Icon(Icons.favorite_outline_rounded), - title: Text( - AppLocalizations.of(ctx).savedFavoritesTitle, - ), - trailing: Text( - snap.data?.toString() ?? - AppLocalizations.of(ctx).dash, - style: Theme.of(ctx).textTheme.bodyLarge, - ), - ), - ), - ListTile( - contentPadding: EdgeInsets.zero, - leading: const Icon(Icons.help_outline_rounded), - title: Text(AppLocalizations.of(ctx).helpTitle), - subtitle: Text(AppLocalizations.of(ctx).helpSubtitle), - trailing: const Icon(Icons.chevron_right_rounded), - onTap: () => PluriOnboardingDialog.mostrar(ctx), - ), - ListTile( - contentPadding: EdgeInsets.zero, - leading: const Icon(Icons.verified_outlined), - title: Text(AppLocalizations.of(ctx).stationFilterTitle), - subtitle: Text( - AppLocalizations.of(ctx).stationFilterSubtitle, - ), - trailing: Icon( - Icons.check_circle_rounded, - color: Theme.of(ctx).colorScheme.secondary, - ), - ), - ListTile( - contentPadding: EdgeInsets.zero, - leading: const Icon(Icons.music_note_outlined), - title: Text(AppLocalizations.of(ctx).backgroundAudioTitle), - subtitle: Text( - AppLocalizations.of(ctx).backgroundAudioSubtitle, - ), - trailing: Icon( - Icons.check_circle_rounded, - color: Theme.of(ctx).colorScheme.secondary, - ), - ), - ], - ), - ), - ); - } -} diff --git a/openspec/changes/rediseno-funcional/tasks.md b/openspec/changes/rediseno-funcional/tasks.md index 7c8c6fe..30bb58e 100644 --- a/openspec/changes/rediseno-funcional/tasks.md +++ b/openspec/changes/rediseno-funcional/tasks.md @@ -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 diff --git a/test/pantallas/ajustes/pantalla_ajustes_backup_test.dart b/test/pantallas/ajustes/pantalla_ajustes_backup_test.dart new file mode 100644 index 0000000..972a340 --- /dev/null +++ b/test/pantallas/ajustes/pantalla_ajustes_backup_test.dart @@ -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 archivoCustomVacio() async => File( + '${Directory.current.path}/test/fixtures/emisoras_custom_vacio.json', + ); + + Future crearEstado() async { + return EstadoRadio( + audio: FakeServicioAudio(), + favoritos: FakeServicioFavoritos(), + radio: FakeServicioRadio(), + servicioEcualizador: FakeServicioEcualizador(), + servicioGrabacion: FakeServicioGrabacionRadioInactiva(), + resolverArchivoCustom: archivoCustomVacio, + iniciarAutomaticamente: false, + ); + } + + Widget buildScreen(EstadoRadio estado) { + return ChangeNotifierProvider.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(exportTile).onTap, isNotNull); + expect(tester.widget(importTile).onTap, isNotNull); + }, + ); +} diff --git a/test/pantallas/ajustes/pantalla_ajustes_grabaciones_test.dart b/test/pantallas/ajustes/pantalla_ajustes_grabaciones_test.dart new file mode 100644 index 0000000..78bb121 --- /dev/null +++ b/test/pantallas/ajustes/pantalla_ajustes_grabaciones_test.dart @@ -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.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(); + }); +} diff --git a/test/pantallas/ajustes/pantalla_ajustes_idioma_test.dart b/test/pantallas/ajustes/pantalla_ajustes_idioma_test.dart new file mode 100644 index 0000000..a0bff5f --- /dev/null +++ b/test/pantallas/ajustes/pantalla_ajustes_idioma_test.dart @@ -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.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)); + 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(); + }); +} diff --git a/test/pantallas/ajustes/pantalla_ajustes_info_test.dart b/test/pantallas/ajustes/pantalla_ajustes_info_test.dart new file mode 100644 index 0000000..93885fa --- /dev/null +++ b/test/pantallas/ajustes/pantalla_ajustes_info_test.dart @@ -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 archivoCustomVacio() async => File( + '${Directory.current.path}/test/fixtures/emisoras_custom_vacio.json', + ); + + Future crearEstado() async { + return EstadoRadio( + audio: FakeServicioAudio(), + favoritos: FakeServicioFavoritos(), + radio: FakeServicioRadio(), + servicioEcualizador: FakeServicioEcualizador(), + servicioGrabacion: FakeServicioGrabacionRadioInactiva(), + resolverArchivoCustom: archivoCustomVacio, + iniciarAutomaticamente: false, + ); + } + + Widget buildScreen(EstadoRadio estado) { + return ChangeNotifierProvider.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); + }, + ); +} diff --git a/test/pantallas/ajustes/pantalla_ajustes_musica_local_test.dart b/test/pantallas/ajustes/pantalla_ajustes_musica_local_test.dart new file mode 100644 index 0000000..70f9e15 --- /dev/null +++ b/test/pantallas/ajustes/pantalla_ajustes_musica_local_test.dart @@ -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); + }); +} diff --git a/test/pantallas/pantalla_ajustes_test.dart b/test/pantallas/pantalla_ajustes_test.dart index 547bac0..3fb0271 100644 --- a/test/pantallas/pantalla_ajustes_test.dart +++ b/test/pantallas/pantalla_ajustes_test.dart @@ -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), 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); }); }); }