Audit 5.4 (t4:254/260): "Tus idiomas" and "Todos" eyebrows now sit
OUTSIDE any card at title-tier (20px) padding, styled with
eyebrowLabel -- were titleMedium w900 inside a PluriGlassSurface.
"Todos" also gains its missing total count ("{title} · {count}").
Audit 5.7 (t4:252): the header gained a real `search` action -- toggles
an inline TextField that filters the country list by name or ISO
code, not a decorative no-op button. New ARB key countriesSearchHint.
Item 5.6 (station count as a subtitle line, not a trailing widget) was
already fixed as an undocumented side effect of Tier3's row rebuild
(3303bd3) -- reconfirmed by direct read, no change needed here.
339 lines
12 KiB
Dart
339 lines
12 KiB
Dart
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<PaisRadio>? onPaisSeleccionado;
|
|
|
|
@override
|
|
State<PantallaPaises> createState() => _PantallaPaisesState();
|
|
}
|
|
|
|
class _PantallaPaisesState extends State<PantallaPaises> {
|
|
/// 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 = <String, String>{
|
|
'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<EstadoBusqueda>().cargarPaises();
|
|
});
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_controladorBusqueda.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final l10n = AppLocalizations.of(context);
|
|
final estado = context.watch<EstadoBusqueda>();
|
|
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),
|
|
const SizedBox(height: 16),
|
|
_seccionTodos(context, estado.paises, l10n),
|
|
] else
|
|
_seccionTodos(context, paisesFiltrados, l10n),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
void _seleccionar(PaisRadio pais) {
|
|
widget.onPaisSeleccionado?.call(pais);
|
|
}
|
|
|
|
Widget _seccionTusIdiomas(
|
|
BuildContext context,
|
|
List<PaisRadio> paises,
|
|
AppLocalizations l10n,
|
|
) {
|
|
final porCodigo = {for (final p in paises) p.codigoIso: p};
|
|
final destacados =
|
|
_paisPorIdioma.values
|
|
.map((codigo) => porCodigo[codigo])
|
|
.whereType<PaisRadio>()
|
|
.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<PaisRadio> 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),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|