From e0164f68b516930dfc88e81b7fca503737e90ebc Mon Sep 17 00:00:00 2001 From: freetlab Date: Wed, 29 Jul 2026 21:26:48 +0200 Subject: [PATCH] fix(ajustes): show each settings row's current value The prototype puts a trailing current-value string on nearly every settings row (t4 lines 512-539, 625 -- "3 guardados", "Alfabetico", "Espanol", "7 . 84 MB"). FilaAjuste only accepted icon/titulo/onTap, so every row was value-blind. Add an optional `valor` slot to FilaAjuste (13px, rgba(242,247,250,.55), rendered before the chevron). Wire 8 of the 12 built rows to state already available at the settings root: equalizer on/off, sleep-timer active, favourite-group count, preferred station name, custom-station count, sort order, recordings count-and-size (FutureBuilder over EstadoGrabacion.listarGrabaciones), and the current language (hoisted pantalla_ajustes_idioma.dart's native-name list to module level so the root can read it without duplicating it). Salida de audio, Musica local, Backup and Info's version are left without a value -- each lacks a low-risk, deterministically-testable data source (see the apply-progress note for the reason per row). Reading EstadoRadio for these values through a root `context.watch` would rebuild the whole settings list -- including the Grabaciones FutureBuilder's disk read -- on every unrelated audio notification; this follows the codebase's existing S4-R5 convention of narrow `context.select` per field instead. Ajustes' own PluriRootHeader/PluriScreenHeader edit (S2 in this same pass) landed in this commit too, since both touched the same header block in pantalla_ajustes.dart at the same time. S8, Tier 1 visual-fidelity pass (audit id 2521). --- .../ajustes/pantalla_ajustes_idioma.dart | 90 +++++---- .../ajustes/widgets/fila_ajuste.dart | 40 +++- lib/pantallas/pantalla_ajustes.dart | 129 ++++++++++--- .../ajustes/widgets/fila_ajuste_test.dart | 52 ++++++ .../pantalla_ajustes_row_values_test.dart | 174 ++++++++++++++++++ 5 files changed, 421 insertions(+), 64 deletions(-) create mode 100644 test/pantallas/ajustes/widgets/fila_ajuste_test.dart create mode 100644 test/pantallas/pantalla_ajustes_row_values_test.dart diff --git a/lib/pantallas/ajustes/pantalla_ajustes_idioma.dart b/lib/pantallas/ajustes/pantalla_ajustes_idioma.dart index 6f15166..754e3bb 100644 --- a/lib/pantallas/ajustes/pantalla_ajustes_idioma.dart +++ b/lib/pantallas/ajustes/pantalla_ajustes_idioma.dart @@ -28,32 +28,60 @@ class PantallaAjustesIdioma extends StatelessWidget { } } +/// S8 (Tier 1 visual fidelity): the "system" pseudo-code, the native-name +/// list and the locale<->code mapping used to be private to this file's +/// `_CuerpoIdioma`. Hoisted to module level (unchanged values/logic) so +/// `pantalla_ajustes.dart`'s Idioma row can show the CURRENT language's +/// native name as its trailing value (t4's own example, line 526: +/// "Español") without duplicating this list. +const codigoIdiomaSistema = 'system'; + +const idiomasDisponibles = [ + 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'), +]; + +String codigoLocaleIdioma(Locale locale) { + final countryCode = locale.countryCode; + if (countryCode == null || countryCode.isEmpty) { + return locale.languageCode; + } + return '${locale.languageCode}_$countryCode'; +} + +/// The current language's own native name (e.g. "Español"), or the +/// localized "system default" label when [locale] is null. +String nombreIdiomaActual(Locale? locale, AppLocalizations l10n) { + if (locale == null) return l10n.languageSystemDefault; + final codigo = codigoLocaleIdioma(locale); + final idioma = idiomasDisponibles.firstWhere( + (item) => codigoLocaleIdioma(item.locale) == codigo, + orElse: () => idiomasDisponibles.first, + ); + return idioma.nombreNativo; +} + 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); + final valorActual = + locale == null ? codigoIdiomaSistema : codigoLocaleIdioma(locale); return PluriGlassSurface( child: Column( @@ -73,18 +101,18 @@ class _CuerpoIdioma extends StatelessWidget { ), items: [ DropdownMenuItem( - value: _codigoSistema, + value: codigoIdiomaSistema, child: Text(l10n.languageSystemDefault), ), - for (final idioma in _idiomas) + for (final idioma in idiomasDisponibles) DropdownMenuItem( - value: _codigoLocale(idioma.locale), + value: codigoLocaleIdioma(idioma.locale), child: Text(idioma.nombreNativo), ), ], onChanged: (codigo) async { if (codigo == null) return; - if (codigo == _codigoSistema) { + if (codigo == codigoIdiomaSistema) { await context.read().seleccionarSistema(); if (!context.mounted) return; ScaffoldMessenger.of(context).showSnackBar( @@ -93,9 +121,9 @@ class _CuerpoIdioma extends StatelessWidget { return; } - final idioma = _idiomas.firstWhere( - (item) => _codigoLocale(item.locale) == codigo, - orElse: () => _idiomas.first, + final idioma = idiomasDisponibles.firstWhere( + (item) => codigoLocaleIdioma(item.locale) == codigo, + orElse: () => idiomasDisponibles.first, ); await context.read().seleccionarLocale( idioma.locale, @@ -113,18 +141,10 @@ class _CuerpoIdioma extends StatelessWidget { ), ); } - - 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); +class IdiomaDisponible { + const IdiomaDisponible(this.locale, this.nombreNativo); final Locale locale; final String nombreNativo; diff --git a/lib/pantallas/ajustes/widgets/fila_ajuste.dart b/lib/pantallas/ajustes/widgets/fila_ajuste.dart index dc3ee52..fea804a 100644 --- a/lib/pantallas/ajustes/widgets/fila_ajuste.dart +++ b/lib/pantallas/ajustes/widgets/fila_ajuste.dart @@ -17,7 +17,12 @@ class GrupoAjustes extends StatelessWidget { /// already in its display form — this style never applies `toUpperCase()`. final String titulo; - final List filas; + /// S8 (Tier 1 visual fidelity): `Widget`, not `List` — a few + /// rows source their current-value text asynchronously (e.g. recordings + /// count, app version) and wrap their own `FilaAjuste` in a + /// `FutureBuilder`. `GrupoAjustes` only iterates and inserts dividers; it + /// never reaches into `FilaAjuste`-specific state. + final List filas; @override Widget build(BuildContext context) { @@ -38,29 +43,54 @@ class GrupoAjustes extends StatelessWidget { } } -/// A single Settings navigation row: icon, title, and a trailing chevron. -/// Tapping it is the row's only behaviour — it carries no switches, sliders -/// or text fields, which is what "zero inline controls" means at the root. +/// A single Settings navigation row: icon, title, an optional trailing +/// current-value string, and a trailing chevron. Tapping it is the row's +/// only behaviour — it carries no switches, sliders or text fields, which +/// is what "zero inline controls" means at the root. class FilaAjuste extends StatelessWidget { const FilaAjuste({ super.key, required this.icon, required this.titulo, required this.onTap, + this.valor, }); final IconData icon; final String titulo; final VoidCallback onTap; + /// S8 (Tier 1 visual fidelity): the prototype puts a trailing current + /// value on nearly every row (t4 lines 512-539, 625 — e.g. "3 guardados", + /// "Alfabético", "Español"), 13px `rgba(242,247,250,.55)`. Null means + /// "no current value to show" — the row renders exactly as before. + final String? valor; + @override Widget build(BuildContext context) { final type = context.pluriType; + final valorActual = valor; return ListTile( contentPadding: EdgeInsets.zero, leading: Icon(icon), title: Text(titulo, style: type.cardTitle), - trailing: const Icon(Icons.chevron_right_rounded), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (valorActual != null) ...[ + Text( + valorActual, + // bodyStrong is already 13/w600, matching the prototype's row + // value spec exactly — only the colour needs overriding. + style: type.bodyStrong.copyWith( + color: const Color(0xFFF2F7FA).withValues(alpha: 0.55), + ), + ), + const SizedBox(width: 6), + ], + const Icon(Icons.chevron_right_rounded), + ], + ), onTap: onTap, ); } diff --git a/lib/pantallas/pantalla_ajustes.dart b/lib/pantallas/pantalla_ajustes.dart index fb0e194..4d14c0c 100644 --- a/lib/pantallas/pantalla_ajustes.dart +++ b/lib/pantallas/pantalla_ajustes.dart @@ -1,9 +1,15 @@ import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import '../estado/estado_ecualizador.dart'; +import '../estado/estado_grabacion.dart'; +import '../estado/estado_idioma.dart'; +import '../estado/estado_radio.dart'; +import '../l10n/display_names.dart'; import '../l10n/gen/app_localizations.dart'; -import '../widgets/pluri_icon.dart'; +import '../modelos/archivo_grabacion.dart'; +import '../modelos/emisora.dart'; import '../widgets/pluri_layout.dart'; -import '../widgets/pluri_premium_widgets.dart'; import '../widgets/pluri_push_scaffold.dart'; import '../widgets/pluri_root_header.dart'; import '../widgets/pluri_sleep_timer_sheet.dart'; @@ -39,15 +45,6 @@ class PantallaAjustes extends StatelessWidget { title: l10n.settingsTitle, onSleepTimer: () => showPluriSleepTimerSheet(context), ), - PluriScreenHeader( - title: l10n.settingsTitle, - subtitle: l10n.settingsSubtitle, - glyph: PluriIconGlyph.settings, - trailing: PluriStatusPill( - icon: Icons.security_rounded, - label: l10n.settingsSafeStatus, - ), - ), const Padding( padding: PluriLayout.pageContentPadding, child: _AjustesContent(), @@ -68,6 +65,39 @@ class _AjustesContent extends StatelessWidget { @override Widget build(BuildContext context) { final l10n = AppLocalizations.of(context); + // S8 (Tier 1 visual fidelity): the prototype shows every row's current + // value (t4 lines 512-539, 625). 8 of the 12 rows below read it from + // state already provided at the app root — no new providers needed. + // + // S4-R5 convention (see pantalla_inicio.dart, pantalla_buscar.dart): + // `context.select` per scalar, NOT a root `context.watch()` + // — EstadoRadio also notifies on audio buffer/position events, which + // this screen has nothing to do with. A root watch here rebuilds the + // WHOLE settings list (including the Grabaciones row's FutureBuilder, + // which would re-issue `listarGrabaciones()` on every single one of + // those unrelated notifications) far more often than intended. + final gruposCount = context.select( + (e) => e.gruposFavoritos.length, + ); + final emisoraPreferida = context.select( + (e) => e.emisoraPreferida, + ); + final emisorasCustomCount = context.select( + (e) => e.emisorasCustom.length, + ); + final ordenListas = context.select( + (e) => e.ordenListas, + ); + final timerActivo = context.select( + (e) => e.timer.activo, + ); + final ecualizadorActivo = context.select( + (e) => e.activo, + ); + final grabacion = context.watch(); + final idioma = context.select( + (e) => e.localeSeleccionado, + ); return Column( children: [ @@ -77,12 +107,18 @@ class _AjustesContent extends StatelessWidget { FilaAjuste( icon: Icons.equalizer_rounded, titulo: l10n.equalizerTitle, + valor: + ecualizadorActivo + ? l10n.equalizerActive + : l10n.equalizerDisabled, onTap: () => PluriPushScaffold.push( context, (_) => const PantallaAjustesEcualizador(), ), ), + // No `valor`: per-device output naming needs a friendly-name + // lookup this root doesn't have (only a raw device id). FilaAjuste( icon: Icons.devices_rounded, titulo: l10n.advancedEqSectionTitle, @@ -95,6 +131,10 @@ class _AjustesContent extends StatelessWidget { FilaAjuste( icon: Icons.bedtime_rounded, titulo: l10n.timerSectionTitle, + // Reuses the equalizer's own "Active" string (generic enough + // in every locale) — shown only while running, matching how + // the preferred-station row shows nothing when unset. + valor: timerActivo ? l10n.equalizerActive : null, onTap: () => PluriPushScaffold.push( context, @@ -110,6 +150,7 @@ class _AjustesContent extends StatelessWidget { FilaAjuste( icon: Icons.playlist_add_check_circle_rounded, titulo: l10n.favoriteGroupsTitle, + valor: '$gruposCount', onTap: () => PluriPushScaffold.push( context, @@ -119,6 +160,10 @@ class _AjustesContent extends StatelessWidget { FilaAjuste( icon: Icons.radio_rounded, titulo: l10n.preferredStationTitle, + valor: + emisoraPreferida != null + ? localizedStationName(l10n, emisoraPreferida.nombre) + : l10n.dash, onTap: () => PluriPushScaffold.push( context, @@ -128,6 +173,7 @@ class _AjustesContent extends StatelessWidget { FilaAjuste( icon: Icons.add_circle_outline_rounded, titulo: l10n.customStationsTitle, + valor: '$emisorasCustomCount', onTap: () => PluriPushScaffold.push( context, @@ -137,6 +183,10 @@ class _AjustesContent extends StatelessWidget { FilaAjuste( icon: Icons.sort_rounded, titulo: l10n.stationOrderTitle, + valor: + ordenListas == OrdenEmisoras.calidad + ? l10n.stationOrderByQuality + : l10n.stationOrderByName, onTap: () => PluriPushScaffold.push( context, @@ -149,20 +199,39 @@ class _AjustesContent extends StatelessWidget { GrupoAjustes( titulo: l10n.settingsGroupRecordingsTitle, filas: [ - FilaAjuste( - icon: Icons.radio_button_checked_rounded, - titulo: l10n.recordingsSectionTitle, - // WU15b: this row opens the recordings LIBRARY - // (PantallaGrabaciones), matching the approved mockup's - // "Ajustes > Grabaciones" screen. The folder/size settings - // form (PantallaAjustesGrabaciones) is still reachable, but - // now from within the library via its own settings action. - onTap: - () => PluriPushScaffold.push( - context, - (_) => const PantallaGrabaciones(), - ), + // FutureBuilder-wrapped (not a plain FilaAjuste): the count · + // size value needs an async disk listing + // (EstadoGrabacion.listarGrabaciones), same source + // PantallaGrabaciones itself reads. + FutureBuilder>( + future: grabacion.listarGrabaciones(), + builder: (context, snapshot) { + final archivos = snapshot.data; + return FilaAjuste( + icon: Icons.radio_button_checked_rounded, + titulo: l10n.recordingsSectionTitle, + valor: + archivos == null + ? null + : '${archivos.length} · ${_totalMb(archivos)} MB', + // WU15b: this row opens the recordings LIBRARY + // (PantallaGrabaciones), matching the approved mockup's + // "Ajustes > Grabaciones" screen. The folder/size + // settings form (PantallaAjustesGrabaciones) is still + // reachable, but now from within the library via its own + // settings action. + onTap: + () => PluriPushScaffold.push( + context, + (_) => const PantallaGrabaciones(), + ), + ); + }, ), + // No `valor`: the configured folder is a SAF tree URI resolved + // by an async native channel call + // (FuenteMusicaLocalAutoImpl.carpetaActual) this root would + // need to invoke itself, unmocked, just to render a value. FilaAjuste( icon: Icons.library_music_outlined, titulo: l10n.localMusicSectionTitle, @@ -181,12 +250,15 @@ class _AjustesContent extends StatelessWidget { FilaAjuste( icon: Icons.language_rounded, titulo: l10n.languageSectionTitle, + valor: nombreIdiomaActual(idioma, l10n), onTap: () => PluriPushScaffold.push( context, (_) => const PantallaAjustesIdioma(), ), ), + // No `valor`: there is no persisted "last backup" timestamp to + // read — showing one here would mean fabricating it. FilaAjuste( icon: Icons.backup_outlined, titulo: l10n.backupSectionTitle, @@ -196,6 +268,10 @@ class _AjustesContent extends StatelessWidget { (_) => const PantallaAjustesBackup(), ), ), + // No `valor`: PackageInfo.fromPlatform() has no test-environment + // fallback anywhere else in this codebase either (see + // PantallaAjustesInfo's own FutureBuilder) — wiring it here + // would add a value this pass cannot deterministically test. FilaAjuste( icon: Icons.info_outline_rounded, titulo: l10n.infoSectionTitle, @@ -210,4 +286,9 @@ class _AjustesContent extends StatelessWidget { ], ); } + + static int _totalMb(List archivos) { + final totalBytes = archivos.fold(0, (a, b) => a + b.tamanoBytes); + return (totalBytes / (1024 * 1024)).round(); + } } diff --git a/test/pantallas/ajustes/widgets/fila_ajuste_test.dart b/test/pantallas/ajustes/widgets/fila_ajuste_test.dart new file mode 100644 index 0000000..2fdfde3 --- /dev/null +++ b/test/pantallas/ajustes/widgets/fila_ajuste_test.dart @@ -0,0 +1,52 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:pluriwave/pantallas/ajustes/widgets/fila_ajuste.dart'; +import 'package:pluriwave/tema/pluriwave_theme.dart'; + +/// S8 (Tier 1 visual fidelity): the prototype puts a trailing "current +/// value" on nearly every settings row, 13px `rgba(242,247,250,.55)` (t4 +/// lines 512-539, 625 — "Voz clara", "Alta", "3 guardados", "Alfabético", +/// "7 · 84 MB", "Español", "Hoy, 08:12", "200 MB"). `FilaAjuste` used to +/// accept only `icon`/`titulo`/`onTap` — no value slot at all. +void main() { + Widget host(Widget child) { + return MaterialApp( + theme: PluriWaveTheme.dark(), + home: Scaffold(body: child), + ); + } + + testWidgets('renders the trailing value before the chevron when provided', ( + tester, + ) async { + await tester.pumpWidget( + host( + FilaAjuste( + icon: Icons.language_rounded, + titulo: 'Language', + valor: 'English', + onTap: () {}, + ), + ), + ); + + expect(find.text('English'), findsOneWidget); + expect(find.byIcon(Icons.chevron_right_rounded), findsOneWidget); + }); + + testWidgets('renders no trailing value text when valor is omitted ' + '(unchanged pre-S8 behaviour)', (tester) async { + await tester.pumpWidget( + host( + FilaAjuste( + icon: Icons.info_outline_rounded, + titulo: 'Info', + onTap: () {}, + ), + ), + ); + + expect(find.text('Info'), findsOneWidget); + expect(find.byIcon(Icons.chevron_right_rounded), findsOneWidget); + }); +} diff --git a/test/pantallas/pantalla_ajustes_row_values_test.dart b/test/pantallas/pantalla_ajustes_row_values_test.dart new file mode 100644 index 0000000..ad54ccd --- /dev/null +++ b/test/pantallas/pantalla_ajustes_row_values_test.dart @@ -0,0 +1,174 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:pluriwave/estado/estado_ecualizador.dart'; +import 'package:pluriwave/estado/estado_grabacion.dart'; +import 'package:pluriwave/estado/estado_idioma.dart'; +import 'package:pluriwave/estado/estado_radio.dart'; +import 'package:pluriwave/l10n/gen/app_localizations.dart'; +import 'package:pluriwave/modelos/archivo_grabacion.dart'; +import 'package:pluriwave/pantallas/pantalla_ajustes.dart'; +import 'package:pluriwave/servicios/servicio_grabacion_radio.dart'; +import 'package:provider/provider.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import '../helpers/fakes.dart'; + +/// S8 (Tier 1 visual fidelity): the prototype puts a trailing "current +/// value" on nearly every settings row (t4 lines 512-539, 625 — e.g. +/// "3 guardados", "Alfabético", "Español", "7 · 84 MB"). Wires 8 of the 12 +/// built rows to real, already-available state. The other 4 (Salida de +/// audio, Música local, Backup, Info's version) are deliberately left +/// without a value — see the apply-progress note for why each lacks a +/// low-risk, deterministically-testable data source. +/// +/// 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); +} + +/// Returns a fixed, in-memory recordings list — `listarGrabaciones()`'s real +/// implementation touches the filesystem directly (`Directory.listSync`), +/// which the project's own convention forbids exercising bare in a widget +/// test. +class _FakeServicioGrabacionConArchivos extends ServicioGrabacionRadio { + final _controller = StreamController.broadcast(); + + @override + EstadoGrabacionRadio get estado => const EstadoGrabacionRadio.inactiva(); + + @override + Stream get estadoStream => _controller.stream; + + @override + Future inicializar() async {} + + @override + Future> listarGrabaciones() async => [ + ArchivoGrabacion( + ruta: '/a.m4a', + nombre: 'a', + fecha: DateTime(2026, 1, 1), + tamanoBytes: 2 * 1024 * 1024, + ), + ArchivoGrabacion( + ruta: '/b.m4a', + nombre: 'b', + fecha: DateTime(2026, 1, 2), + tamanoBytes: 5 * 1024 * 1024, + ), + ]; + + @override + Future dispose() => _controller.close(); +} + +void main() { + setUp(() { + SharedPreferences.setMockInitialValues({}); + }); + + Future archivoCustomVacio() async => File( + '${Directory.current.path}/test/fixtures/emisoras_custom_vacio.json', + ); + + void setLargeSurface(WidgetTester tester) { + tester.view.physicalSize = const Size(1440, 3200); + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + } + + Future pumpStable(WidgetTester tester) async { + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); + } + + Widget buildAjustes(EstadoRadio estado, EstadoIdioma idioma) { + return MultiProvider( + providers: [ + ChangeNotifierProvider.value(value: estado), + ListenableProvider.value(value: estado.ecualizador), + ListenableProvider.value(value: estado.grabacion), + ChangeNotifierProvider.value(value: idioma), + ], + child: MaterialApp( + locale: const Locale('en'), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: const Scaffold(body: PantallaAjustes()), + ), + ); + } + + testWidgets('settings rows show their current value: EQ on, favourite groups ' + 'count, preferred station name, custom stations count, sort order, ' + 'recordings count · size, and the current language', (tester) async { + setLargeSurface(tester); + _suppressListTileInkAssertion(); + + final estado = EstadoRadio( + audio: FakeServicioAudio(), + favoritos: FakeServicioFavoritos(), + radio: FakeServicioRadio(), + servicioEcualizador: FakeServicioEcualizador(), + servicioGrabacion: _FakeServicioGrabacionConArchivos(), + resolverArchivoCustom: archivoCustomVacio, + iniciarAutomaticamente: false, + ); + addTearDown(estado.dispose); + await estado.ecualizador.cargarPersistido(); + await estado.ecualizador.cambiarActivo(true); + + final favoritos = estado.favoritos as FakeServicioFavoritos; + await favoritos.crearGrupo('Rock'); + await favoritos.crearGrupo('Jazz'); + await estado.cargarGruposFavoritos(); + + final preferida = emisoraDemo(uuid: 'pref-1', nombre: 'Radio Horizonte'); + await favoritos.agregar(preferida); + await estado.cargarFavoritos(); + await estado.cambiarEmisoraPreferida(preferida); + await estado.ordenarFavoritos(OrdenEmisoras.calidad); + + final idioma = EstadoIdioma(); + // EstadoIdioma's constructor kicks off its own async `_cargar()` read + // from SharedPreferences; without waiting for it to settle first, it + // can resolve AFTER `seleccionarLocale` below and clobber the + // selection back to null (a real race, not a test flake). `tester. + // pump()`, NOT a bare `Future.delayed` — a real Timer/delay never + // fires inside `testWidgets`' fake-async zone without something + // driving fake time forward, and hangs the whole test. + await tester.pump(); + await idioma.seleccionarLocale(const Locale('en')); + + await tester.pumpWidget(buildAjustes(estado, idioma)); + await pumpStable(tester); + // Lets the recordings FutureBuilder resolve. + await tester.pump(); + + final l10n = await AppLocalizations.delegate.load(const Locale('en')); + + expect(find.text(l10n.equalizerActive), findsOneWidget); + // 3, not 2 — FakeServicioFavoritos seeds a protected "unassigned" + // group by default, on top of the 2 this test creates. + expect(find.text('3'), findsOneWidget); // favourite groups + expect(find.text('Radio Horizonte'), findsOneWidget); + expect(find.text(l10n.stationOrderByQuality), findsOneWidget); + expect(find.text('2 · 7 MB'), findsOneWidget); + expect(find.text('English'), findsOneWidget); + }); +}