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.
This commit is contained in:
2026-07-28 21:38:20 +02:00
parent e99ecffb0c
commit bd2b7d8e02
35 changed files with 2763 additions and 1617 deletions
+89 -392
View File
@@ -8,10 +8,9 @@ import 'package:pluriwave/estado/estado_grabacion.dart';
import 'package:pluriwave/estado/estado_idioma.dart';
import 'package:pluriwave/estado/estado_radio.dart';
import 'package:pluriwave/l10n/gen/app_localizations.dart';
import 'package:pluriwave/modelos/dispositivo_audio.dart';
import 'package:pluriwave/modelos/preset_ecualizador.dart';
import 'package:pluriwave/pantallas/pantalla_ajustes.dart';
import 'package:pluriwave/servicios/servicio_grabacion_radio.dart';
import 'package:pluriwave/widgets/pluri_push_scaffold.dart';
import 'package:provider/provider.dart';
import 'package:shared_preferences/shared_preferences.dart';
@@ -61,23 +60,15 @@ void main() {
);
}
Future<EstadoRadio> crearEstado({
bool eqMultiDeviceEnabled = false,
Map<String, PresetEcualizador> presetsDispositivo = const {},
FakeServicioDispositivoAudio? dispositivoAudio,
}) async {
Future<EstadoRadio> crearEstado() async {
final estado = EstadoRadio(
audio: FakeServicioAudio(),
favoritos: FakeServicioFavoritos(),
radio: FakeServicioRadio(),
servicioEcualizador: FakeServicioEcualizador(
eqMultiDeviceEnabled: eqMultiDeviceEnabled,
presetsDispositivo: presetsDispositivo,
),
servicioEcualizador: FakeServicioEcualizador(),
servicioGrabacion: _FakeGrabacion(),
resolverArchivoCustom: _archivoCustomVacio,
iniciarAutomaticamente: false,
dispositivoAudio: dispositivoAudio,
);
await estado.ecualizador.cargarPersistido();
return estado;
@@ -95,425 +86,134 @@ void main() {
await tester.pumpAndSettle(const Duration(milliseconds: 100));
}
// Also update crearEstado to support nombresDispositivos
Future<EstadoRadio> crearEstadoConNombres({
bool eqMultiDeviceEnabled = true,
Map<String, PresetEcualizador>? presetsDispositivo,
Map<String, String>? nombresDispositivos,
String? activeDeviceId,
String nombrePlataforma = 'BT Speaker',
}) async {
final fakeDispositivo = activeDeviceId != null
? (FakeServicioDispositivoAudio()
..emitirDispositivo(
DispositivoAudio(
id: activeDeviceId,
tipo: TipoDispositivo.bluetoothA2dp,
nombre: nombrePlataforma,
),
))
: null;
final estado = EstadoRadio(
audio: FakeServicioAudio(),
favoritos: FakeServicioFavoritos(),
radio: FakeServicioRadio(),
servicioEcualizador: FakeServicioEcualizador(
eqMultiDeviceEnabled: eqMultiDeviceEnabled,
presetsDispositivo: presetsDispositivo ??
{'bt_a2dp:AA:BB': PresetEcualizador.rock},
nombresDispositivos: nombresDispositivos ?? {},
),
servicioGrabacion: _FakeGrabacion(),
resolverArchivoCustom: _archivoCustomVacio,
iniciarAutomaticamente: false,
dispositivoAudio: fakeDispositivo,
);
await estado.ecualizador.cargarPersistido();
return estado;
}
// ── Phase 7 tests ──────────────────────────────────────────────────────────
group('_SeccionEcualizadorAvanzado (Phase 7)', () {
testWidgets('7.1-A: toggle OFF — advanced EQ section is visible but device '
'list is not shown', (tester) async {
// ── WU3a: AUDIO + EMISORAS become grouped nav rows ─────────────────────────
//
// Design ADR-3: the root now carries zero inline controls for the 7
// sections WU3a moved (Ecualizador, Salida de audio, Temporizador de sueño,
// Grupos de favoritos, Emisora preferida, Emisoras personalizadas, Orden de
// listas) — each is reached through a FilaAjuste row instead. The other 5
// sections (Grabaciones, Música local, Idioma, Backup, Info) still render
// inline here: WU3b decomposes GRABACIONES Y MÚSICA / APLICACIÓN the same
// way, so the root is not yet under 400 lines nor fully "zero inline
// controls" — that end state is WU3b's completion, not WU3a's (see the
// apply-progress note on this discrepancy in tasks.md 3a.1/3a.8).
group('WU3a — AUDIO and EMISORAS groups', () {
testWidgets('AUDIO group renders exactly 3 nav rows, no inline controls', (
tester,
) async {
setLargeSurface(tester);
_suppressListTileInkAssertion(); // Must be before pumpWidget.
final estado = await crearEstado(eqMultiDeviceEnabled: false);
_suppressListTileInkAssertion();
final estado = await crearEstado();
addTearDown(estado.dispose);
await tester.pumpWidget(buildAjustes(estado));
await pumpStable(tester);
// Scroll to find the advanced EQ section.
await tester.scrollUntilVisible(
find.text('Advanced Equalization Options'),
300,
scrollable: find.byType(Scrollable).first,
);
await pumpStable(tester);
// The section header must be present.
expect(find.text('AUDIO'), findsOneWidget);
expect(find.text('Equalizer'), findsOneWidget);
expect(find.text('Advanced Equalization Options'), findsOneWidget);
expect(find.text('Sleep timer'), findsOneWidget);
// Toggle switch title must be present.
expect(find.text('Enable per-device EQ'), findsOneWidget);
// When toggle is OFF, device list must NOT be rendered.
expect(find.text('Known audio devices'), findsNothing);
// Zero inline controls: the old always-visible enable switch and
// device-management toggle are gone from the root.
expect(find.text('Enable equalizer'), findsNothing);
expect(find.text('Enable per-device EQ'), findsNothing);
});
testWidgets(
'7.1-B: toggle ON with known devices — device list is visible',
'STATIONS group renders exactly 4 nav rows, no inline controls',
(tester) async {
setLargeSurface(tester);
_suppressListTileInkAssertion();
final estado = await crearEstado(
eqMultiDeviceEnabled: true,
presetsDispositivo: {
'bt_a2dp:AA:BB:CC:DD:EE:FF': PresetEcualizador.rock,
},
);
final estado = await crearEstado();
addTearDown(estado.dispose);
await tester.pumpWidget(buildAjustes(estado));
await pumpStable(tester);
await tester.scrollUntilVisible(
find.text('Advanced Equalization Options'),
300,
scrollable: find.byType(Scrollable).first,
expect(find.text('STATIONS'), findsOneWidget);
expect(find.text('Favorite lists'), findsOneWidget);
expect(find.text('Preferred station'), findsOneWidget);
expect(find.text('Custom stations'), findsOneWidget);
expect(find.text('Station order'), findsOneWidget);
// Zero inline controls: the descriptive body copy that used to sit
// directly under each header is gone from the root now. (A
// DropdownButtonFormField still exists on the page — Idioma's
// language picker, a section WU3b converts, not WU3a.)
expect(
find.text(
'Preselected for new alarms and available for quick playback.',
),
findsNothing,
);
await pumpStable(tester);
// Section header present.
expect(find.text('Advanced Equalization Options'), findsOneWidget);
// Toggle switch title present.
expect(find.text('Enable per-device EQ'), findsOneWidget);
// Known devices header should appear when toggle is on and there are
// known devices.
expect(find.text('Known audio devices'), findsOneWidget);
// The device row is listed. An unnamed device shows its transport plus
// the tail of its address, not the raw id.
expect(find.text('Bluetooth · EE:FF'), findsOneWidget);
},
);
testWidgets(
'7.1-C: toggle can be flipped — tapping it enables multi-device EQ',
(tester) async {
setLargeSurface(tester);
_suppressListTileInkAssertion();
final estado = await crearEstado(eqMultiDeviceEnabled: false);
addTearDown(estado.dispose);
await tester.pumpWidget(buildAjustes(estado));
await pumpStable(tester);
await tester.scrollUntilVisible(
find.text('Advanced Equalization Options'),
300,
scrollable: find.byType(Scrollable).first,
);
await pumpStable(tester);
// Initially OFF.
expect(estado.ecualizador.eqMultiDeviceEnabled, isFalse);
// Tap the Switch widget to toggle on.
await tester.tap(find.byType(Switch).last);
await pumpStable(tester);
// After tap, toggle should be ON.
expect(estado.ecualizador.eqMultiDeviceEnabled, isTrue);
},
);
});
// ── Phase 3 (eq-device-autoswitch-ux): connection indicator + modal ──────────
group('SeccionEcualizadorAvanzado connection indicator Phase 3', () {
// 3.1 RED — active device row shows green connection dot
testWidgets('3.1 active device row shows green connection indicator', (tester) async {
testWidgets('tapping the Ecualizador row pushes its detail screen', (
tester,
) async {
setLargeSurface(tester);
_suppressListTileInkAssertion();
const activeId = 'bt_a2dp:AA:BB';
final estado = await crearEstadoConNombres(
eqMultiDeviceEnabled: true,
presetsDispositivo: {
activeId: PresetEcualizador.rock,
'wired_headset': PresetEcualizador.jazz,
},
activeDeviceId: activeId,
);
final estado = await crearEstado();
addTearDown(estado.dispose);
await tester.pumpWidget(buildAjustes(estado));
await pumpStable(tester);
await tester.scrollUntilVisible(
find.text('Known audio devices'),
300,
scrollable: find.byType(Scrollable).first,
);
await tester.tap(find.text('Equalizer'));
await pumpStable(tester);
// Green connection dot should be present (Icon with green color for active device)
final greenIcons = tester.widgetList<Icon>(find.byType(Icon)).where(
(icon) => icon.color == Colors.green,
);
// At least one green icon present
expect(greenIcons, isNotEmpty);
// Pushed, not index-switched: exactly one PluriPushScaffold now exists,
// and its moved control (the enable switch) is reachable.
expect(find.byType(PluriPushScaffold), findsOneWidget);
expect(find.text('Enable equalizer'), findsOneWidget);
});
// 3.2 RED — tapping device row opens bottom sheet with TextField and EcualizadorWidget
testWidgets('3.2 tapping device row opens modal with TextField', (tester) async {
testWidgets('tapping the Orden de listas row pushes its detail screen', (
tester,
) async {
setLargeSurface(tester);
_suppressListTileInkAssertion();
final estado = await crearEstadoConNombres(
presetsDispositivo: {'bt_a2dp:AA:BB': PresetEcualizador.rock},
);
final estado = await crearEstado();
addTearDown(estado.dispose);
await tester.pumpWidget(buildAjustes(estado));
await pumpStable(tester);
await tester.scrollUntilVisible(
find.text('Known audio devices'),
300,
scrollable: find.byType(Scrollable).first,
);
await tester.tap(find.text('Station order'));
await pumpStable(tester);
// Tap the device row (tap the edit icon or the row itself)
final editIcons = find.byIcon(Icons.edit_rounded);
expect(editIcons, findsWidgets);
await tester.tap(editIcons.first);
await pumpStable(tester);
// Bottom sheet should appear with a TextField
expect(find.byType(TextField), findsWidgets);
});
// 3.6 RED — renaming in modal and confirming calls renombrarDispositivo
testWidgets('3.6 confirming rename in modal updates device name', (tester) async {
setLargeSurface(tester);
_suppressListTileInkAssertion();
const deviceId = 'bt_a2dp:AA:BB';
final estado = await crearEstadoConNombres(
presetsDispositivo: {deviceId: PresetEcualizador.rock},
);
addTearDown(estado.dispose);
await tester.pumpWidget(buildAjustes(estado));
await pumpStable(tester);
await tester.scrollUntilVisible(
find.text('Known audio devices'),
300,
scrollable: find.byType(Scrollable).first,
);
await pumpStable(tester);
// Open modal
await tester.tap(find.byIcon(Icons.edit_rounded).first);
await pumpStable(tester);
// Enter a new name
final textField = find.byType(TextField).first;
await tester.enterText(textField, 'My Living Room Speaker');
await pumpStable(tester);
// Tap confirm/save button
final saveButton = find.byIcon(Icons.save_rounded);
expect(saveButton, findsWidgets);
await tester.tap(saveButton.first);
await pumpStable(tester);
// Device should now have the new name
expect(
estado.ecualizador.obtenerNombreDispositivo(deviceId),
equals('My Living Room Speaker'),
);
});
// 3.7 RED — dismissing modal without confirming leaves name unchanged
testWidgets('3.7 dismissing modal without confirming leaves name unchanged', (tester) async {
setLargeSurface(tester);
_suppressListTileInkAssertion();
const deviceId = 'bt_a2dp:AA:BB';
final estado = await crearEstadoConNombres(
presetsDispositivo: {deviceId: PresetEcualizador.rock},
nombresDispositivos: {deviceId: 'Original Name'},
);
addTearDown(estado.dispose);
await tester.pumpWidget(buildAjustes(estado));
await pumpStable(tester);
await tester.scrollUntilVisible(
find.text('Known audio devices'),
300,
scrollable: find.byType(Scrollable).first,
);
await pumpStable(tester);
// Open modal
await tester.tap(find.byIcon(Icons.edit_rounded).first);
await pumpStable(tester);
// Change the text but do NOT confirm
final textField = find.byType(TextField).first;
await tester.enterText(textField, 'New Name Not Saved');
await pumpStable(tester);
// Dismiss by pressing back/escape
await tester.tapAt(const Offset(100, 100)); // tap outside bottom sheet
await pumpStable(tester);
// Name should remain unchanged
expect(
estado.ecualizador.obtenerNombreDispositivo(deviceId),
equals('Original Name'),
);
expect(find.byType(PluriPushScaffold), findsOneWidget);
expect(find.text('By name'), findsOneWidget);
expect(find.text('By quality'), findsOneWidget);
});
});
// ── bt-device-identity Phase 4: display fix + permission trigger ─────────
group('_SeccionEcualizadorAvanzado — bt-device-identity Phase 4', () {
// 4.1 — new behavior: the cached platform name (not '') now feeds
// nombreVisible, so a device with no custom rename shows its real name.
testWidgets('4.1 platform name displays with no custom rename', (
// ── Sections not yet converted (WU3b's job) stay reachable ─────────────────
group('Sections pending WU3b remain inline and reachable', () {
testWidgets('Grabaciones, Idioma, Backup and Info still render', (
tester,
) async {
setLargeSurface(tester);
_suppressListTileInkAssertion();
const deviceId = 'bt_a2dp:AA:BB:CC:DD:EE:FF';
final estado = await crearEstadoConNombres(
presetsDispositivo: {deviceId: PresetEcualizador.rock},
activeDeviceId: deviceId,
nombrePlataforma: 'AirPods Pro',
);
final estado = await crearEstado();
addTearDown(estado.dispose);
await tester.pumpWidget(buildAjustes(estado));
await pumpStable(tester);
await tester.scrollUntilVisible(
find.text('Known audio devices'),
300,
scrollable: find.byType(Scrollable).first,
);
await pumpStable(tester);
expect(find.text('AirPods Pro'), findsOneWidget);
expect(find.text(deviceId), findsNothing);
});
// 4.4 — triangulation companion: custom rename still wins even though
// the row now also has a cached platform name available.
testWidgets('4.4 custom rename overrides platform name', (tester) async {
setLargeSurface(tester);
_suppressListTileInkAssertion();
const deviceId = 'bt_a2dp:AA:BB:CC:DD:EE:FF';
final estado = await crearEstadoConNombres(
presetsDispositivo: {deviceId: PresetEcualizador.rock},
activeDeviceId: deviceId,
nombrePlataforma: 'AirPods Pro',
nombresDispositivos: {deviceId: 'My Headphones'},
);
addTearDown(estado.dispose);
await tester.pumpWidget(buildAjustes(estado));
await pumpStable(tester);
await tester.scrollUntilVisible(
find.text('Known audio devices'),
300,
scrollable: find.byType(Scrollable).first,
);
await pumpStable(tester);
expect(find.text('My Headphones'), findsOneWidget);
expect(find.text('AirPods Pro'), findsNothing);
});
// 4.5 — a device never seen on the stream has no cached platform name, so
// the row falls back to a humanized transport + address tail instead of the
// raw id, which told the user nothing.
testWidgets('4.5 no platform name yet shows a humanized transport label', (
tester,
) async {
setLargeSurface(tester);
_suppressListTileInkAssertion();
const deviceId = 'bt_a2dp:AA:BB:CC:DD:EE:FF';
final estado = await crearEstadoConNombres(
presetsDispositivo: {deviceId: PresetEcualizador.rock},
);
addTearDown(estado.dispose);
await tester.pumpWidget(buildAjustes(estado));
await pumpStable(tester);
await tester.scrollUntilVisible(
find.text('Known audio devices'),
300,
scrollable: find.byType(Scrollable).first,
);
await pumpStable(tester);
expect(find.text('Bluetooth · EE:FF'), findsOneWidget);
expect(find.text(deviceId), findsNothing);
});
// 4.6 — permission trigger point: turning the toggle ON requests
// BLUETOOTH_CONNECT; turning it back OFF must not re-fire the request.
testWidgets('4.6 permission call fires on device-management open', (
tester,
) async {
setLargeSurface(tester);
_suppressListTileInkAssertion();
final fakeDispositivo = FakeServicioDispositivoAudio();
final estado = await crearEstado(
eqMultiDeviceEnabled: false,
dispositivoAudio: fakeDispositivo,
);
addTearDown(estado.dispose);
await tester.pumpWidget(buildAjustes(estado));
await pumpStable(tester);
await tester.scrollUntilVisible(
find.text('Advanced Equalization Options'),
300,
scrollable: find.byType(Scrollable).first,
);
await pumpStable(tester);
expect(fakeDispositivo.solicitarPermisoBluetoothCalls, equals(0));
// Toggle ON: permission requested exactly once.
await tester.tap(find.byType(Switch).last);
await pumpStable(tester);
expect(estado.ecualizador.eqMultiDeviceEnabled, isTrue);
expect(fakeDispositivo.solicitarPermisoBluetoothCalls, equals(1));
// Toggle OFF again: no further permission request.
await tester.tap(find.byType(Switch).last);
await pumpStable(tester);
expect(estado.ecualizador.eqMultiDeviceEnabled, isFalse);
expect(fakeDispositivo.solicitarPermisoBluetoothCalls, equals(1));
expect(find.text('Recordings'), findsOneWidget);
// "Language" legitimately renders twice (pre-existing, unmodified by
// WU3a): the section header AND the dropdown's own label share the
// same l10n string.
expect(find.text('Language'), findsWidgets);
expect(find.text('Backup'), findsOneWidget);
expect(find.text('Help and tutorial'), findsOneWidget);
});
});
// ── android-auto-local-music-paging Phase 7: friendly folder name ────────
group('_SeccionMusicaLocal — friendly folder name (Phase 7)', () {
testWidgets(
'7.1-A: carpeta configurada muestra el nombre amigable derivado de '
@@ -544,29 +244,26 @@ void main() {
},
);
testWidgets(
'7.1-B: sin carpeta configurada mantiene el mensaje '
'localMusicFolderNotConfigured',
(tester) async {
SharedPreferences.setMockInitialValues({});
setLargeSurface(tester);
_suppressListTileInkAssertion();
final estado = await crearEstado();
addTearDown(estado.dispose);
testWidgets('7.1-B: sin carpeta configurada mantiene el mensaje '
'localMusicFolderNotConfigured', (tester) async {
SharedPreferences.setMockInitialValues({});
setLargeSurface(tester);
_suppressListTileInkAssertion();
final estado = await crearEstado();
addTearDown(estado.dispose);
await tester.pumpWidget(buildAjustes(estado));
await pumpStable(tester);
await tester.pumpWidget(buildAjustes(estado));
await pumpStable(tester);
await tester.scrollUntilVisible(
find.text('Local music folder'),
300,
scrollable: find.byType(Scrollable).first,
);
await pumpStable(tester);
await tester.scrollUntilVisible(
find.text('Local music folder'),
300,
scrollable: find.byType(Scrollable).first,
);
await pumpStable(tester);
expect(find.text('No folder selected'), findsOneWidget);
},
);
expect(find.text('No folder selected'), findsOneWidget);
});
});
}