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().refrescarDispositivoActual()); } @override Widget build(BuildContext context) { final l10n = AppLocalizations.of(context); final eq = context.watch(); 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(); 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 _abrirModal(BuildContext context) async { await showModalBottomSheet( 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(); 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 _guardar() async { final eq = context.read(); await eq.renombrarDispositivo(widget.deviceId, _nombreCtrl.text); if (_presetActual != widget.preset) { await eq.guardarPresetDispositivo(widget.deviceId, _presetActual); } if (mounted) Navigator.of(context).pop(); } Future _eliminar() async { final eq = context.read(); 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), ), ], ), ], ), ), ); } }