import 'package:flutter/material.dart'; 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_layout.dart'; import '../widgets/pluri_push_scaffold.dart'; /// WU7, `station-discovery-browse` spec — "Países Browser Over the Verified /// Countries Contract". Pure display over `EstadoBusqueda.cargarPaises()` /// (in-memory cache guard, so re-entering this screen never re-fetches): /// a "Tus idiomas" shortlist above the full alphabetical list, both with /// live station counts already parsed from the API's `stationcount` /// **string** field (`PaisRadio.fromApi`, Engram id 2500). class PantallaPaises extends StatefulWidget { const PantallaPaises({super.key, this.onPaisSeleccionado}); /// Item 24 / audit 5.2 (t4:256-269): every row is tappable (the prototype /// draws a `chevron_right` on all of them). Optional so this screen stays /// usable stand-alone; the one production call site /// (`pantalla_buscar.dart`'s "Explorar por" grid) wires this to filter /// search results by the tapped country and pop back. final ValueChanged? onPaisSeleccionado; @override State createState() => _PantallaPaisesState(); } 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 /// otherwise specified — this reuses the app's own existing language /// identity rather than inventing a separate curated country list. static const _paisPorIdioma = { 'en': 'US', 'es': 'ES', 'zh': 'CN', 'hi': 'IN', 'ar': 'SA', 'pt': 'PT', 'fr': 'FR', 'ru': 'RU', 'de': 'DE', 'ja': 'JP', 'id': 'ID', 'bn': 'BD', 'it': 'IT', }; @override void initState() { super.initState(); // `cargarPaises()` calls `notifyListeners()` before its first `await` // (its loading-flag flip) — doing that synchronously inside `initState` // would trigger "setState() or markNeedsBuild() called during build." // Deferred to a post-frame callback, matching the established pattern // for triggering a load from a screen's `initState`/`build` // (`pantalla_alarmas.dart`'s `_favoritosSolicitados` guard). WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted) context.read().cargarPaises(); }); } @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: [ 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), // Issue 3 (feedback-pruebas): t4:260 draws a 14px gap // between "Tus idiomas" and "Todos", not 16. const SizedBox( height: 14, key: ValueKey('paises-seccion-gap'), ), _seccionTodos(context, estado.paises, l10n), ] else _seccionTodos(context, paisesFiltrados, l10n), ], ), ); } void _seleccionar(PaisRadio pais) { widget.onPaisSeleccionado?.call(pais); } Widget _seccionTusIdiomas( BuildContext context, List paises, AppLocalizations l10n, ) { final porCodigo = {for (final p in paises) p.codigoIso: p}; final destacados = _paisPorIdioma.values .map((codigo) => porCodigo[codigo]) .whereType() .toList(); if (destacados.isEmpty) return const SizedBox.shrink(); 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, ), 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), ), ], ), ), ], ); } Widget _seccionTodos( BuildContext context, List paises, AppLocalizations l10n, ) { 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, ), 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), ), ], ), ), ], ); } } /// Item 24 / audit 5.1-5.3, 5.5 (t4:256-269): a tappable row -- ISO code /// column, name, station count, and a chevron -- shared by "Tus idiomas" /// and "Todos". [destacado] applies the t4:256 highlight (teal-tinted /// background, bold name) reserved for the very first "Tus idiomas" row. class _FilaPais extends StatelessWidget { const _FilaPais({ required this.pais, required this.l10n, this.destacado = false, this.onTap, }); final PaisRadio pais; final AppLocalizations l10n; final bool destacado; final VoidCallback? onTap; @override Widget build(BuildContext context) { final onSurface = Theme.of(context).colorScheme.onSurface; return DecoratedBox( decoration: BoxDecoration( color: destacado ? PluriWaveTokens.brand.withValues(alpha: 0.1) : Colors.transparent, borderRadius: BorderRadius.circular(14), ), child: Material( type: MaterialType.transparency, child: InkWell( borderRadius: BorderRadius.circular(14), onTap: onTap, child: Padding( padding: const EdgeInsets.all(12), child: Row( children: [ // t4:256-258: 26px/lh1, centred in a 34-wide column. SizedBox( width: 34, child: Text( pais.codigoIso, textAlign: TextAlign.center, style: const TextStyle(fontSize: 26, height: 1), ), ), const SizedBox(width: 14), Expanded( child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( pais.nombre, style: TextStyle( fontSize: 15.5, fontWeight: destacado ? FontWeight.w800 : FontWeight.w700, ), maxLines: 1, overflow: TextOverflow.ellipsis, ), const SizedBox(height: 2), Text( l10n.stationsCount(pais.numeroEmisoras), style: TextStyle( fontSize: 12, color: onSurface.withValues(alpha: 0.55), ), ), ], ), ), Icon( Icons.chevron_right_rounded, size: 20, color: onSurface.withValues(alpha: 0.4), ), ], ), ), ), ), ); } }