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.
370 lines
13 KiB
Dart
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),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|