refactor(state): extract recording and search state, scope screen rebuilds
- New EstadoGrabacion owns the recording service, subscription, directory/size preferences and open-file actions - New EstadoBusqueda owns search, nearby stations, pagination and the min-bitrate filter - New orden_emisoras.dart with the OrdenEmisoras enum, shared sorter and list identity memoization so context.select comparisons work on derived lists - Large screens (inicio, buscar, favoritos, ajustes, reproductor) consume scoped selects/dedicated notifiers instead of root context.watch<EstadoRadio>, so audio buffer events no longer rebuild whole screens - Remove all 15 TODO(S4b) compat members from EstadoRadio; consumers use the dedicated providers. EstadoRadio drops from ~1121 to 753 lines, keeping playback/stations/favorites orchestration - 8 new tests including a rebuild-scoping probe (110 total green), flutter analyze clean
This commit is contained in:
@@ -9,6 +9,7 @@ import 'package:share_plus/share_plus.dart' show Share, XFile;
|
||||
import 'package:uuid/uuid.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';
|
||||
@@ -85,7 +86,7 @@ class _SeccionGrabaciones extends StatelessWidget {
|
||||
const _SeccionGrabaciones();
|
||||
|
||||
Future<void> _seleccionarRuta(BuildContext context) async {
|
||||
final estado = context.read<EstadoRadio>();
|
||||
final estado = context.read<EstadoGrabacion>();
|
||||
final messenger = ScaffoldMessenger.of(context);
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final ruta = await FilePicker.platform.getDirectoryPath(
|
||||
@@ -93,7 +94,7 @@ class _SeccionGrabaciones extends StatelessWidget {
|
||||
);
|
||||
if (ruta == null) return;
|
||||
try {
|
||||
await estado.cambiarDirectorioGrabacion(ruta);
|
||||
await estado.cambiarDirectorio(ruta);
|
||||
if (!context.mounted) return;
|
||||
messenger.showSnackBar(
|
||||
SnackBar(content: Text(l10n.recordingsPathUpdated)),
|
||||
@@ -107,10 +108,10 @@ class _SeccionGrabaciones extends StatelessWidget {
|
||||
}
|
||||
|
||||
Future<void> _restaurarRuta(BuildContext context) async {
|
||||
final estado = context.read<EstadoRadio>();
|
||||
final estado = context.read<EstadoGrabacion>();
|
||||
final messenger = ScaffoldMessenger.of(context);
|
||||
final l10n = AppLocalizations.of(context);
|
||||
await estado.restaurarDirectorioGrabacion();
|
||||
await estado.restaurarDirectorio();
|
||||
if (!context.mounted) return;
|
||||
messenger.showSnackBar(
|
||||
SnackBar(content: Text(l10n.recordingsDefaultFolderRestored)),
|
||||
@@ -118,11 +119,11 @@ class _SeccionGrabaciones extends StatelessWidget {
|
||||
}
|
||||
|
||||
Future<void> _abrirCarpeta(BuildContext context) async {
|
||||
final estado = context.read<EstadoRadio>();
|
||||
final estado = context.read<EstadoGrabacion>();
|
||||
final messenger = ScaffoldMessenger.of(context);
|
||||
final l10n = AppLocalizations.of(context);
|
||||
try {
|
||||
final abierto = await estado.abrirDirectorioGrabacion();
|
||||
final abierto = await estado.abrirDirectorio();
|
||||
if (!context.mounted) return;
|
||||
if (!abierto) {
|
||||
messenger.showSnackBar(
|
||||
@@ -138,9 +139,9 @@ class _SeccionGrabaciones extends StatelessWidget {
|
||||
}
|
||||
|
||||
Future<void> _editarTamanoMaximo(BuildContext context) async {
|
||||
final estado = context.read<EstadoRadio>();
|
||||
final estado = context.read<EstadoGrabacion>();
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final actualMb = _bytesAMegabytes(estado.maxBytesGrabacion);
|
||||
final actualMb = _bytesAMegabytes(estado.maxBytes);
|
||||
final controller = TextEditingController(text: actualMb.toString());
|
||||
|
||||
final nuevoMb = await showModalBottomSheet<int>(
|
||||
@@ -186,7 +187,7 @@ class _SeccionGrabaciones extends StatelessWidget {
|
||||
);
|
||||
controller.dispose();
|
||||
if (nuevoMb == null || !context.mounted) return;
|
||||
await estado.cambiarMaxBytesGrabacion(nuevoMb * 1024 * 1024);
|
||||
await estado.cambiarMaxBytes(nuevoMb * 1024 * 1024);
|
||||
if (!context.mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(l10n.recordingsMaxSizeSaved(nuevoMb))),
|
||||
@@ -198,7 +199,9 @@ class _SeccionGrabaciones extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final estado = context.watch<EstadoRadio>();
|
||||
// Recording state lives in EstadoGrabacion (S4-R2): this section only
|
||||
// rebuilds on recording changes, never on playback notifications.
|
||||
final estado = context.watch<EstadoGrabacion>();
|
||||
final l10n = AppLocalizations.of(context);
|
||||
|
||||
return PluriGlassSurface(
|
||||
@@ -216,7 +219,7 @@ class _SeccionGrabaciones extends StatelessWidget {
|
||||
],
|
||||
),
|
||||
FutureBuilder<String>(
|
||||
future: estado.directorioGrabacionEfectivo(),
|
||||
future: estado.directorioEfectivo(),
|
||||
builder:
|
||||
(ctx, snap) => ListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
@@ -256,9 +259,7 @@ class _SeccionGrabaciones extends StatelessWidget {
|
||||
leading: const Icon(Icons.sd_storage_rounded),
|
||||
title: Text(l10n.recordingsMaxSizeTitle),
|
||||
subtitle: Text(
|
||||
l10n.recordingsMaxSizeSubtitle(
|
||||
_bytesAMegabytes(estado.maxBytesGrabacion),
|
||||
),
|
||||
l10n.recordingsMaxSizeSubtitle(_bytesAMegabytes(estado.maxBytes)),
|
||||
),
|
||||
onTap: () => _editarTamanoMaximo(context),
|
||||
),
|
||||
@@ -301,8 +302,10 @@ class _SeccionTimerSueno extends StatelessWidget {
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
|
||||
final estado = context.watch<EstadoRadio>();
|
||||
final presets = estado.timerSuenoPresetsSegundos;
|
||||
// S4-R5: scoped select — rebuilds only when the presets list changes.
|
||||
final presets = context.select<EstadoRadio, List<int>>(
|
||||
(e) => e.timerSuenoPresetsSegundos,
|
||||
);
|
||||
return PluriGlassSurface(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
@@ -654,7 +657,10 @@ class _SeccionOrdenListas extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final estado = context.watch<EstadoRadio>();
|
||||
// S4-R5: scoped select — rebuilds only when the ordering changes.
|
||||
final orden = context.select<EstadoRadio, OrdenEmisoras>(
|
||||
(e) => e.ordenListas,
|
||||
);
|
||||
final l10n = AppLocalizations.of(context);
|
||||
return PluriGlassSurface(
|
||||
child: Column(
|
||||
@@ -684,9 +690,9 @@ class _SeccionOrdenListas extends StatelessWidget {
|
||||
label: Text(l10n.stationOrderByQuality),
|
||||
),
|
||||
],
|
||||
selected: {estado.ordenListas},
|
||||
selected: {orden},
|
||||
onSelectionChanged: (value) {
|
||||
estado.cambiarOrdenListas(value.first);
|
||||
context.read<EstadoRadio>().cambiarOrdenListas(value.first);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
@@ -790,9 +796,11 @@ class _SeccionGruposFavoritos extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final estado = context.watch<EstadoRadio>();
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final grupos = estado.gruposFavoritos;
|
||||
// S4-R5: scoped select — rebuilds only when the groups list changes.
|
||||
final grupos = context.select<EstadoRadio, List<GrupoFavoritos>>(
|
||||
(e) => e.gruposFavoritos,
|
||||
);
|
||||
return PluriGlassSurface(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
@@ -858,11 +866,18 @@ class _SeccionEmisoraPreferida extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final estado = context.watch<EstadoRadio>();
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final favoritas = estado.listaFavoritos;
|
||||
final preferida = estado.emisoraPreferida;
|
||||
final opciones = _opciones(estado, preferida);
|
||||
// S4-R5: scoped selects over identity-memoized getters.
|
||||
final favoritas = context.select<EstadoRadio, List<Emisora>>(
|
||||
(e) => e.listaFavoritos,
|
||||
);
|
||||
final disponibles = context.select<EstadoRadio, List<Emisora>>(
|
||||
(e) => e.emisorasDisponiblesPreferencia,
|
||||
);
|
||||
final preferida = context.select<EstadoRadio, Emisora?>(
|
||||
(e) => e.emisoraPreferida,
|
||||
);
|
||||
final opciones = _opciones(favoritas, disponibles, preferida);
|
||||
|
||||
return PluriGlassSurface(
|
||||
child: Column(
|
||||
@@ -947,11 +962,12 @@ class _SeccionEmisoraPreferida extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
List<Emisora> _opciones(EstadoRadio estado, Emisora? preferida) {
|
||||
final base =
|
||||
estado.listaFavoritos.isNotEmpty
|
||||
? estado.listaFavoritos
|
||||
: estado.emisorasDisponiblesPreferencia;
|
||||
List<Emisora> _opciones(
|
||||
List<Emisora> favoritas,
|
||||
List<Emisora> disponibles,
|
||||
Emisora? preferida,
|
||||
) {
|
||||
final base = favoritas.isNotEmpty ? favoritas : disponibles;
|
||||
final mapa = <String, Emisora>{
|
||||
for (final emisora in base) emisora.uuid: emisora,
|
||||
};
|
||||
@@ -967,8 +983,10 @@ class _SeccionEmisoras extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final estado = context.watch<EstadoRadio>();
|
||||
final custom = estado.emisorasCustom;
|
||||
// S4-R5: scoped select — rebuilds only when the custom list changes.
|
||||
final custom = context.select<EstadoRadio, List<Emisora>>(
|
||||
(e) => e.emisorasCustom,
|
||||
);
|
||||
|
||||
return PluriGlassSurface(
|
||||
child: Column(
|
||||
|
||||
@@ -2,7 +2,7 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter_animate/flutter_animate.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../estado/estado_radio.dart';
|
||||
import '../estado/estado_busqueda.dart';
|
||||
import '../l10n/gen/app_localizations.dart';
|
||||
import '../widgets/pluri_glass_surface.dart';
|
||||
import '../widgets/pluri_icon.dart';
|
||||
@@ -58,7 +58,7 @@ class _PantallaBuscarState extends State<PantallaBuscar> {
|
||||
|
||||
void _buscar() {
|
||||
final q = _controller.text.trim();
|
||||
context.read<EstadoRadio>().buscar(
|
||||
context.read<EstadoBusqueda>().buscar(
|
||||
nombre: q.isNotEmpty ? q : null,
|
||||
pais: _paisSeleccionado,
|
||||
idioma: _idiomaSeleccionado,
|
||||
@@ -68,7 +68,9 @@ class _PantallaBuscarState extends State<PantallaBuscar> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final estado = context.watch<EstadoRadio>();
|
||||
// S4-R3/S4-R5: this screen depends only on search state, so it watches
|
||||
// the dedicated notifier — playback events no longer rebuild it.
|
||||
final estado = context.watch<EstadoBusqueda>();
|
||||
final theme = Theme.of(context);
|
||||
final l10n = AppLocalizations.of(context);
|
||||
|
||||
@@ -85,7 +87,12 @@ class _PantallaBuscarState extends State<PantallaBuscar> {
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(PluriLayout.horizontal, 10, PluriLayout.horizontal, 0),
|
||||
padding: const EdgeInsets.fromLTRB(
|
||||
PluriLayout.horizontal,
|
||||
10,
|
||||
PluriLayout.horizontal,
|
||||
0,
|
||||
),
|
||||
child: PluriGlassSurface(
|
||||
padding: const EdgeInsets.all(10),
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
@@ -132,7 +139,13 @@ class _PantallaBuscarState extends State<PantallaBuscar> {
|
||||
),
|
||||
_seccionFiltroInt(
|
||||
l10n.searchMinQualityFilterLabel,
|
||||
const [('64 kbps', 64), ('96 kbps', 96), ('128 kbps', 128), ('192 kbps', 192), ('320 kbps', 320)],
|
||||
const [
|
||||
('64 kbps', 64),
|
||||
('96 kbps', 96),
|
||||
('128 kbps', 128),
|
||||
('192 kbps', 192),
|
||||
('320 kbps', 320),
|
||||
],
|
||||
_calidadMinima,
|
||||
(v) {
|
||||
setState(() => _calidadMinima = v);
|
||||
@@ -144,7 +157,6 @@ class _PantallaBuscarState extends State<PantallaBuscar> {
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Widget _seccionFiltro(
|
||||
String titulo,
|
||||
List<(String, String)> opciones,
|
||||
@@ -153,7 +165,12 @@ class _PantallaBuscarState extends State<PantallaBuscar> {
|
||||
) {
|
||||
final theme = Theme.of(context);
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(PluriLayout.horizontal, 8, PluriLayout.horizontal, 0),
|
||||
padding: const EdgeInsets.fromLTRB(
|
||||
PluriLayout.horizontal,
|
||||
8,
|
||||
PluriLayout.horizontal,
|
||||
0,
|
||||
),
|
||||
child: PluriGlassSurface(
|
||||
padding: const EdgeInsets.all(10),
|
||||
child: Column(
|
||||
@@ -198,7 +215,12 @@ class _PantallaBuscarState extends State<PantallaBuscar> {
|
||||
) {
|
||||
final theme = Theme.of(context);
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(PluriLayout.horizontal, 8, PluriLayout.horizontal, 0),
|
||||
padding: const EdgeInsets.fromLTRB(
|
||||
PluriLayout.horizontal,
|
||||
8,
|
||||
PluriLayout.horizontal,
|
||||
0,
|
||||
),
|
||||
child: PluriGlassSurface(
|
||||
padding: const EdgeInsets.all(10),
|
||||
child: Column(
|
||||
@@ -235,16 +257,16 @@ class _PantallaBuscarState extends State<PantallaBuscar> {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _resultados(EstadoRadio estado, ThemeData theme) {
|
||||
Widget _resultados(EstadoBusqueda estado, ThemeData theme) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
if (estado.cargandoBusqueda) {
|
||||
if (estado.cargando) {
|
||||
return const SizedBox(
|
||||
height: 220,
|
||||
child: Center(child: CircularProgressIndicator()),
|
||||
);
|
||||
}
|
||||
|
||||
final resultados = estado.resultadosBusqueda;
|
||||
final resultados = estado.resultados;
|
||||
|
||||
if (resultados.isEmpty) {
|
||||
final sinFiltros =
|
||||
@@ -255,8 +277,7 @@ class _PantallaBuscarState extends State<PantallaBuscar> {
|
||||
height: 260,
|
||||
child: PluriEmptyState(
|
||||
glyph: PluriIconGlyph.search,
|
||||
title:
|
||||
sinFiltros ? l10n.searchEmptyTitle : l10n.searchNoResultsTitle,
|
||||
title: sinFiltros ? l10n.searchEmptyTitle : l10n.searchNoResultsTitle,
|
||||
subtitle:
|
||||
sinFiltros
|
||||
? l10n.searchEmptySubtitle
|
||||
@@ -265,7 +286,7 @@ class _PantallaBuscarState extends State<PantallaBuscar> {
|
||||
);
|
||||
}
|
||||
|
||||
final total = resultados.length + (estado.hayMasBusqueda ? 1 : 0);
|
||||
final total = resultados.length + (estado.hayMas ? 1 : 0);
|
||||
return ListView.separated(
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
@@ -274,16 +295,16 @@ class _PantallaBuscarState extends State<PantallaBuscar> {
|
||||
separatorBuilder: (_, __) => const SizedBox(height: 10),
|
||||
itemBuilder: (context, i) {
|
||||
if (i >= resultados.length) {
|
||||
if (!estado.cargandoMasBusqueda) {
|
||||
Future<void>.microtask(estado.cargarMasBusqueda);
|
||||
if (!estado.cargandoMas) {
|
||||
Future<void>.microtask(estado.cargarMas);
|
||||
}
|
||||
return const Padding(
|
||||
padding: EdgeInsets.all(18),
|
||||
child: Center(child: CircularProgressIndicator()),
|
||||
);
|
||||
}
|
||||
if (i >= resultados.length - 5 && estado.hayMasBusqueda) {
|
||||
Future<void>.microtask(estado.cargarMasBusqueda);
|
||||
if (i >= resultados.length - 5 && estado.hayMas) {
|
||||
Future<void>.microtask(estado.cargarMas);
|
||||
}
|
||||
return TarjetaEmisora(
|
||||
emisora: resultados[i],
|
||||
|
||||
@@ -19,9 +19,15 @@ class PantallaFavoritos extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final estado = context.watch<EstadoRadio>();
|
||||
final favoritos = estado.listaFavoritos;
|
||||
final grupos = estado.gruposFavoritos;
|
||||
// S4-R5: no root watch — select only the fields this screen reads. The
|
||||
// getters are identity-memoized, so playback notifications that do not
|
||||
// change favorites/groups no longer rebuild the screen.
|
||||
final favoritos = context.select<EstadoRadio, List<Emisora>>(
|
||||
(e) => e.listaFavoritos,
|
||||
);
|
||||
final grupos = context.select<EstadoRadio, List<GrupoFavoritos>>(
|
||||
(e) => e.gruposFavoritos,
|
||||
);
|
||||
final l10n = AppLocalizations.of(context);
|
||||
|
||||
if (favoritos.isEmpty) {
|
||||
@@ -49,16 +55,17 @@ class PantallaFavoritos extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
final gruposVisibles = grupos.isEmpty
|
||||
? [
|
||||
GrupoFavoritos(
|
||||
id: GrupoFavoritos.sinAsignarId,
|
||||
nombre: l10n.favoriteGroupsUnassigned,
|
||||
orden: 0,
|
||||
protegido: true,
|
||||
),
|
||||
]
|
||||
: grupos;
|
||||
final gruposVisibles =
|
||||
grupos.isEmpty
|
||||
? [
|
||||
GrupoFavoritos(
|
||||
id: GrupoFavoritos.sinAsignarId,
|
||||
nombre: l10n.favoriteGroupsUnassigned,
|
||||
orden: 0,
|
||||
protegido: true,
|
||||
),
|
||||
]
|
||||
: grupos;
|
||||
|
||||
return CustomScrollView(
|
||||
slivers: [
|
||||
@@ -86,9 +93,10 @@ class PantallaFavoritos extends StatelessWidget {
|
||||
_GrupoFavoritosPanel(
|
||||
grupo: grupo,
|
||||
grupos: gruposVisibles,
|
||||
emisoras: favoritos
|
||||
.where((e) => e.grupoFavoritosId == grupo.id)
|
||||
.toList(),
|
||||
emisoras:
|
||||
favoritos
|
||||
.where((e) => e.grupoFavoritosId == grupo.id)
|
||||
.toList(),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
],
|
||||
@@ -125,7 +133,9 @@ class _GrupoFavoritosPanel extends StatelessWidget {
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(grupo.esSinAsignar ? Icons.lock_rounded : Icons.folder_rounded),
|
||||
Icon(
|
||||
grupo.esSinAsignar ? Icons.lock_rounded : Icons.folder_rounded,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
@@ -181,30 +191,31 @@ class _FavoritoItem extends StatelessWidget {
|
||||
final seleccionado = await showModalBottomSheet<String>(
|
||||
context: context,
|
||||
showDragHandle: true,
|
||||
builder: (ctx) => SafeArea(
|
||||
child: ListView(
|
||||
shrinkWrap: true,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 4, 20, 12),
|
||||
child: Text(
|
||||
l10n.favoriteGroupsAssign,
|
||||
style: Theme.of(ctx).textTheme.titleLarge,
|
||||
),
|
||||
),
|
||||
for (final grupo in grupos)
|
||||
ListTile(
|
||||
leading: Icon(
|
||||
grupo.id == emisora.grupoFavoritosId
|
||||
? Icons.radio_button_checked_rounded
|
||||
: Icons.radio_button_off_rounded,
|
||||
builder:
|
||||
(ctx) => SafeArea(
|
||||
child: ListView(
|
||||
shrinkWrap: true,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 4, 20, 12),
|
||||
child: Text(
|
||||
l10n.favoriteGroupsAssign,
|
||||
style: Theme.of(ctx).textTheme.titleLarge,
|
||||
),
|
||||
),
|
||||
title: Text(_nombreVisible(l10n, grupo)),
|
||||
onTap: () => Navigator.pop(ctx, grupo.id),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
for (final grupo in grupos)
|
||||
ListTile(
|
||||
leading: Icon(
|
||||
grupo.id == emisora.grupoFavoritosId
|
||||
? Icons.radio_button_checked_rounded
|
||||
: Icons.radio_button_off_rounded,
|
||||
),
|
||||
title: Text(_nombreVisible(l10n, grupo)),
|
||||
onTap: () => Navigator.pop(ctx, grupo.id),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
if (seleccionado == null || !context.mounted) return;
|
||||
await context.read<EstadoRadio>().asignarGrupoFavorito(
|
||||
|
||||
@@ -3,8 +3,10 @@ import 'package:flutter_animate/flutter_animate.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:shimmer/shimmer.dart' as shimmer;
|
||||
|
||||
import '../estado/estado_busqueda.dart';
|
||||
import '../estado/estado_radio.dart';
|
||||
import '../l10n/gen/app_localizations.dart';
|
||||
import '../modelos/emisora.dart';
|
||||
import '../widgets/pluri_glass_surface.dart';
|
||||
import '../widgets/pluri_icon.dart';
|
||||
import '../widgets/pluri_layout.dart';
|
||||
@@ -40,20 +42,25 @@ class _PantallaInicioState extends State<PantallaInicio> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final estado = context.watch<EstadoRadio>();
|
||||
// S4-R5: no root watch on EstadoRadio. Every field is consumed through
|
||||
// context.select over identity-memoized getters, so audio buffer events
|
||||
// (which notify EstadoRadio) no longer rebuild this screen.
|
||||
final theme = Theme.of(context);
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final error = context.select<EstadoRadio, String?>((e) => e.error);
|
||||
|
||||
return RefreshIndicator(
|
||||
onRefresh: estado.cargarPopulares,
|
||||
onRefresh: () => context.read<EstadoRadio>().cargarPopulares(),
|
||||
child: CustomScrollView(
|
||||
slivers: [
|
||||
SliverToBoxAdapter(child: _heroHeader(context, estado, l10n)),
|
||||
SliverToBoxAdapter(child: _seccionCercanas(estado, theme, l10n)),
|
||||
SliverToBoxAdapter(child: _seccionTendencias(estado, theme, l10n)),
|
||||
SliverToBoxAdapter(child: _heroHeader(context, l10n)),
|
||||
SliverToBoxAdapter(child: _seccionCercanas(context, theme, l10n)),
|
||||
SliverToBoxAdapter(child: _seccionTendencias(context, theme, l10n)),
|
||||
SliverToBoxAdapter(child: _chipGeneros(context, theme, l10n)),
|
||||
if (estado.error != null)
|
||||
SliverToBoxAdapter(child: _errorBanner(estado, theme, l10n)),
|
||||
if (error != null)
|
||||
SliverToBoxAdapter(
|
||||
child: _errorBanner(context, error, theme, l10n),
|
||||
),
|
||||
SliverPadding(
|
||||
padding: const EdgeInsets.fromLTRB(
|
||||
PluriLayout.horizontal,
|
||||
@@ -61,30 +68,29 @@ class _PantallaInicioState extends State<PantallaInicio> {
|
||||
PluriLayout.horizontal,
|
||||
PluriLayout.bottomChromeInset,
|
||||
),
|
||||
sliver: _gridEmisoras(estado, l10n),
|
||||
sliver: _gridEmisoras(context, l10n),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _heroHeader(
|
||||
BuildContext context,
|
||||
EstadoRadio estado,
|
||||
AppLocalizations l10n,
|
||||
) {
|
||||
Widget _heroHeader(BuildContext context, AppLocalizations l10n) {
|
||||
final totalEmisoras = context.select<EstadoRadio, int>(
|
||||
(e) => e.emisorasInicio.length,
|
||||
);
|
||||
return PluriScreenHeader(
|
||||
title: l10n.appTitle,
|
||||
subtitle: l10n.homeScreenSubtitle,
|
||||
glyph: PluriIconGlyph.home,
|
||||
primaryActionLabel: l10n.exploreStations,
|
||||
onPrimaryAction: estado.cargarPopulares,
|
||||
onPrimaryAction: () => context.read<EstadoRadio>().cargarPopulares(),
|
||||
trailing: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
PluriStatusPill(
|
||||
icon: Icons.public_rounded,
|
||||
label: l10n.stationsCount(estado.emisorasInicio.length),
|
||||
label: l10n.stationsCount(totalEmisoras),
|
||||
accent: Theme.of(context).colorScheme.secondary,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
@@ -95,11 +101,13 @@ class _PantallaInicioState extends State<PantallaInicio> {
|
||||
}
|
||||
|
||||
Widget _seccionCercanas(
|
||||
EstadoRadio estado,
|
||||
BuildContext context,
|
||||
ThemeData theme,
|
||||
AppLocalizations l10n,
|
||||
) {
|
||||
final pais = estado.paisCercanoDetectado;
|
||||
// Nearby stations live in EstadoBusqueda (S4-R3).
|
||||
final busqueda = context.watch<EstadoBusqueda>();
|
||||
final pais = busqueda.paisCercanoDetectado;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(
|
||||
PluriLayout.horizontal,
|
||||
@@ -124,11 +132,11 @@ class _PantallaInicioState extends State<PantallaInicio> {
|
||||
),
|
||||
TextButton.icon(
|
||||
onPressed:
|
||||
estado.cargandoCercanas
|
||||
busqueda.cargandoCercanas
|
||||
? null
|
||||
: estado.cargarEmisorasCercanas,
|
||||
: busqueda.cargarEmisorasCercanas,
|
||||
icon:
|
||||
estado.cargandoCercanas
|
||||
busqueda.cargandoCercanas
|
||||
? const SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
@@ -139,23 +147,23 @@ class _PantallaInicioState extends State<PantallaInicio> {
|
||||
),
|
||||
],
|
||||
),
|
||||
if (estado.errorCercanas != null)
|
||||
if (busqueda.errorCercanas != null)
|
||||
Text(
|
||||
estado.errorCercanas!,
|
||||
busqueda.errorCercanas!,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.error,
|
||||
),
|
||||
),
|
||||
if (estado.emisorasCercanas.isNotEmpty) ...[
|
||||
if (busqueda.cercanas.isNotEmpty) ...[
|
||||
const SizedBox(height: 8),
|
||||
SizedBox(
|
||||
height: 76,
|
||||
child: ListView.separated(
|
||||
scrollDirection: Axis.horizontal,
|
||||
itemCount: estado.emisorasCercanas.length,
|
||||
itemCount: busqueda.cercanas.length,
|
||||
separatorBuilder: (_, __) => const SizedBox(width: 8),
|
||||
itemBuilder: (context, i) {
|
||||
final emisora = estado.emisorasCercanas[i];
|
||||
final emisora = busqueda.cercanas[i];
|
||||
return SizedBox(
|
||||
width: 260,
|
||||
child: TarjetaEmisora(
|
||||
@@ -175,10 +183,16 @@ class _PantallaInicioState extends State<PantallaInicio> {
|
||||
}
|
||||
|
||||
Widget _seccionTendencias(
|
||||
EstadoRadio estado,
|
||||
BuildContext context,
|
||||
ThemeData theme,
|
||||
AppLocalizations l10n,
|
||||
) {
|
||||
final cargando = context.select<EstadoRadio, bool>(
|
||||
(e) => e.cargandoPopulares,
|
||||
);
|
||||
final tendencias = context.select<EstadoRadio, List<Emisora>>(
|
||||
(e) => e.tendencias,
|
||||
);
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(
|
||||
PluriLayout.horizontal,
|
||||
@@ -196,7 +210,7 @@ class _PantallaInicioState extends State<PantallaInicio> {
|
||||
SizedBox(
|
||||
height: 56,
|
||||
child:
|
||||
estado.cargandoPopulares
|
||||
cargando
|
||||
? ListView.separated(
|
||||
scrollDirection: Axis.horizontal,
|
||||
itemCount: 5,
|
||||
@@ -205,10 +219,10 @@ class _PantallaInicioState extends State<PantallaInicio> {
|
||||
)
|
||||
: ListView.separated(
|
||||
scrollDirection: Axis.horizontal,
|
||||
itemCount: estado.tendencias.length,
|
||||
itemCount: tendencias.length,
|
||||
separatorBuilder: (_, __) => const SizedBox(width: 8),
|
||||
itemBuilder: (context, i) {
|
||||
final e = estado.tendencias[i];
|
||||
final e = tendencias[i];
|
||||
return ActionChip(
|
||||
avatar: const Icon(
|
||||
Icons.graphic_eq_rounded,
|
||||
@@ -259,7 +273,7 @@ class _PantallaInicioState extends State<PantallaInicio> {
|
||||
_generoSeleccionado = seleccionado ? null : g;
|
||||
});
|
||||
if (!seleccionado) {
|
||||
context.read<EstadoRadio>().buscar(tag: g);
|
||||
context.read<EstadoBusqueda>().buscar(tag: g);
|
||||
} else {
|
||||
context.read<EstadoRadio>().cargarPopulares();
|
||||
}
|
||||
@@ -274,7 +288,8 @@ class _PantallaInicioState extends State<PantallaInicio> {
|
||||
}
|
||||
|
||||
Widget _errorBanner(
|
||||
EstadoRadio estado,
|
||||
BuildContext context,
|
||||
String error,
|
||||
ThemeData theme,
|
||||
AppLocalizations l10n,
|
||||
) {
|
||||
@@ -286,9 +301,9 @@ class _PantallaInicioState extends State<PantallaInicio> {
|
||||
children: [
|
||||
Icon(Icons.wifi_off, color: theme.colorScheme.error),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(child: Text(estado.error!)),
|
||||
Expanded(child: Text(error)),
|
||||
TextButton(
|
||||
onPressed: estado.cargarPopulares,
|
||||
onPressed: () => context.read<EstadoRadio>().cargarPopulares(),
|
||||
child: Text(l10n.retryAction),
|
||||
),
|
||||
],
|
||||
@@ -297,14 +312,17 @@ class _PantallaInicioState extends State<PantallaInicio> {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _gridEmisoras(EstadoRadio estado, AppLocalizations l10n) {
|
||||
Widget _gridEmisoras(BuildContext context, AppLocalizations l10n) {
|
||||
final porGenero = _generoSeleccionado != null;
|
||||
final emisoras =
|
||||
_generoSeleccionado != null
|
||||
? estado.resultadosBusqueda
|
||||
: estado.emisorasInicio;
|
||||
porGenero
|
||||
? context.select<EstadoBusqueda, List<Emisora>>((b) => b.resultados)
|
||||
: context.select<EstadoRadio, List<Emisora>>(
|
||||
(e) => e.emisorasInicio,
|
||||
);
|
||||
final cargando =
|
||||
estado.cargandoPopulares ||
|
||||
(_generoSeleccionado != null && estado.cargandoBusqueda);
|
||||
context.select<EstadoRadio, bool>((e) => e.cargandoPopulares) ||
|
||||
(porGenero && context.select<EstadoBusqueda, bool>((b) => b.cargando));
|
||||
|
||||
if (cargando) {
|
||||
return SliverGrid(
|
||||
|
||||
@@ -5,6 +5,7 @@ import 'package:provider/provider.dart';
|
||||
import 'package:shimmer/shimmer.dart';
|
||||
|
||||
import '../estado/estado_ecualizador.dart';
|
||||
import '../estado/estado_grabacion.dart';
|
||||
import '../estado/estado_radio.dart';
|
||||
import '../l10n/gen/app_localizations.dart';
|
||||
import '../modelos/emisora.dart';
|
||||
@@ -177,7 +178,7 @@ class _PantallaReproductorState extends State<PantallaReproductor>
|
||||
emisora: emisoraActiva,
|
||||
).animate().fadeIn(delay: 300.ms).slideY(begin: 0.3),
|
||||
const SizedBox(height: 14),
|
||||
_GrabacionWidget(estado: estado).animate().fadeIn(delay: 360.ms),
|
||||
const _GrabacionWidget().animate().fadeIn(delay: 360.ms),
|
||||
const SizedBox(height: 14),
|
||||
_TimerWidget(estado: estado).animate().fadeIn(delay: 400.ms),
|
||||
const SizedBox(height: 16),
|
||||
@@ -358,16 +359,18 @@ class _InfoChips extends StatelessWidget {
|
||||
}
|
||||
|
||||
class _GrabacionWidget extends StatelessWidget {
|
||||
final EstadoRadio estado;
|
||||
const _GrabacionWidget({required this.estado});
|
||||
// Recording state lives in EstadoGrabacion (S4-R2); EstadoRadio no longer
|
||||
// notifies on recording progress, so this widget watches the new notifier.
|
||||
const _GrabacionWidget();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final theme = Theme.of(context);
|
||||
final grabacion = estado.estadoGrabacion;
|
||||
final estado = context.watch<EstadoGrabacion>();
|
||||
final grabacion = estado.estado;
|
||||
final activa = grabacion.activa;
|
||||
final hayUltimaGrabacion = estado.ultimaGrabacion != null;
|
||||
final hayUltimaGrabacion = estado.ultimoArchivo != null;
|
||||
|
||||
return PluriGlassSurface(
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
@@ -416,7 +419,7 @@ class _GrabacionWidget extends StatelessWidget {
|
||||
label: Text(activa ? l10n.stopAction : l10n.recordAction),
|
||||
onPressed:
|
||||
activa
|
||||
? estado.detenerGrabacion
|
||||
? estado.detener
|
||||
: () => _mostrarDialogoGrabacion(context),
|
||||
),
|
||||
if (!activa)
|
||||
@@ -440,7 +443,8 @@ class _GrabacionWidget extends StatelessWidget {
|
||||
|
||||
Future<void> _abrirUltimaGrabacion(BuildContext context) async {
|
||||
final messenger = ScaffoldMessenger.of(context);
|
||||
final abierto = await estado.abrirUltimaGrabacion();
|
||||
final abierto =
|
||||
await context.read<EstadoGrabacion>().abrirUltimaGrabacion();
|
||||
if (!context.mounted) return;
|
||||
if (!abierto) {
|
||||
messenger.showSnackBar(
|
||||
@@ -453,7 +457,7 @@ class _GrabacionWidget extends StatelessWidget {
|
||||
|
||||
Future<void> _abrirCarpetaGrabaciones(BuildContext context) async {
|
||||
final messenger = ScaffoldMessenger.of(context);
|
||||
final abierto = await estado.abrirDirectorioGrabacion();
|
||||
final abierto = await context.read<EstadoGrabacion>().abrirDirectorio();
|
||||
if (!context.mounted) return;
|
||||
if (!abierto) {
|
||||
messenger.showSnackBar(
|
||||
@@ -467,6 +471,7 @@ class _GrabacionWidget extends StatelessWidget {
|
||||
}
|
||||
|
||||
void _mostrarDialogoGrabacion(BuildContext context) {
|
||||
final grabacion = context.read<EstadoGrabacion>();
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
builder:
|
||||
@@ -495,7 +500,7 @@ class _GrabacionWidget extends StatelessWidget {
|
||||
),
|
||||
label: Text(AppLocalizations.of(ctx).indefiniteOption),
|
||||
onPressed: () {
|
||||
estado.iniciarGrabacion();
|
||||
grabacion.iniciar();
|
||||
Navigator.pop(ctx);
|
||||
},
|
||||
),
|
||||
@@ -511,7 +516,7 @@ class _GrabacionWidget extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
onPressed: () {
|
||||
estado.iniciarGrabacion(duracion: opcion.duracion);
|
||||
grabacion.iniciar(duracion: opcion.duracion);
|
||||
Navigator.pop(ctx);
|
||||
},
|
||||
),
|
||||
@@ -533,6 +538,7 @@ class _GrabacionWidget extends StatelessWidget {
|
||||
}
|
||||
|
||||
Future<void> _mostrarDuracionPersonalizada(BuildContext context) async {
|
||||
final grabacion = context.read<EstadoGrabacion>();
|
||||
final minutosCtrl = TextEditingController();
|
||||
final segundosCtrl = TextEditingController(text: '0');
|
||||
final formKey = GlobalKey<FormState>();
|
||||
@@ -585,7 +591,7 @@ class _GrabacionWidget extends StatelessWidget {
|
||||
seconds: segundos,
|
||||
);
|
||||
if (duracion <= Duration.zero) return;
|
||||
estado.iniciarGrabacion(duracion: duracion);
|
||||
grabacion.iniciar(duracion: duracion);
|
||||
Navigator.pop(ctx);
|
||||
},
|
||||
child: Text(AppLocalizations.of(ctx).recordAction),
|
||||
|
||||
Reference in New Issue
Block a user