Tres fallos reportados en uso real, con sus causas raiz verificadas en codigo.
1. Ecualizador: el estado no tenia dueño unico
El handler arrancaba con `_ecualizadorActivo = true` a fuego. El valor
persistido solo llegaba por EstadoEcualizador.cargarPersistido(), alcanzable
unicamente desde el arbol de widgets, que un arranque headless de Android Auto
nunca construye. Resultado: el coche reproducia con el EQ forzado a ON mientras
disco e interfaz decian OFF.
Ahora registrarHandler siembra el flag desde disco en todos los motores y
setEcualizadorActivo persiste por su cuenta, asi que un toggle desde el coche o
la notificacion sobrevive sin EstadoEcualizador. _resincronizarConHandler pasa
a ser adopcion pura de interfaz.
Ademas mapearGananciaNativa enviaba 0 dB al punto MEDIO del rango nativo. Con
un getBandLevelRange() asimetrico, un preset plano metia varios dB de boost
real: la causa del "suena muy alto con el boton apagado". Reescrito para
escalar cada lado contra su propio extremo, de modo que 0 dB es siempre 0.
El dispatch de customAction no tenia ningun test. Se extrae decidirToggleEq y
se cubre contra el handler real. El efecto nativo se re-asierta al reactivarse
el reproductor, porque AudioEffect.setEnabled de just_audio es un no-op
mientras la plataforma esta desacoplada.
2. Musica Local no aparecia en el arbol de Android Auto
hayCarpetaConfigurada() consultaba MethodChannel('pluriwave/file_actions'),
registrado solo en MainActivity.configureFlutterEngine. Sin Activity no hay
handler, invokeMethod lanza MissingPluginException y el catch la confundia con
"permiso revocado", omitiendo el nodo. No dependia del entitlement.
La logica SAF sale a packages/pluriwave_file_actions, un paquete plugin local.
El motor headless que crea audio_service ejecuta GeneratedPluginRegistrant en
su constructor, asi que el canal queda registrado en ambos motores. Repuntar el
manifest a una subclase de AudioService no era viable: AudioServicePlugin
enlaza por ComponentName explicito y la app perderia el audio.
EstadoCarpetaLocal de tres valores separa "sin carpeta" de "canal no
disponible"; la raiz decide por la URI persistida y el subarbol muestra un item
explicativo en vez de una carpeta vacia. La invalidacion del arbol cacheado se
dispara al reanudar con el coche ya suscrito; el guardia anterior miraba
View.maybeOf, que bajo runApp siempre existe, por lo que se gastaba en el
arranque headless y no volvia a dispararse.
3. El paywall bloqueaba las compras
restorePurchases() de in_app_purchase_android emite siempre, y con lista vacia
si no hay nada que restaurar. El `if (compras.isEmpty) return;` se la tragaba,
noEncontrada nunca se emitia y la rama que limpia _compraEnCurso estaba muerta
en produccion. Como comprar y restaurar comparten ese flag, un usuario sin
compras que pulsaba restaurar se quedaba sin poder comprar.
Suite completa: 1366 pasan, 2 omitidos. 17 tests nuevos, todos nacidos rojos y
verificados por mutacion. flutter analyze mantiene los 5 avisos preexistentes.
140 lines
4.8 KiB
Dart
140 lines
4.8 KiB
Dart
import 'package:flutter/material.dart';
|
|
|
|
import '../../l10n/gen/app_localizations.dart';
|
|
import '../../servicios/musica_local_auto.dart';
|
|
import '../../servicios/servicio_audio.dart' show invalidarArbolAuto;
|
|
import '../../widgets/pluri_glass_surface.dart';
|
|
import '../../widgets/pluri_layout.dart';
|
|
import '../../widgets/pluri_push_scaffold.dart';
|
|
|
|
/// GRABACIONES Y MÚSICA group · "Música local" (design ADR-3). Body moved
|
|
/// verbatim from the former `_SeccionMusicaLocal` in `pantalla_ajustes.dart`
|
|
/// — only the panel header's icon and title were removed (the pushed
|
|
/// screen's title now carries them). Deliberately does NOT use
|
|
/// `FilePicker.platform` (see tasks.md "Grounding corrections") —
|
|
/// [FuenteMusicaLocalAutoImpl.elegirCarpeta] calls the native
|
|
/// `pickMusicFolder` channel method directly, since it needs a
|
|
/// persistable-grant SAF tree URI, not a plain filesystem path.
|
|
class PantallaAjustesMusicaLocal extends StatelessWidget {
|
|
const PantallaAjustesMusicaLocal({super.key});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final l10n = AppLocalizations.of(context);
|
|
return PluriPushScaffold(
|
|
title: l10n.localMusicSectionTitle,
|
|
body: ListView(
|
|
padding: PluriLayout.pageContentPadding,
|
|
children: const [_CuerpoMusicaLocal()],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _CuerpoMusicaLocal extends StatefulWidget {
|
|
const _CuerpoMusicaLocal();
|
|
|
|
@override
|
|
State<_CuerpoMusicaLocal> createState() => _CuerpoMusicaLocalState();
|
|
}
|
|
|
|
class _CuerpoMusicaLocalState extends State<_CuerpoMusicaLocal> {
|
|
final _fuente = FuenteMusicaLocalAutoImpl();
|
|
late Future<String?> _carpetaActual;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_carpetaActual = _fuente.carpetaActual();
|
|
}
|
|
|
|
Future<void> _elegirCarpeta(BuildContext context) async {
|
|
final messenger = ScaffoldMessenger.of(context);
|
|
final l10n = AppLocalizations.of(context);
|
|
try {
|
|
final uri = await _fuente.elegirCarpeta();
|
|
if (uri == null) return; // Cancelled — no snackbar, matches the SAF
|
|
// picker's own "nothing changed" affordance.
|
|
|
|
// fix/android-auto-musica-local, item 4: acaba de aparecer música
|
|
// local donde antes no había. Android Auto cachea la raíz y no
|
|
// vuelve a preguntar por su cuenta, así que sin esto el coche seguía
|
|
// sin ofrecer «Música Local» hasta el siguiente re-bind — que puede
|
|
// no llegar en toda la sesión. Fuera del `context.mounted` de abajo:
|
|
// el árbol del coche no depende de que esta pantalla siga viva.
|
|
invalidarArbolAuto();
|
|
|
|
if (!context.mounted) return;
|
|
setState(() {
|
|
_carpetaActual = Future.value(uri);
|
|
});
|
|
messenger.showSnackBar(
|
|
SnackBar(content: Text(l10n.localMusicFolderUpdated)),
|
|
);
|
|
} catch (e) {
|
|
if (!context.mounted) return;
|
|
messenger.showSnackBar(
|
|
SnackBar(content: Text(l10n.localMusicFolderSaveError(e.toString()))),
|
|
);
|
|
}
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final l10n = AppLocalizations.of(context);
|
|
|
|
return PluriGlassSurface(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
const SizedBox(height: 8),
|
|
Text(
|
|
l10n.localMusicSectionDescription,
|
|
style: Theme.of(context).textTheme.bodySmall,
|
|
),
|
|
FutureBuilder<String?>(
|
|
future: _carpetaActual,
|
|
builder: (ctx, snap) {
|
|
final carpeta = snap.data;
|
|
return ListTile(
|
|
contentPadding: EdgeInsets.zero,
|
|
leading: const Icon(Icons.folder_outlined),
|
|
title: Text(l10n.localMusicFolderTitle),
|
|
subtitle: Text(
|
|
(carpeta == null || carpeta.isEmpty)
|
|
? l10n.localMusicFolderNotConfigured
|
|
: nombreCarpetaDesdeUri(
|
|
carpeta,
|
|
nombreGenerico: l10n.localMusicFolderGenericName,
|
|
),
|
|
maxLines: 2,
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
);
|
|
},
|
|
),
|
|
const SizedBox(height: 8),
|
|
FutureBuilder<String?>(
|
|
future: _carpetaActual,
|
|
builder: (ctx, snap) {
|
|
final configurada = (snap.data ?? '').isNotEmpty;
|
|
return Align(
|
|
alignment: Alignment.centerLeft,
|
|
child: OutlinedButton.icon(
|
|
icon: const Icon(Icons.folder_open_rounded),
|
|
label: Text(
|
|
configurada
|
|
? l10n.localMusicChangePath
|
|
: l10n.localMusicChoosePath,
|
|
),
|
|
onPressed: () => _elegirCarpeta(context),
|
|
),
|
|
);
|
|
},
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|