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:
Edison Jwa
2026-06-11 20:43:14 +09:00
parent 7ab50a4eb5
commit ef19f4e7ce
5 changed files with 140 additions and 3 deletions
@@ -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);
});
} }