diff --git a/lib/pantallas/pantalla_paises.dart b/lib/pantallas/pantalla_paises.dart index 31d7671..ac4444c 100644 --- a/lib/pantallas/pantalla_paises.dart +++ b/lib/pantallas/pantalla_paises.dart @@ -4,8 +4,8 @@ import 'package:provider/provider.dart'; import '../estado/estado_busqueda.dart'; import '../l10n/gen/app_localizations.dart'; import '../modelos/pais_radio.dart'; +import '../tema/pluriwave_theme.dart'; import '../tema/pluriwave_tokens.dart'; -import '../widgets/pluri_glass_surface.dart'; import '../widgets/pluri_layout.dart'; import '../widgets/pluri_push_scaffold.dart'; @@ -30,6 +30,12 @@ class PantallaPaises extends StatefulWidget { } class _PantallaPaisesState extends State { + /// Audit 5.7 (t4:252): the header's `search` action. Null means "not + /// searching" — an ephemeral UI concern (design's "State is for + /// ephemeral UI only" ruling), never persisted. + bool _buscando = false; + final _controladorBusqueda = TextEditingController(); + /// One representative country per app-supported locale (the same 13 /// locales as `pantalla_ajustes_idioma.dart`'s `_idiomas` list). "Tus /// idiomas" is named by the proposal/spec but its derivation is not @@ -65,22 +71,73 @@ class _PantallaPaisesState extends State { }); } + @override + void dispose() { + _controladorBusqueda.dispose(); + super.dispose(); + } + @override Widget build(BuildContext context) { final l10n = AppLocalizations.of(context); final estado = context.watch(); + final query = _controladorBusqueda.text.trim().toLowerCase(); + final paisesFiltrados = + query.isEmpty + ? estado.paises + : estado.paises + .where( + (p) => + p.nombre.toLowerCase().contains(query) || + p.codigoIso.toLowerCase().contains(query), + ) + .toList(); return PluriPushScaffold( title: l10n.countriesScreenTitle, + // Audit 5.7 (t4:252): a `search` header action -- toggles an inline + // filter field over the SAME country list, rather than a decorative + // no-op button. `PluriPushScaffold.titleOverride` stays reserved for + // its one documented exception (the player's "EN DIRECTO" pill) -- + // the search field lives in the body instead, not the AppBar title. + actions: [ + IconButton( + key: const ValueKey('countries-search-toggle'), + icon: Icon(_buscando ? Icons.close_rounded : Icons.search_rounded), + tooltip: l10n.navSearch, + onPressed: + () => setState(() { + _buscando = !_buscando; + if (!_buscando) _controladorBusqueda.clear(); + }), + ), + ], body: estado.cargandoPaises && estado.paises.isEmpty ? const Center(child: CircularProgressIndicator()) : ListView( padding: PluriLayout.pageContentPadding, children: [ - _seccionTusIdiomas(context, estado.paises, l10n), - const SizedBox(height: 16), - _seccionTodos(context, estado.paises, l10n), + if (_buscando) + Padding( + padding: const EdgeInsets.only(bottom: 12), + child: TextField( + key: const ValueKey('countries-search-field'), + controller: _controladorBusqueda, + autofocus: true, + decoration: InputDecoration( + hintText: l10n.countriesSearchHint, + prefixIcon: const Icon(Icons.search_rounded), + ), + onChanged: (_) => setState(() {}), + ), + ), + if (query.isEmpty) ...[ + _seccionTusIdiomas(context, estado.paises, l10n), + const SizedBox(height: 16), + _seccionTodos(context, estado.paises, l10n), + ] else + _seccionTodos(context, paisesFiltrados, l10n), ], ), ); @@ -104,31 +161,45 @@ class _PantallaPaisesState extends State { if (destacados.isEmpty) return const SizedBox.shrink(); - final theme = Theme.of(context); - return PluriGlassSurface( - padding: const EdgeInsets.all(12), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - l10n.countriesYourLanguagesTitle, - style: theme.textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.w900, - ), + final type = context.pluriType; + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Audit 5.4 (t4:254): an eyebrow OUTSIDE any card, title-tier + // (20px) padding -- was titleMedium w900 inside a PluriGlassSurface. + Padding( + padding: const EdgeInsets.fromLTRB( + PluriLayout.titleHorizontal, + 0, + PluriLayout.titleHorizontal, + 8, ), - const SizedBox(height: 8), - // Item 24 / audit 5.1 (t4:255-258): a column of tappable ISO - // rows, not a Wrap of non-interactive Chips. - for (final pais in destacados) - _FilaPais( - pais: pais, - l10n: l10n, - // Item 24 / audit 5.5 (t4:256): the first row is highlighted. - destacado: pais == destacados.first, - onTap: () => _seleccionar(pais), - ), - ], - ), + child: Text( + l10n.countriesYourLanguagesTitle, + style: type.eyebrowLabel, + ), + ), + Padding( + padding: const EdgeInsets.symmetric( + horizontal: PluriLayout.rowHorizontal, + ), + child: Column( + children: [ + // Item 24 / audit 5.1 (t4:255-258): a column of tappable ISO + // rows, not a Wrap of non-interactive Chips. + for (final pais in destacados) + _FilaPais( + pais: pais, + l10n: l10n, + // Item 24 / audit 5.5 (t4:256): the first row is + // highlighted. + destacado: pais == destacados.first, + onTap: () => _seleccionar(pais), + ), + ], + ), + ), + ], ); } @@ -137,26 +208,44 @@ class _PantallaPaisesState extends State { List paises, AppLocalizations l10n, ) { - final theme = Theme.of(context); - return PluriGlassSurface( - padding: const EdgeInsets.all(12), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - l10n.countriesAllTitle, - style: theme.textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.w900, - ), + final type = context.pluriType; + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Audit 5.4 (t4:260-261): "TODOS · 238" -- an eyebrow OUTSIDE any + // card, carrying the total country count, which never rendered + // anywhere before. + Padding( + padding: const EdgeInsets.fromLTRB( + PluriLayout.titleHorizontal, + 0, + PluriLayout.titleHorizontal, + 8, ), - const SizedBox(height: 4), - // Item 24 / audit 5.1-5.3 (t4:262-269): the same tappable ISO row - // as "Tus idiomas" -- not the previous ListTile, which had no ISO - // column and no onTap. - for (final pais in paises) - _FilaPais(pais: pais, l10n: l10n, onTap: () => _seleccionar(pais)), - ], - ), + child: Text( + '${l10n.countriesAllTitle} · ${paises.length}', + style: type.eyebrowLabel, + ), + ), + Padding( + padding: const EdgeInsets.symmetric( + horizontal: PluriLayout.rowHorizontal, + ), + child: Column( + children: [ + // Item 24 / audit 5.1-5.3 (t4:262-269): the same tappable ISO + // row as "Tus idiomas" -- not the previous ListTile, which + // had no ISO column and no onTap. + for (final pais in paises) + _FilaPais( + pais: pais, + l10n: l10n, + onTap: () => _seleccionar(pais), + ), + ], + ), + ), + ], ); } } diff --git a/test/pantallas/pantalla_paises_test.dart b/test/pantallas/pantalla_paises_test.dart index ba5a04c..1d15d9d 100644 --- a/test/pantallas/pantalla_paises_test.dart +++ b/test/pantallas/pantalla_paises_test.dart @@ -4,6 +4,7 @@ import 'package:pluriwave/estado/estado_busqueda.dart'; import 'package:pluriwave/l10n/gen/app_localizations.dart'; import 'package:pluriwave/modelos/pais_radio.dart'; import 'package:pluriwave/pantallas/pantalla_paises.dart'; +import 'package:pluriwave/widgets/pluri_glass_surface.dart'; import 'package:pluriwave/widgets/pluri_push_scaffold.dart'; import 'package:provider/provider.dart'; @@ -62,7 +63,9 @@ void main() { tester.element(find.byType(PantallaPaises)), ); expect(find.text(l10n.countriesYourLanguagesTitle), findsOneWidget); - expect(find.text(l10n.countriesAllTitle), findsOneWidget); + // Audit 5.4: "Todos" now carries the total count as part of the + // same eyebrow string ("{title} · {count}"), not a bare title. + expect(find.textContaining(l10n.countriesAllTitle), findsOneWidget); // Full alphabetical list: all 3 fetched countries render. expect(find.text('Argentina'), findsOneWidget); @@ -201,6 +204,66 @@ void main() { ); }); + group('visual fidelity (audit 5.4/5.7)', () { + testWidgets( + 'the section eyebrows sit OUTSIDE any card, styled as an eyebrow ' + 'label, and "Todos" carries the total country count (t4:254/260)', + (tester) async { + final estado = EstadoBusqueda( + radio: FakeServicioRadio( + paises: const [ + PaisRadio(nombre: 'Spain', codigoIso: 'ES', numeroEmisoras: 482), + PaisRadio( + nombre: 'Argentina', + codigoIso: 'AR', + numeroEmisoras: 120, + ), + PaisRadio(nombre: 'France', codigoIso: 'FR', numeroEmisoras: 75), + ], + ), + ); + addTearDown(estado.dispose); + + await tester.pumpWidget(buildScreen(estado)); + await pumpEstable(tester); + + final l10n = AppLocalizations.of( + tester.element(find.byType(PantallaPaises)), + ); + + final eyebrow = tester.widget( + find.text(l10n.countriesYourLanguagesTitle), + ); + expect(eyebrow.style?.fontSize, 11); + expect(eyebrow.style?.fontWeight, FontWeight.w800); + + expect( + find.ancestor( + of: find.text(l10n.countriesYourLanguagesTitle), + matching: find.byType(PluriGlassSurface), + ), + findsNothing, + reason: 'the eyebrow must not be a descendant of any card', + ); + + expect( + find.textContaining('${l10n.countriesAllTitle} · 3'), + findsOneWidget, + ); + }, + ); + + testWidgets('the header carries a search action (t4:252)', (tester) async { + final estado = EstadoBusqueda(radio: FakeServicioRadio(paises: const [])); + addTearDown(estado.dispose); + + await tester.pumpWidget(buildScreen(estado)); + await pumpEstable(tester); + + expect(find.byIcon(Icons.search_rounded), findsOneWidget); + }); + }); + testWidgets( 'cargarPaises no se re-dispara si ya hay datos en caché al reconstruir', (tester) async {