Files
pluriwave/lib/pantallas/ajustes/pantalla_ajustes_salida_audio.dart
T
FreeTLab bd2b7d8e02 refactor(ajustes): split Settings AUDIO/EMISORAS into pushed detail screens
Moves the AUDIO group (Ecualizador, Salida de audio, Temporizador de
sueno) and the EMISORAS group (Grupos de favoritos, Emisora preferida,
Emisoras personalizadas, Orden de listas) out of pantalla_ajustes.dart
into 7 new lib/pantallas/ajustes/*.dart screens, each wrapped in
PluriPushScaffold. The root now reaches them through FilaAjuste rows
under two new GrupoAjustes cards (lib/pantallas/ajustes/widgets/
fila_ajuste.dart), per design ADR-3.

Verbatim-move rule applied throughout: only each section's panel header
(icon + title, sometimes a status chip) was removed, since the pushed
screen's own 56px header now carries the title. Two sections whose
header row carried a real action (Temporizador de sueno's "Add",
Grupos de favoritos' "Add list", Emisoras personalizadas' "Add") kept
that action in the body instead of dropping it.

size:exception (move-only diff, pre-recorded at design/tasks time):
34 files, ~4250 changed lines excluding the 13 auto-regenerated l10n
files (~90 more lines there) - higher than the 800-1000 estimate
because that estimate covered the 7 production screens but not the
matching 7 new test files (task 3a.2), one of which relocates ~10
pre-existing device-management test cases verbatim. Business logic is
untouched; app.dart's import of pantalla_ajustes.dart is unchanged.

Correction to tasks.md 3a.1/3a.8: those two lines describe the combined
WU3a+WU3b end state ("4 grouped nav lists", "<400 lines"), matching
design ADR-3's own aggregate blast-radius note - not a WU3a-only claim.
This commit converts only the 2 groups that are WU3a's job; the root
is 788 lines with 5 sections (Grabaciones, Musica local, Idioma,
Backup, Info) still inline, reachable, and unchanged, pending WU3b.

Two new ARB keys (settingsGroupAudioTitle, settingsGroupStationsTitle),
en/es only per the WU1 precedent - all 7 detail-screen titles reuse
existing keys. Discovered and worked around, without touching app
code: Directory.systemTemp hangs real dart:io writes in this sandbox,
and pumpAndSettle() cannot settle while a screen shows an indeterminate
CircularProgressIndicator - both are test-only concerns, documented
inline where hit.

Tests: 560 -> 579 (32 in this commit's scope, net +19 after retiring
13 relocated cases from the old combined pantalla_ajustes_test.dart).
flutter analyze: unchanged at 1 pre-existing info. git diff is empty
for navegacion_auto.dart, servicio_ecualizador.dart and
servicio_audio.dart; pantalla_alarma_sonando_dismiss_guard_test.dart
untouched.
2026-07-28 21:38:20 +02:00

370 lines
13 KiB
Dart

import 'dart:async';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../estado/estado_ecualizador.dart';
import '../../l10n/gen/app_localizations.dart';
import '../../modelos/preset_ecualizador.dart';
import '../../widgets/ecualizador_widget.dart';
import '../../widgets/pluri_glass_surface.dart';
import '../../widgets/pluri_layout.dart';
import '../../widgets/pluri_push_scaffold.dart';
/// AUDIO group · "Salida de audio" (design ADR-3). Body moved verbatim from
/// the former `_SeccionEcualizadorAvanzado` + `_FilaDispositivo` +
/// `_DialogoEdicionDispositivo` in `pantalla_ajustes.dart` — only the panel
/// header row (icon + title) was removed, since [PluriPushScaffold] now
/// carries the title. The visible title text is unchanged
/// ("Advanced Equalization Options" / `advancedEqSectionTitle`) — the file
/// name reflects the design's AUDIO row label ("Salida de audio"), not new
/// UI copy.
class PantallaAjustesSalidaAudio extends StatelessWidget {
const PantallaAjustesSalidaAudio({super.key});
@override
Widget build(BuildContext context) => PluriPushScaffold(
title: AppLocalizations.of(context).advancedEqSectionTitle,
body: ListView(
padding: PluriLayout.pageContentPadding,
children: const [_CuerpoSalidaAudio()],
),
);
}
/// Always shows the feature toggle so the user can discover it. When the
/// toggle is OFF, the device list is completely absent (not just invisible),
/// matching the spec scenario "Settings section is absent when toggle is
/// off".
class _CuerpoSalidaAudio extends StatefulWidget {
const _CuerpoSalidaAudio();
@override
State<_CuerpoSalidaAudio> createState() => _CuerpoSalidaAudioState();
}
class _CuerpoSalidaAudioState extends State<_CuerpoSalidaAudio> {
@override
void initState() {
super.initState();
// Fix "stale green dot": refresh the active-device indicator with a
// fresh native query the moment this section becomes visible, instead of
// trusting the last event that happened to arrive (no-op when the
// multi-device toggle is off).
unawaited(context.read<EstadoEcualizador>().refrescarDispositivoActual());
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final eq = context.watch<EstadoEcualizador>();
final multiDeviceEnabled = eq.eqMultiDeviceEnabled;
final presetsDispositivo = eq.presetsDispositivo;
return PluriGlassSurface(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// The toggle uses GestureDetector + custom row instead of
// SwitchListTile to avoid Material ink assertion inside
// PluriGlassSurface's DecoratedBox. The visual result is identical
// to SwitchListTile.
Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Row(
children: [
Expanded(
child: GestureDetector(
behavior: HitTestBehavior.translucent,
onTap: () => _alternarMultiDevice(eq, !multiDeviceEnabled),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
l10n.advancedEqEnableToggle,
style: Theme.of(context).textTheme.bodyLarge,
),
const SizedBox(height: 2),
Text(
l10n.advancedEqEnableToggleSubtitle,
style: Theme.of(context).textTheme.bodySmall,
),
],
),
),
),
Switch.adaptive(
value: multiDeviceEnabled,
onChanged:
(habilitado) => _alternarMultiDevice(eq, habilitado),
),
],
),
),
if (multiDeviceEnabled) ...[
const SizedBox(height: 8),
Text(
l10n.advancedEqKnownDevicesTitle,
style: Theme.of(context).textTheme.labelLarge,
),
const SizedBox(height: 4),
if (presetsDispositivo.isEmpty)
Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Text(
l10n.advancedEqKnownDevicesEmpty,
style: Theme.of(context).textTheme.bodySmall,
),
)
else
for (final entry in presetsDispositivo.entries)
_FilaDispositivo(deviceId: entry.key, preset: entry.value),
],
],
),
);
}
/// Toggles the multi-device EQ feature and, when turning it ON, requests
/// `BLUETOOTH_CONNECT` at this point-of-intent (bt-device-identity ADR-1)
/// so BT devices report their real MAC instead of the OS placeholder.
/// Fire-and-forget: neither call blocks the toggle UI on its result.
void _alternarMultiDevice(EstadoEcualizador eq, bool habilitado) {
unawaited(eq.cambiarMultiDeviceEnabled(habilitado));
if (habilitado) {
unawaited(eq.solicitarPermisoBluetooth());
}
}
}
/// A single device row in the known-devices list.
///
/// Shows a connection indicator (green dot) when [deviceId] matches the
/// currently active device. Tapping the edit icon opens
/// [_DialogoEdicionDispositivo].
class _FilaDispositivo extends StatelessWidget {
const _FilaDispositivo({required this.deviceId, required this.preset});
final String deviceId;
final PresetEcualizador preset;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final eq = context.watch<EstadoEcualizador>();
final isActive = eq.dispositivoActualId == deviceId;
final displayName = _nombreLegible(
deviceId,
eq.nombreVisible(deviceId, eq.nombrePlataforma(deviceId)),
);
return Padding(
padding: const EdgeInsets.symmetric(vertical: 6),
child: Row(
children: [
// The dot marks where audio is coming out RIGHT NOW, which is not the
// same as "paired" or "connected" — it needs a label, both for screen
// readers and for anyone wondering what a bare green dot means.
if (isActive)
Tooltip(
message: l10n.eqDeviceActiveOutput,
child: Icon(
Icons.circle,
size: 10,
color: Colors.green,
semanticLabel: l10n.eqDeviceActiveOutput,
),
)
else
const SizedBox(width: 10),
const SizedBox(width: 8),
const Icon(Icons.headphones_rounded, size: 20),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
displayName,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.bodyMedium,
),
Text(
l10n.advancedEqDevicePresetLabel(preset.nombre),
style: Theme.of(context).textTheme.bodySmall,
),
],
),
),
IconButton(
icon: const Icon(Icons.edit_rounded, size: 20),
tooltip: l10n.eqDeviceEditTitle,
onPressed: () => _abrirModal(context),
),
],
),
);
}
/// Turns a device id the user never named into something readable.
///
/// [nombreVisible] falls back to the raw id when neither a custom name nor a
/// platform name is known — which is the normal case for a Bluetooth device
/// that is not currently connected, since platform names are cached in memory
/// only. Showing `bt_a2dp:AA:BB:CC:DD:EE:FF` tells the user nothing, so keep
/// the transport plus the tail of the address, which is what distinguishes
/// two otherwise identical rows.
static String _nombreLegible(String deviceId, String nombreVisible) {
if (nombreVisible != deviceId) return nombreVisible;
final separador = deviceId.indexOf(':');
if (separador == -1) return deviceId;
final transporte = deviceId.substring(0, separador);
final resto = deviceId.substring(separador + 1);
final etiqueta = switch (transporte) {
'bt_a2dp' => 'Bluetooth',
'usb_headset' => 'USB',
_ => transporte,
};
final cola = resto.split(':').where((p) => p.isNotEmpty).toList();
if (cola.isEmpty) return etiqueta;
final sufijo =
cola.length >= 2 ? cola.sublist(cola.length - 2).join(':') : cola.last;
return '$etiqueta · $sufijo';
}
Future<void> _abrirModal(BuildContext context) async {
await showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
showDragHandle: true,
builder:
(ctx) =>
_DialogoEdicionDispositivo(deviceId: deviceId, preset: preset),
);
}
}
/// Bottom sheet for editing a device's custom name and EQ preset.
class _DialogoEdicionDispositivo extends StatefulWidget {
const _DialogoEdicionDispositivo({
required this.deviceId,
required this.preset,
});
final String deviceId;
final PresetEcualizador preset;
@override
State<_DialogoEdicionDispositivo> createState() =>
_DialogoEdicionDispositivoState();
}
class _DialogoEdicionDispositivoState
extends State<_DialogoEdicionDispositivo> {
late final TextEditingController _nombreCtrl;
late PresetEcualizador _presetActual;
@override
void initState() {
super.initState();
final eq = context.read<EstadoEcualizador>();
final displayName = eq.nombreVisible(
widget.deviceId,
eq.nombrePlataforma(widget.deviceId),
);
_nombreCtrl = TextEditingController(text: displayName);
_presetActual = widget.preset;
}
@override
void dispose() {
_nombreCtrl.dispose();
super.dispose();
}
Future<void> _guardar() async {
final eq = context.read<EstadoEcualizador>();
await eq.renombrarDispositivo(widget.deviceId, _nombreCtrl.text);
if (_presetActual != widget.preset) {
await eq.guardarPresetDispositivo(widget.deviceId, _presetActual);
}
if (mounted) Navigator.of(context).pop();
}
Future<void> _eliminar() async {
final eq = context.read<EstadoEcualizador>();
final l10n = AppLocalizations.of(context);
final messenger = ScaffoldMessenger.of(context);
final nombre =
_nombreCtrl.text.trim().isEmpty
? widget.deviceId
: _nombreCtrl.text.trim();
await eq.eliminarDispositivo(widget.deviceId);
if (!mounted) return;
Navigator.of(context).pop();
messenger.showSnackBar(
SnackBar(content: Text(l10n.eqDeviceRemoved(nombre))),
);
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final bottom = MediaQuery.viewInsetsOf(context).bottom;
return SingleChildScrollView(
child: Padding(
padding: EdgeInsets.fromLTRB(20, 0, 20, bottom + 24),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
l10n.eqDeviceEditTitle,
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 16),
TextField(
controller: _nombreCtrl,
autofocus: true,
decoration: InputDecoration(
labelText: l10n.eqDeviceNameLabel,
hintText: l10n.eqDeviceNameHint,
border: const OutlineInputBorder(),
),
),
const SizedBox(height: 16),
EcualizadorWidget(
preset: _presetActual,
onCambio: (p) => setState(() => _presetActual = p),
),
const SizedBox(height: 16),
Row(
children: [
Expanded(
child: FilledButton.icon(
onPressed: _guardar,
icon: const Icon(Icons.save_rounded),
label: Text(l10n.eqDeviceNameConfirm),
),
),
const SizedBox(width: 12),
// Lets the user clear stale or duplicate rows. The device comes
// back on its next connection, so this is recoverable.
OutlinedButton.icon(
onPressed: _eliminar,
icon: const Icon(Icons.delete_outline_rounded),
label: Text(l10n.eqDeviceRemove),
),
],
),
],
),
),
);
}
}