feat(flutter): persist voice settings and per-user volume (TODO-039,040)
Add SharedPreferences persistence for transmit mode, release tail, input/output device. Per-user volume/mute persisted across sessions.
This commit is contained in:
@@ -18,11 +18,19 @@ class UiSettings {
|
|||||||
this.host = '',
|
this.host = '',
|
||||||
this.nickname = '',
|
this.nickname = '',
|
||||||
this.themeMode = UiThemeMode.system,
|
this.themeMode = UiThemeMode.system,
|
||||||
|
this.transmitModeIndex,
|
||||||
|
this.releaseTailMs,
|
||||||
|
this.inputDeviceId,
|
||||||
|
this.outputDeviceId,
|
||||||
});
|
});
|
||||||
|
|
||||||
final String host;
|
final String host;
|
||||||
final String nickname;
|
final String nickname;
|
||||||
final UiThemeMode themeMode;
|
final UiThemeMode themeMode;
|
||||||
|
final int? transmitModeIndex;
|
||||||
|
final int? releaseTailMs;
|
||||||
|
final String? inputDeviceId;
|
||||||
|
final String? outputDeviceId;
|
||||||
}
|
}
|
||||||
|
|
||||||
class UiPreferencesService {
|
class UiPreferencesService {
|
||||||
@@ -30,6 +38,10 @@ class UiPreferencesService {
|
|||||||
static const _nicknameKey = 'ui.nickname';
|
static const _nicknameKey = 'ui.nickname';
|
||||||
static const _themeModeKey = 'ui.theme_mode';
|
static const _themeModeKey = 'ui.theme_mode';
|
||||||
static const _permissionsExplainedKey = 'perms_explained';
|
static const _permissionsExplainedKey = 'perms_explained';
|
||||||
|
static const _transmitModeIndexKey = 'voice.transmit_mode_index';
|
||||||
|
static const _releaseTailMsKey = 'voice.release_tail_ms';
|
||||||
|
static const _inputDeviceIdKey = 'audio.input_device_id';
|
||||||
|
static const _outputDeviceIdKey = 'audio.output_device_id';
|
||||||
|
|
||||||
const UiPreferencesService();
|
const UiPreferencesService();
|
||||||
|
|
||||||
@@ -39,6 +51,10 @@ class UiPreferencesService {
|
|||||||
host: prefs.getString(_hostKey) ?? '',
|
host: prefs.getString(_hostKey) ?? '',
|
||||||
nickname: prefs.getString(_nicknameKey) ?? '',
|
nickname: prefs.getString(_nicknameKey) ?? '',
|
||||||
themeMode: UiThemeMode.fromStorage(prefs.getString(_themeModeKey)),
|
themeMode: UiThemeMode.fromStorage(prefs.getString(_themeModeKey)),
|
||||||
|
transmitModeIndex: prefs.getInt(_transmitModeIndexKey),
|
||||||
|
releaseTailMs: prefs.getInt(_releaseTailMsKey),
|
||||||
|
inputDeviceId: prefs.getString(_inputDeviceIdKey),
|
||||||
|
outputDeviceId: prefs.getString(_outputDeviceIdKey),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -53,6 +69,34 @@ class UiPreferencesService {
|
|||||||
await prefs.setString(_themeModeKey, themeMode.name);
|
await prefs.setString(_themeModeKey, themeMode.name);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> saveTransmitModeIndex(int index) async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
await prefs.setInt(_transmitModeIndexKey, index);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> saveReleaseTailMs(int ms) async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
await prefs.setInt(_releaseTailMsKey, ms);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> saveInputDeviceId(String? id) async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
if (id == null) {
|
||||||
|
await prefs.remove(_inputDeviceIdKey);
|
||||||
|
} else {
|
||||||
|
await prefs.setString(_inputDeviceIdKey, id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> saveOutputDeviceId(String? id) async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
if (id == null) {
|
||||||
|
await prefs.remove(_outputDeviceIdKey);
|
||||||
|
} else {
|
||||||
|
await prefs.setString(_outputDeviceIdKey, id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Future<bool> hasExplainedPermissions() async {
|
Future<bool> hasExplainedPermissions() async {
|
||||||
final prefs = await SharedPreferences.getInstance();
|
final prefs = await SharedPreferences.getInstance();
|
||||||
return prefs.getBool(_permissionsExplainedKey) ?? false;
|
return prefs.getBool(_permissionsExplainedKey) ?? false;
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ class AudioDeviceListTile extends StatefulWidget {
|
|||||||
AudioDeviceListLoader? loadDevices,
|
AudioDeviceListLoader? loadDevices,
|
||||||
AudioDeviceSetter? setInputDevice,
|
AudioDeviceSetter? setInputDevice,
|
||||||
AudioDeviceSetter? setOutputDevice,
|
AudioDeviceSetter? setOutputDevice,
|
||||||
|
this.onDeviceChanged,
|
||||||
}) : loadDevices = loadDevices ?? rust.listAudioDevices,
|
}) : loadDevices = loadDevices ?? rust.listAudioDevices,
|
||||||
setInputDevice = setInputDevice ?? rust.setInputDevice,
|
setInputDevice = setInputDevice ?? rust.setInputDevice,
|
||||||
setOutputDevice = setOutputDevice ?? rust.setOutputDevice;
|
setOutputDevice = setOutputDevice ?? rust.setOutputDevice;
|
||||||
@@ -46,6 +47,10 @@ class AudioDeviceListTile extends StatefulWidget {
|
|||||||
/// Selects an output device.
|
/// Selects an output device.
|
||||||
final AudioDeviceSetter setOutputDevice;
|
final AudioDeviceSetter setOutputDevice;
|
||||||
|
|
||||||
|
/// Called after a device selection succeeds. Receives the device id
|
||||||
|
/// (null for system default).
|
||||||
|
final ValueChanged<String?>? onDeviceChanged;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<AudioDeviceListTile> createState() => _AudioDeviceListTileState();
|
State<AudioDeviceListTile> createState() => _AudioDeviceListTileState();
|
||||||
}
|
}
|
||||||
@@ -88,6 +93,7 @@ class _AudioDeviceListTileState extends State<AudioDeviceListTile> {
|
|||||||
setState(() {
|
setState(() {
|
||||||
_selectedDeviceId = deviceId;
|
_selectedDeviceId = deviceId;
|
||||||
});
|
});
|
||||||
|
widget.onDeviceChanged?.call(deviceId);
|
||||||
|
|
||||||
final selectedName = _selectedDevice?.name ?? 'System default';
|
final selectedName = _selectedDevice?.name ?? 'System default';
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
import '../l10n/generated/app_localizations.dart';
|
import '../l10n/generated/app_localizations.dart';
|
||||||
import '../services/channel_spacer.dart';
|
import '../services/channel_spacer.dart';
|
||||||
@@ -571,11 +572,40 @@ class _ClientVolumePreference {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _ClientVolumePreferences extends ChangeNotifier {
|
class _ClientVolumePreferences extends ChangeNotifier {
|
||||||
_ClientVolumePreferences._();
|
_ClientVolumePreferences._() {
|
||||||
|
_load();
|
||||||
|
}
|
||||||
|
|
||||||
static final instance = _ClientVolumePreferences._();
|
static final instance = _ClientVolumePreferences._();
|
||||||
|
|
||||||
|
static const _prefsKey = 'client_volume_prefs';
|
||||||
|
|
||||||
final Map<BigInt, _ClientVolumePreference> _byClientId = {};
|
final Map<BigInt, _ClientVolumePreference> _byClientId = {};
|
||||||
|
bool _loaded = false;
|
||||||
|
|
||||||
|
Future<void> _load() async {
|
||||||
|
if (_loaded) return;
|
||||||
|
_loaded = true;
|
||||||
|
try {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
final raw = prefs.getStringList(_prefsKey) ?? const [];
|
||||||
|
for (final entry in raw) {
|
||||||
|
final parts = entry.split(':');
|
||||||
|
if (parts.length == 3) {
|
||||||
|
final id = BigInt.tryParse(parts[0]);
|
||||||
|
final volume = double.tryParse(parts[1]);
|
||||||
|
final muted = parts[2] == '1';
|
||||||
|
if (id != null && volume != null) {
|
||||||
|
_byClientId[id] = _ClientVolumePreference(
|
||||||
|
volume: volume,
|
||||||
|
muted: muted,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
notifyListeners();
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
_ClientVolumePreference preferenceFor(BigInt clientId) {
|
_ClientVolumePreference preferenceFor(BigInt clientId) {
|
||||||
return _byClientId[clientId] ?? const _ClientVolumePreference();
|
return _byClientId[clientId] ?? const _ClientVolumePreference();
|
||||||
@@ -588,6 +618,17 @@ class _ClientVolumePreferences extends ChangeNotifier {
|
|||||||
_byClientId.remove(clientId);
|
_byClientId.remove(clientId);
|
||||||
}
|
}
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
|
_save();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _save() async {
|
||||||
|
try {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
final raw = _byClientId.entries
|
||||||
|
.map((e) => '${e.key}:${e.value.volume}:${e.value.muted ? 1 : 0}')
|
||||||
|
.toList();
|
||||||
|
await prefs.setStringList(_prefsKey, raw);
|
||||||
|
} catch (_) {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -47,6 +47,8 @@ class VoiceSettingsDialog extends StatefulWidget {
|
|||||||
this.talkPower,
|
this.talkPower,
|
||||||
this.neededTalkPower,
|
this.neededTalkPower,
|
||||||
this.talkPowerGranted,
|
this.talkPowerGranted,
|
||||||
|
this.onInputDeviceChanged,
|
||||||
|
this.onOutputDeviceChanged,
|
||||||
});
|
});
|
||||||
|
|
||||||
final rust.BridgeTransmitMode initialMode;
|
final rust.BridgeTransmitMode initialMode;
|
||||||
@@ -58,6 +60,8 @@ class VoiceSettingsDialog extends StatefulWidget {
|
|||||||
final int? talkPower;
|
final int? talkPower;
|
||||||
final int? neededTalkPower;
|
final int? neededTalkPower;
|
||||||
final bool? talkPowerGranted;
|
final bool? talkPowerGranted;
|
||||||
|
final ValueChanged<String?>? onInputDeviceChanged;
|
||||||
|
final ValueChanged<String?>? onOutputDeviceChanged;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<VoiceSettingsDialog> createState() => _VoiceSettingsDialogState();
|
State<VoiceSettingsDialog> createState() => _VoiceSettingsDialogState();
|
||||||
@@ -207,13 +211,15 @@ class _VoiceSettingsDialogState extends State<VoiceSettingsDialog> {
|
|||||||
if (!isAndroidHost && !isIosHost) ...[
|
if (!isAndroidHost && !isIosHost) ...[
|
||||||
const Divider(height: 24),
|
const Divider(height: 24),
|
||||||
const VoiceSectionHeader('Audio devices'),
|
const VoiceSectionHeader('Audio devices'),
|
||||||
const AudioDeviceListTile(
|
AudioDeviceListTile(
|
||||||
label: 'Input',
|
label: 'Input',
|
||||||
kind: AudioDeviceKind.input,
|
kind: AudioDeviceKind.input,
|
||||||
|
onDeviceChanged: widget.onInputDeviceChanged,
|
||||||
),
|
),
|
||||||
const AudioDeviceListTile(
|
AudioDeviceListTile(
|
||||||
label: 'Output',
|
label: 'Output',
|
||||||
kind: AudioDeviceKind.output,
|
kind: AudioDeviceKind.output,
|
||||||
|
onDeviceChanged: widget.onOutputDeviceChanged,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -67,4 +67,44 @@ void main() {
|
|||||||
|
|
||||||
expect(settings.themeMode, UiThemeMode.system);
|
expect(settings.themeMode, UiThemeMode.system);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('loads null voice settings when unset', () async {
|
||||||
|
final settings = await service.loadSettings();
|
||||||
|
|
||||||
|
expect(settings.transmitModeIndex, isNull);
|
||||||
|
expect(settings.releaseTailMs, isNull);
|
||||||
|
expect(settings.inputDeviceId, isNull);
|
||||||
|
expect(settings.outputDeviceId, isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('saves and loads transmit mode index', () async {
|
||||||
|
await service.saveTransmitModeIndex(1);
|
||||||
|
final settings = await service.loadSettings();
|
||||||
|
|
||||||
|
expect(settings.transmitModeIndex, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('saves and loads release tail ms', () async {
|
||||||
|
await service.saveReleaseTailMs(300);
|
||||||
|
final settings = await service.loadSettings();
|
||||||
|
|
||||||
|
expect(settings.releaseTailMs, 300);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('saves and loads audio device ids', () async {
|
||||||
|
await service.saveInputDeviceId('input-123');
|
||||||
|
await service.saveOutputDeviceId('output-456');
|
||||||
|
final settings = await service.loadSettings();
|
||||||
|
|
||||||
|
expect(settings.inputDeviceId, 'input-123');
|
||||||
|
expect(settings.outputDeviceId, 'output-456');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('removes audio device id when set to null', () async {
|
||||||
|
await service.saveInputDeviceId('input-123');
|
||||||
|
await service.saveInputDeviceId(null);
|
||||||
|
final settings = await service.loadSettings();
|
||||||
|
|
||||||
|
expect(settings.inputDeviceId, isNull);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user