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.
This commit is contained in:
@@ -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.)
|
||||
Reference in New Issue
Block a user