docs(openspec): add SDD artifact trail for recent alarm and EQ changes
Build & Deploy PluriWave / Análisis de código (push) Successful in 38s
Build & Deploy PluriWave / Build APK + AAB release (push) Successful in 2m30s

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.
This commit is contained in:
2026-07-04 12:42:11 +02:00
parent e5b6d8acb3
commit bccc5c48b8
64 changed files with 6020 additions and 0 deletions
+213
View File
@@ -0,0 +1,213 @@
# Design: Multi-Device Equalizer
## Technical Approach
Add a device dimension to the existing 2-level EQ resolution (station > global) by introducing a platform channel bridge for device detection, a Dart service abstraction, and extending `EstadoEcualizador` to resolve through a 4-level hierarchy. Follows existing project patterns: ChangeNotifier state, SharedPreferences persistence via `ServicioEcualizador`, platform channels in `MainActivity.kt`, and constructor-injected fakes for testing.
## Architecture Decisions
### ADR-1: Platform Channel vs Package
| Option | Tradeoff | Decision |
|--------|----------|----------|
| Custom platform channel `pluriwave/audio_devices` | Native code in Kotlin+Swift; full control over device ID format, BT MAC access | **Chosen** |
| `flutter_audio_output` package | No native code; unmaintained (2021), no BT MAC, dependency risk | Rejected |
| `audio_session` events only | Zero new code; incomplete -- no BT identity, only becoming-noisy | Rejected |
**Rationale**: Project already has 3 platform channels (visualizer, alarm, file_actions). The pattern is established. BT MAC from `AudioManager.getDevices()` requires no extra permission and gives stable device keys.
### ADR-2: Device Service as Abstract Class
| Option | Tradeoff | Decision |
|--------|----------|----------|
| Abstract `ServicioDispositivoAudio` with real + fake impls | Testable without platform channels; matches `ServicioAudio`/`ServicioEcualizador` pattern | **Chosen** |
| Concrete class with `@visibleForTesting` fields | Simpler; harder to fake stream behavior in tests | Rejected |
**Rationale**: `EstadoEcualizador` tests must verify device-change reactions. An abstract class with `FakeServicioDispositivoAudio` in `test/helpers/fakes.dart` follows the established fake pattern.
### ADR-3: Composite Key for Matrix Persistence
| Option | Tradeoff | Decision |
|--------|----------|----------|
| `"stationUuid:deviceId"` string key in flat map | Simple; ~80 bytes/entry, predictable SP size | **Chosen** |
| Nested map `{stationUuid: {deviceId: preset}}` | Type-safe; more complex serialization/deserialization | Rejected |
**Rationale**: SharedPreferences stores a single JSON string. A flat map with composite keys is simpler to serialize, query, and migrate. Delimiter `:` is safe because station UUIDs are RFC 4122 (no colons) and device IDs use `:` only inside BT MACs which appear after the `bt_a2dp:` prefix.
### ADR-4: Resolution Wiring Point
| Option | Tradeoff | Decision |
|--------|----------|----------|
| `EstadoEcualizador` subscribes to device stream, resolves internally | Single owner of resolution logic; matches existing pattern where `EstadoEcualizador` owns all EQ state | **Chosen** |
| `PluriWaveAudioHandler` resolves via callback | Keeps resolution near the engine; requires handler to know about stations and persistence | Rejected |
**Rationale**: `PluriWaveAudioHandler` is intentionally thin on state (it stores `_presetActual` only). The handler calls `aplicarPreset()` -- it should not know about resolution hierarchy. `EstadoEcualizador` already owns the station-map resolution.
### ADR-5: EQ Re-application After `_recrearPlayer()`
| Option | Tradeoff | Decision |
|--------|----------|----------|
| `_activarEcualizador()` applies `_presetActual` (no change to handler) and `EstadoEcualizador` keeps `_presetActual` updated on device/station change | Handler stays unchanged; state layer ensures `_presetActual` is always the resolved preset | **Chosen** |
| Inject resolution callback into handler | Handler becomes aware of device dimension; breaks current layering | Rejected |
**Rationale**: `_activarEcualizador()` already calls `aplicarPreset(_presetActual)`. If `EstadoEcualizador` updates `_presetActual` via `aplicarPresetActivo()` whenever device or station changes, the handler needs no modification. The existing `aplicarPresetActivo` path already flows through `ServicioAudio.aplicarPreset()` to the handler.
### ADR-6: Feature Toggle Scope
| Option | Tradeoff | Decision |
|--------|----------|----------|
| SP key `eq_multi_device_enabled_v1` read by `EstadoEcualizador`; when false, skip device subscription and 4-level resolution | Zero behavioral change when off; toggle is at the state layer | **Chosen** |
| Feature flag in UI only (hide settings section) | State layer still runs device logic even when "disabled" | Rejected |
**Rationale**: Toggle must fully isolate the feature. When off, `EstadoEcualizador` should behave identically to current code -- no device stream subscription, 2-level resolution only.
## Data Flow
```
Platform (Android/iOS)
|
AudioDeviceCallback /
routeChangeNotification
|
+----- EventChannel ------+
| pluriwave/audio_devices |
+-------------------------+
|
ServicioDispositivoAudio
Stream<DispositivoAudio>
|
+--- EstadoEcualizador (ChangeNotifier) ---+
| |
| resolve: matrix > station > device > global
| |
+----→ aplicarPresetActivo(resolved) -------+
| |
ServicioAudio ServicioEcualizador
(apply to engine) (persist to SP)
```
Device change flow:
1. Native callback fires (connect/disconnect)
2. EventChannel pushes device event to Dart
3. `ServicioDispositivoAudio` emits `DispositivoAudio` on stream
4. `EstadoEcualizador._onDeviceChanged()` triggers 4-level resolution
5. Resolved preset applied via `aplicarPresetActivo()` (existing path)
6. If first-seen device: copy current preset as initial device preset
## File Changes
| File | Action | Description |
|------|--------|-------------|
| `lib/modelos/dispositivo_audio.dart` | Create | `DispositivoAudio` value model + `TipoDispositivo` enum |
| `lib/servicios/servicio_dispositivo_audio.dart` | Create | Abstract class + platform channel implementation |
| `android/.../MainActivity.kt` | Modify | Add `pluriwave/audio_devices` EventChannel + MethodChannel |
| `ios/Runner/AudioDevicesPlugin.swift` | Create | AVAudioSession route detection |
| `ios/Runner/AppDelegate.swift` | Modify | Register `AudioDevicesPlugin` |
| `lib/servicios/servicio_ecualizador.dart` | Modify | New SP keys, device/matrix CRUD, extended `ConfiguracionEcualizador` |
| `lib/estado/estado_ecualizador.dart` | Modify | Device stream subscription, 4-level resolution, toggle logic |
| `lib/servicios/servicio_export_import.dart` | Modify | v3 schema with `presetsPorDispositivo` + `presetsMatriz` fields |
| `lib/pantallas/pantalla_ajustes.dart` | Modify | `_SeccionEcualizadorAvanzado` widget behind feature toggle |
| `test/helpers/fakes.dart` | Modify | Add `FakeServicioDispositivoAudio` |
| `test/estado/estado_ecualizador_test.dart` | Modify | Device-dimension test cases |
## Interfaces / Contracts
### DispositivoAudio Model
```dart
enum TipoDispositivo {
altavozInterno, // "builtin_speaker"
auricularesCable, // "wired_headset"
bluetoothA2dp, // "bt_a2dp:<MAC>"
usbAudio, // "usb_headset:<address>"
desconocido, // fallback
}
class DispositivoAudio {
final String id; // Stable key: "builtin_speaker", "bt_a2dp:AA:BB:CC:DD:EE:FF"
final TipoDispositivo tipo;
final String nombre; // Human-readable: "Galaxy Buds Pro"
const DispositivoAudio({required this.id, required this.tipo, required this.nombre});
}
```
### ServicioDispositivoAudio Contract
```dart
abstract class ServicioDispositivoAudio {
/// Current active output device (null before first query).
DispositivoAudio? get dispositivoActual;
/// Stream of active device changes.
Stream<DispositivoAudio> get onDispositivoCambiado;
/// Query current device (pull).
Future<DispositivoAudio> obtenerDispositivoActual();
/// Clean up native resources.
Future<void> dispose();
}
```
### Platform Channel Protocol
Channel: `pluriwave/audio_devices`
**MethodChannel (pull):**
- `getActiveDevice` -> `Map<String, dynamic>` (`{id, type, name}`)
**EventChannel (push):**
- Stream of `Map<String, dynamic>` (`{id, type, name}`) on device change
Type constants (int, matching Android `AudioDeviceInfo` types):
- `2` = builtin_speaker, `3` = wired_headset, `8` = bt_a2dp, `14` = usb_headset
### Extended ConfiguracionEcualizador
```dart
class ConfiguracionEcualizador {
final PresetEcualizador principal;
final Map<String, PresetEcualizador> porEmisora;
final Map<String, PresetEcualizador> porDispositivo; // NEW
final Map<String, PresetEcualizador> matriz; // NEW (key: "uuid:deviceId")
final bool activo;
final bool multiDispositivoHabilitado; // NEW
}
```
### New SP Keys
```
eq_multi_device_enabled_v1 -> bool (default false)
eq_preset_por_dispositivo_v1 -> Map<deviceId, PresetEcualizador> (JSON)
eq_presets_matriz_v1 -> Map<"stationUuid:deviceId", PresetEcualizador> (JSON)
```
## Testing Strategy
| Layer | What to Test | Approach |
|-------|-------------|----------|
| Unit | 4-level resolution hierarchy (all combinations) | `EstadoEcualizador` with `FakeServicioDispositivoAudio` + `FakeServicioEcualizador` + `FakeServicioAudio` |
| Unit | Device change triggers preset swap | Emit device events on fake stream, assert `presetsAplicados` |
| Unit | Feature toggle isolation | Toggle off: verify no device subscription, 2-level resolution only |
| Unit | First-seen device copies current preset | Assert persistence call on unknown device ID |
| Unit | `_recrearPlayer()` re-applies correct preset | Verify `_presetActual` is device-resolved before `_activarEcualizador()` runs |
| Unit | ServicioEcualizador CRUD for new SP keys | Direct persistence layer tests |
| Unit | Export/import v3 round-trip + v2 backward compat | `ServicioExportImport` with device fields present/absent |
| Unit | DispositivoAudio model equality and serialization | Value model tests |
`FakeServicioDispositivoAudio` exposes a `StreamController<DispositivoAudio>` so tests can push device events synchronously.
## Migration / Rollout
- Feature toggle `eq_multi_device_enabled_v1` defaults to `false` -- zero behavioral change on upgrade.
- New SP keys are independent of existing keys; no migration needed.
- Export v3 adds nullable fields; v2 importers ignore unknown keys (existing `importar()` uses `Map<String, dynamic>.from()`).
- Import path handles missing device fields with null-safe defaults.
- Native channel is additive; no existing channels modified.
## Open Questions
- [x] iOS full implementation or stub? -- **Decided**: Full detection, no-op EQ application (same as current global EQ on iOS). State is tracked and persisted for UI display.
- [ ] Should station × device matrix entries be cleaned up when a station is removed from favorites? (Low priority; orphaned entries are harmless and tiny.)