Files
pluriwave/openspec/changes/eq-device-autoswitch-ux/design.md
T
FreeTLab bccc5c48b8
Build & Deploy PluriWave / Análisis de código (push) Successful in 38s
Build & Deploy PluriWave / Build APK + AAB release (push) Successful in 2m30s
docs(openspec): add SDD artifact trail for recent alarm and EQ changes
Persist the exploration, proposal, spec, design, tasks, and
verify/archive reports produced during the multi-device EQ,
alarm-countdown, and notification-visual-polish SDD cycles.
2026-07-04 12:42:11 +02:00

130 lines
7.9 KiB
Markdown

# Design: EQ Device Auto-Switch UX
## Technical Approach
Fix the startup device-seeding bug by adding a single `obtenerDispositivoActual()` call inside `cargarPersistido()` after the subscription is wired. Add a `nombresDispositivos` persistence layer (JSON map in SharedPreferences) exposed through `EstadoEcualizador`. Rewrite `_SeccionEcualizadorAvanzado` device rows with connection dot, display name fallback chain, and tap-to-edit bottom sheet containing rename TextField + embedded `EcualizadorWidget`.
## Architecture Decisions
### Decision: Bug fix location
| Option | Tradeoff | Decision |
|--------|----------|----------|
| A. Call `obtenerDispositivoActual()` in `cargarPersistido()` after `_configurarSuscripcionDispositivo()` | Minimal change, Dart-only, no platform touch | **Chosen** |
| B. Self-initializing stream (emit current device on `onListen` from Kotlin) | Single code path for all events | Rejected: requires Kotlin change, out of scope |
| C. Persist last-known device ID to SP | Survives process kill | Rejected: stale-state risk, higher complexity |
**Rationale**: The `obtenerDispositivoActual()` method channel already exists and is tested on the platform side. One `await` call seeds `_dispositivoActualId`, triggers first-seen bootstrap, and re-resolves the preset. Zero platform code changes.
### Decision: Device names persistence location
| Option | Tradeoff | Decision |
|--------|----------|----------|
| A. New SP key `eq_nombres_dispositivos_v1` in `ServicioEcualizador` as `Map<String, String>` JSON | Reuses existing `_leerMapa`/`_guardarMapa` pattern (adapted for String values), co-located with other EQ SP keys | **Chosen** |
| B. Field on `DispositivoAudio` model | Requires model change + breaks value-equality contract (id-only) | Rejected |
| C. Separate `ServicioNombresDispositivo` | Over-engineered for a single map | Rejected |
**Rationale**: `ServicioEcualizador` already manages 6 SP keys with the same read/write JSON pattern. Adding a 7th key with a simpler `Map<String, String>` type keeps all EQ persistence in one service. The `DispositivoAudio` model stays pure (id-based equality, no persistence coupling).
### Decision: Modal architecture
| Option | Tradeoff | Decision |
|--------|----------|----------|
| A. `showModalBottomSheet` with rename + embedded `EcualizadorWidget` | Established pattern (used 4x in settings), `isScrollControlled: true` handles tall content | **Chosen** |
| B. Inline expanded accordion per device | Always visible, but very tall list, complex expand/collapse state | Rejected |
| C. Full-screen dialog / page route | Overkill for rename + 5-band slider | Rejected |
**Rationale**: The app already uses `showModalBottomSheet` with `showDragHandle: true` and `isScrollControlled: true` in `_editarGrupo`, `_editarTamanoMaximo`, `_FormularioDuracionTimer`, and `_mostrarFormularioAnadir`. Same pattern, consistent UX.
### Decision: Display name resolution
| Option | Tradeoff | Decision |
|--------|----------|----------|
| A. Fallback chain: `nombresDispositivos[id]` > `DispositivoAudio.nombre` > `id` | Clean, handles all null/empty cases, no model changes | **Chosen** |
| B. Merge custom name into a new model field | Couples persistence to model | Rejected |
**Rationale**: The display name is a pure UI concern. `DispositivoAudio.nombre` comes from platform (`productName`) and can be null/empty on some devices. The fallback chain is computed at render time in the settings widget, keeping the model and state layers clean.
### Decision: Connection status derivation
**Choice**: Compare `entry.key == eq.dispositivoActualId` per row to show a green dot. No new API needed -- `EstadoEcualizador.dispositivoActualId` getter already exists.
**Rejected**: Tracking a `Set<String> connectedDevices` from the stream -- unnecessary since only one audio output is active at a time on Android.
## Data Flow
```
App startup
|
v
cargarPersistido()
|-- servicio.cargar() --> loads config + nombresDispositivos
|-- _configurarSuscripcionDispositivo() --> subscribes to EventChannel
|-- obtenerDispositivoActual() --> seeds _dispositivoActualId [NEW]
|-- _onDispositivoCambiado() --> first-seen bootstrap + re-resolve
|-- audio.aplicarPreset() --> correct preset applied
v
Settings UI (tap device row)
|
v
showModalBottomSheet
|-- TextField (custom name) --> renombrarDispositivo(id, name)
| |-> servicio.guardarNombresDispositivos(map)
| |-> notifyListeners()
|
|-- EcualizadorWidget(preset, onCambio)
|-> eq.cambiarPresetDispositivo(id, preset) [NEW convenience method]
```
## File Changes
| File | Action | Description |
|------|--------|-------------|
| `lib/estado/estado_ecualizador.dart` | Modify | Add `obtenerDispositivoActual()` call in `cargarPersistido()` after subscription setup. Add `_nombresDispositivos` map, `renombrarDispositivo()`, `obtenerNombreDispositivo()`, `nombreVisible()` fallback helper. |
| `lib/servicios/servicio_ecualizador.dart` | Modify | Add `_keyNombresDispositivos = 'eq_nombres_dispositivos_v1'`. Add `cargarNombresDispositivos()`, `guardarNombresDispositivos()` methods. Load names inside `cargar()` and include in `ConfiguracionEcualizador`. |
| `lib/servicios/servicio_ecualizador.dart` (ConfiguracionEcualizador) | Modify | Add `nombresDispositivos` field (`Map<String, String>`). |
| `lib/pantallas/pantalla_ajustes.dart` | Modify | Rewrite `_SeccionEcualizadorAvanzado` device rows: add connection dot (green `CircleAvatar` 8px), display name via fallback chain, `onTap``_mostrarEdicionDispositivo()`. Add new `_DialogoEdicionDispositivo` StatefulWidget (bottom sheet body: TextField for name + `EcualizadorWidget`). |
| `lib/l10n/app_en.arb` | Modify | Add ~5 new keys: `advancedEqDeviceConnected`, `advancedEqDeviceRenameLabel`, `advancedEqDeviceEditTitle`, `advancedEqDeviceNameHint`, `advancedEqDeviceRenamed`. |
| `lib/l10n/app_*.arb` (12 files) | Modify | Translate the new keys to all supported locales. |
| `test/` | Create | Tests for: startup device seeding, rename persistence round-trip, display name fallback chain, connection indicator logic. |
## Interfaces / Contracts
```dart
// ConfiguracionEcualizador -- add field
class ConfiguracionEcualizador {
// ... existing fields ...
final Map<String, String> nombresDispositivos; // deviceId -> custom name
}
// EstadoEcualizador -- new public API
Map<String, String> get nombresDispositivos;
Future<void> renombrarDispositivo(String deviceId, String nombre);
String obtenerNombreDispositivo(String deviceId); // returns custom name or ''
String nombreVisible(String deviceId, String platformName); // fallback chain
```
```dart
// ServicioEcualizador -- new methods
Future<Map<String, String>> cargarNombresDispositivos();
Future<void> guardarNombresDispositivos(Map<String, String> nombres);
```
## Testing Strategy
| Layer | What to Test | Approach |
|-------|-------------|----------|
| Unit | `cargarPersistido()` seeds `_dispositivoActualId` when multi-device ON | Fake `ServicioDispositivoAudio` returns known device; assert `dispositivoActualId != null` after load |
| Unit | `renombrarDispositivo` persists and notifies | Call rename, verify SP key written and listeners notified |
| Unit | `nombreVisible` fallback chain: custom > platform > id | Three cases: all present, no custom, no platform |
| Unit | Connection indicator: `entry.key == dispositivoActualId` | Verify boolean derivation in widget test or pure logic |
| Widget | Bottom sheet renders rename + EQ | `pumpWidget` with `_DialogoEdicionDispositivo`, verify TextField and `EcualizadorWidget` present |
## Migration / Rollout
No migration required. The new SP key `eq_nombres_dispositivos_v1` is created on first rename. If the code is reverted, the key is simply ignored -- no data loss or corruption. The startup fix is additive (one extra method call).
## Open Questions
- None blocking. The `nombresDispositivos` omission from export schema v4 is acknowledged and deferred per proposal.