feat: integrate chat voice and diagnostics client

This commit is contained in:
Edison Jwa
2026-05-23 06:51:55 +09:00
parent 7e28791ec2
commit 7d5d8c2c90
93 changed files with 10273 additions and 3347 deletions
+170 -199
View File
@@ -145,7 +145,7 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
ConnectionPhase _phase = ConnectionPhase.idle;
bool get _serverReachable => _phase.isServerReachable;
rust.BridgeSnapshot? _snapshot;
String? _error;
final List<String> _uiDiagnostics = [];
rust.BridgeAudioStats? _audioStats;
Timer? _statsTimer;
int _statsTick = 0;
@@ -195,11 +195,10 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
final List<ChatEntry> _chatMessages = [];
int _chatUnread = 0;
bool _chatOpen = false;
bool _showPokeDialogs = true;
final ValueNotifier<List<_ReceivedPoke>> _pokeDialogPokes = ValueNotifier(
final ValueNotifier<List<_ReceivedPoke>> _pokeSnackBarPokes = ValueNotifier(
const [],
);
bool _pokeDialogShowing = false;
bool _pokeSnackBarVisible = false;
IconData? _audioRoute;
// SDD-106 / SRS-209: Android RECORD_AUDIO runtime permission service.
@@ -246,25 +245,12 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
if (settings.nickname.isNotEmpty) {
_nickCtl.text = settings.nickname;
}
if (mounted) {
setState(() => _showPokeDialogs = settings.showPokeDialogs);
} else {
_showPokeDialogs = settings.showPokeDialogs;
}
} catch (_) {}
}
Future<void> _saveUiSettings({
String? host,
String? nickname,
bool? showPokeDialogs,
}) async {
Future<void> _saveUiSettings({String? host, String? nickname}) async {
try {
await _uiPreferences.saveSettings(
host: host,
nickname: nickname,
showPokeDialogs: showPokeDialogs,
);
await _uiPreferences.saveSettings(host: host, nickname: nickname);
} catch (_) {}
}
@@ -342,12 +328,45 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
});
} catch (e) {
if (!mounted) return;
setState(() => _error = e.toString());
_showUiError('clear permission mute', e);
} finally {
_permissionHardMuteClearInFlight = false;
}
}
void _recordUiDiagnostic(String area, Object error) {
final line = '${DateTime.now().toIso8601String()} [$area] $error';
debugPrint('chanora: $line');
_uiDiagnostics.add(line);
if (_uiDiagnostics.length > 100) {
_uiDiagnostics.removeRange(0, _uiDiagnostics.length - 100);
}
}
void _showUiError(String area, Object error) {
if (!mounted) return;
_showUiErrorSnackBar(area: area, error: error);
}
void _showUiErrorSnackBar({
required String area,
required Object error,
String? displayMessage,
}) {
_recordUiDiagnostic(area, error);
final l10n = AppL10n.of(context);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
behavior: SnackBarBehavior.floating,
content: Text(
displayMessage ?? l10n.statusError(error.toString()),
maxLines: 3,
overflow: TextOverflow.ellipsis,
),
),
);
}
bool _handleFocusedPttKey(KeyEvent event) {
final label = pttDisplayLabelForKey(event.logicalKey);
final isBoundKey =
@@ -564,13 +583,11 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
}
});
if (isPoke) {
if (_showPokeDialogs) {
_appendPokeDialog(
senderName: senderName,
message: message,
receivedAt: receivedAt,
);
}
_showPokeSnackBar(
senderName: senderName,
message: message,
receivedAt: receivedAt,
);
return;
}
if (!_chatOpen && _chatUnread > 0) {
@@ -656,7 +673,7 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
_hostCtl.dispose();
_nickCtl.dispose();
_passwordCtl.dispose();
_pokeDialogPokes.dispose();
_pokeSnackBarPokes.dispose();
_androidPermissions.recordAudioState.removeListener(
_onRecordAudioPermissionChanged,
);
@@ -678,7 +695,6 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
}) async {
setState(() {
_phase = ConnectionPhase.connecting;
_error = null;
_snapshot = null;
_chatMessages.clear();
});
@@ -718,8 +734,8 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
}
setState(() {
_phase = ConnectionPhase.idle;
_error = errorStr;
});
_showUiErrorSnackBar(area: 'connect', error: e);
}
}
@@ -786,7 +802,7 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
await rust.setPtt(active: active);
} catch (e) {
if (!mounted || !reportError) return;
setState(() => _error = e.toString());
_showUiError('ptt', e);
}
}
@@ -800,7 +816,7 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
});
} catch (e) {
if (!mounted) return;
setState(() => _error = e.toString());
_showUiError('output mute', e);
}
}
@@ -812,7 +828,6 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
if (_pendingVoiceChannelId != null) return;
if (ch.id == _currentVoiceChannelId) return;
final l10n = AppL10n.of(context);
final messenger = ScaffoldMessenger.of(context);
String? password;
if (askForPassword) {
password = await _askChannelPassword(l10n);
@@ -872,18 +887,15 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
} catch (e) {
if (!mounted) return;
// Surface as a SnackBar so the user sees it even while
// connected (the persistent _error string lives in the
// pre-connect area and is hidden post-connect). The message
// is selected by TS3 error code per the canonical
// catalogue at https://github.com/ReSpeak/tsdeclarations.
// connected. The message is selected by TS3 error code per
// the canonical catalogue at
// https://github.com/ReSpeak/tsdeclarations.
final message = channelJoinErrorMessage(l10n, e);
setState(() => _pendingVoiceChannelId = null);
messenger.showSnackBar(
SnackBar(
content: Text(message),
behavior: SnackBarBehavior.floating,
duration: const Duration(seconds: 4),
),
_showUiErrorSnackBar(
area: 'join channel',
error: e,
displayMessage: message,
);
}
}
@@ -914,7 +926,7 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
});
} catch (e) {
if (!mounted) return;
setState(() => _error = e.toString());
_showUiError('hard mute', e);
}
}
@@ -938,7 +950,7 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
} catch (e) {
if (held) {
if (!mounted) return;
setState(() => _error = e.toString());
_showUiError('onscreen ptt', e);
}
}
}
@@ -977,7 +989,7 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
setState(() => _transmitMode = mode);
} catch (e) {
if (!mounted) return;
setState(() => _error = e.toString());
_showUiError('transmit mode', e);
}
},
onReleaseTailChanged: (ms) async {
@@ -987,7 +999,7 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
setState(() => _releaseTailMs = ms);
} catch (e) {
if (!mounted) return;
setState(() => _error = e.toString());
_showUiError('release tail', e);
}
},
onAudioConfigChanged: (config) async {
@@ -995,7 +1007,7 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
await rust.setAudioProcessingConfig(config: config);
} catch (e) {
if (!mounted) return;
setState(() => _error = e.toString());
_showUiError('audio processing config', e);
}
},
);
@@ -1023,7 +1035,7 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
await rust.setAudioProcessingConfig(config: result.audioConfig);
} catch (e) {
if (!mounted) return;
setState(() => _error = e.toString());
_showUiError('voice settings', e);
}
if (result.bindKeyRequested && mounted) {
await _onConfigurePtt(context);
@@ -1055,7 +1067,7 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
setState(() => _applySnapshot(snap));
} catch (e) {
if (!mounted) return;
setState(() => _error = e.toString());
_showUiError('refresh snapshot', e);
}
}
@@ -1113,7 +1125,6 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
_phase = phase;
_snapshot = null;
_audioStats = null;
_error = null;
_inputMuted = false;
_outputMuted = false;
_inChannel = false;
@@ -1171,36 +1182,52 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
if (mounted) setState(() => _chatOpen = false);
}
void _appendPokeDialog({
void _showPokeSnackBar({
required String senderName,
required String message,
required DateTime receivedAt,
}) {
_pokeDialogPokes.value = [
..._pokeDialogPokes.value,
_pokeSnackBarPokes.value = [
..._pokeSnackBarPokes.value,
_ReceivedPoke(
senderName: senderName,
message: message,
receivedAt: receivedAt,
),
];
unawaited(_showPokeDialog());
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) _renderPokeSnackBar();
});
}
Future<void> _showPokeDialog() async {
if (_pokeDialogShowing || _pokeDialogPokes.value.isEmpty || !mounted) {
return;
}
_pokeDialogShowing = true;
try {
await showDialog<void>(
context: context,
builder: (ctx) => _PokeDialog(pokes: _pokeDialogPokes),
);
} finally {
_pokeDialogPokes.value = const [];
_pokeDialogShowing = false;
}
void _renderPokeSnackBar() {
if (_pokeSnackBarPokes.value.isEmpty || _pokeSnackBarVisible) return;
_pokeSnackBarVisible = true;
final messenger = ScaffoldMessenger.of(context);
final controller = messenger.showSnackBar(
SnackBar(
behavior: SnackBarBehavior.floating,
margin: _chatSnackBarMargin(),
duration: const Duration(days: 365),
dismissDirection: DismissDirection.none,
content: _PokeSnackBarContent(pokes: _pokeSnackBarPokes),
action: SnackBarAction(
label: AppL10n.of(context).pokeSnackBarClearAction,
onPressed: () {
_pokeSnackBarPokes.value = const [];
_pokeSnackBarVisible = false;
},
),
),
);
controller.closed.then((_) {
if (!mounted) return;
_pokeSnackBarVisible = false;
if (_pokeSnackBarPokes.value.isEmpty) return;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) _renderPokeSnackBar();
});
});
}
void _showChatMessageSnackBar({
@@ -1208,6 +1235,7 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
required String message,
required rust.BridgeMessageTarget target,
}) {
if (_pokeSnackBarPokes.value.isNotEmpty) return;
final messenger = ScaffoldMessenger.of(context);
messenger.hideCurrentSnackBar();
messenger.showSnackBar(
@@ -1313,7 +1341,11 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
Future<void> _onShowDiagnostics(BuildContext context) async {
final l10n = AppL10n.of(context);
final text = rust.exportDiagnostics();
final rustText = rust.exportDiagnostics();
final uiText = _uiDiagnostics.isEmpty
? 'UI diagnostics: none'
: ['UI diagnostics:', ..._uiDiagnostics].join('\n');
final text = '$uiText\n\nRust diagnostics:\n$rustText';
if (!mounted) return;
await showDialog<void>(
context: context,
@@ -1370,10 +1402,7 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
);
} catch (e) {
if (!mounted) return;
final messenger = ScaffoldMessenger.of(this.context);
messenger.showSnackBar(
SnackBar(content: Text(l10n.statusError(e.toString()))),
);
_showUiErrorSnackBar(area: 'ptt portal binding', error: e);
}
return;
}
@@ -1405,13 +1434,7 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
if (!mounted) return;
// Surface the failure as a snackbar so the user sees that
// their binding did not stick.
if (!mounted) return;
// Use the State's context (guaranteed valid because we
// re-checked `mounted` immediately above).
final messenger = ScaffoldMessenger.of(this.context);
messenger.showSnackBar(
SnackBar(content: Text(l10n.statusError(e.toString()))),
);
_showUiErrorSnackBar(area: 'ptt binding', error: e);
}
}
@@ -1514,7 +1537,7 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
await _reloadBookmarks();
} catch (e) {
if (!mounted) return;
setState(() => _error = e.toString());
_showUiError('add bookmark', e);
}
}
@@ -1524,7 +1547,7 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
await _reloadBookmarks();
} catch (e) {
if (!mounted) return;
setState(() => _error = e.toString());
_showUiError('delete bookmark', e);
}
}
@@ -1573,24 +1596,10 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
).showSnackBar(SnackBar(content: Text('Added bookmark "$bookmarkName"')));
} catch (e) {
if (!mounted) return;
setState(() => _error = e.toString());
_showUiError('teamspeak link bookmark', e);
}
}
Future<void> _onOpenAppSettings() async {
await Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => _AppSettingsPage(
showPokeDialogs: _showPokeDialogs,
onShowPokeDialogsChanged: (value) {
setState(() => _showPokeDialogs = value);
unawaited(_saveUiSettings(showPokeDialogs: value));
},
),
),
);
}
@override
Widget build(BuildContext context) {
final l10n = AppL10n.of(context);
@@ -1617,11 +1626,6 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
onPressed: _toggleOutputMute,
),
],
IconButton(
tooltip: l10n.settingsAction,
icon: const Icon(Icons.settings_outlined),
onPressed: _onOpenAppSettings,
),
IconButton(
tooltip: l10n.aboutAction,
icon: const Icon(Icons.info_outline),
@@ -1656,7 +1660,6 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
final statusText = connectionStatusText(
phase: _phase,
l10n: l10n,
error: _error,
serverName: _snapshot?.serverName,
lostReason: _lostReason,
reconnectAttempt: _reconnectAttempt,
@@ -1686,11 +1689,16 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
if (!isWideSnapshot) ...[banner, const SizedBox(height: 12)],
if (!_serverReachable)
Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(connTokens.icon, size: 18, color: connTokens.color),
const SizedBox(width: 6),
Text(statusText, style: theme.textTheme.titleMedium),
Expanded(
child: Text(
statusText,
softWrap: true,
style: theme.textTheme.titleMedium,
),
),
],
),
if (_serverReachable && _audioRoute != null) ...[
@@ -1928,75 +1936,74 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
}
}
class _PokeDialog extends StatelessWidget {
const _PokeDialog({required this.pokes});
class _PokeSnackBarContent extends StatelessWidget {
const _PokeSnackBarContent({required this.pokes});
final ValueListenable<List<_ReceivedPoke>> pokes;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final l10n = AppL10n.of(context);
return AlertDialog(
icon: Icon(
Icons.notifications_active_outlined,
color: theme.colorScheme.primary,
),
title: Text(l10n.pokeDialogTitle),
content: ValueListenableBuilder<List<_ReceivedPoke>>(
valueListenable: pokes,
builder: (context, entries, _) => ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 360, maxHeight: 360),
child: ListView.separated(
shrinkWrap: true,
itemCount: entries.length,
separatorBuilder: (_, _) => const SizedBox(height: 10),
itemBuilder: (_, index) => _PokeDialogEntry(poke: entries[index]),
),
),
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: Text(l10n.pokeDialogCloseAction),
),
],
return ValueListenableBuilder<List<_ReceivedPoke>>(
valueListenable: pokes,
builder: (context, entries, _) {
final l10n = AppL10n.of(context);
final visible = entries.length <= 3
? entries
: entries.sublist(entries.length - 3);
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (entries.length > 3)
Padding(
padding: const EdgeInsetsDirectional.only(bottom: 2),
child: Text(
l10n.pokeSnackBarMoreIndicator,
style: const TextStyle(fontWeight: FontWeight.w600),
),
),
for (final poke in visible) _PokeSnackBarRow(poke: poke),
],
);
},
);
}
}
class _AppSettingsPage extends StatefulWidget {
const _AppSettingsPage({
required this.showPokeDialogs,
required this.onShowPokeDialogsChanged,
});
class _PokeSnackBarRow extends StatelessWidget {
const _PokeSnackBarRow({required this.poke});
final bool showPokeDialogs;
final ValueChanged<bool> onShowPokeDialogsChanged;
@override
State<_AppSettingsPage> createState() => _AppSettingsPageState();
}
class _AppSettingsPageState extends State<_AppSettingsPage> {
late bool _showPokeDialogs = widget.showPokeDialogs;
final _ReceivedPoke poke;
@override
Widget build(BuildContext context) {
final l10n = AppL10n.of(context);
return Scaffold(
appBar: AppBar(title: Text(l10n.appSettingsTitle)),
body: ListView(
final message = poke.message.trim();
final text = message.isEmpty
? l10n.pokeSnackBarIncomingNoMessage(poke.senderName)
: l10n.pokeSnackBarIncomingWithMessage(poke.senderName, message);
return Padding(
padding: const EdgeInsets.symmetric(vertical: 1),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SwitchListTile(
title: Text(l10n.pokeAlertsTitle),
subtitle: Text(l10n.pokeAlertsDescription),
value: _showPokeDialogs,
onChanged: (value) {
setState(() => _showPokeDialogs = value);
widget.onShowPokeDialogsChanged(value);
},
Expanded(
child: Text(
text,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontWeight: FontWeight.w600),
),
),
const SizedBox(width: 12),
Text(
pokeSnackBarTimeLabel(poke.receivedAt),
style: TextStyle(
color: Theme.of(
context,
).colorScheme.onInverseSurface.withValues(alpha: 0.72),
),
),
],
),
@@ -2004,43 +2011,7 @@ class _AppSettingsPageState extends State<_AppSettingsPage> {
}
}
class _PokeDialogEntry extends StatelessWidget {
const _PokeDialogEntry({required this.poke});
final _ReceivedPoke poke;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final l10n = AppL10n.of(context);
final message = poke.message.trim();
final hasMessage = message.isNotEmpty;
final time = chatTimeLabel(poke.receivedAt);
final detail = hasMessage
? l10n.pokeDialogIncomingWithMessage(time, poke.senderName)
: l10n.pokeDialogIncomingNoMessage(time, poke.senderName);
return DecoratedBox(
decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(8),
),
child: Padding(
padding: const EdgeInsets.all(12),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
detail,
style: theme.textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w600,
),
),
if (hasMessage) ...[const SizedBox(height: 8), Text(message)],
],
),
),
);
}
String pokeSnackBarTimeLabel(DateTime timestamp) {
String two(int value) => value.toString().padLeft(2, '0');
return '${two(timestamp.hour)}:${two(timestamp.minute)}';
}