chore: restore product scaffold to rollback baseline
This commit is contained in:
@@ -313,47 +313,51 @@ class _AndroidAudioOutputPickerSheet extends StatelessWidget {
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppL10n.of(context);
|
||||
final theme = Theme.of(context);
|
||||
final maxHeight = MediaQuery.sizeOf(context).height * 0.72;
|
||||
return SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 8, 20, 24),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Text(l10n.audioOutputLabel, style: theme.textTheme.titleLarge),
|
||||
const SizedBox(height: 16),
|
||||
_PickerRow(
|
||||
icon: Icons.speaker,
|
||||
label: l10n.audioRouteSystemDefault,
|
||||
selected: !devices.any((d) => d.isSelected),
|
||||
onTap: () => Navigator.of(context).pop('auto'),
|
||||
),
|
||||
if (loading)
|
||||
const Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 24),
|
||||
child: Center(child: CircularProgressIndicator()),
|
||||
)
|
||||
else if (devices.isEmpty)
|
||||
TextButton.icon(
|
||||
onPressed: onRefresh,
|
||||
icon: const Icon(Icons.refresh),
|
||||
label: Text(l10n.audioRouteRefreshDevices),
|
||||
)
|
||||
else
|
||||
for (final device in devices)
|
||||
_PickerRow(
|
||||
icon: AudioOutputTileState._androidDeviceIcon(device.type),
|
||||
label: AudioOutputTileState._androidDeviceLabel(
|
||||
device.type,
|
||||
device.name,
|
||||
l10n,
|
||||
child: ConstrainedBox(
|
||||
constraints: BoxConstraints(maxHeight: maxHeight),
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.fromLTRB(20, 8, 20, 24),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Text(l10n.audioOutputLabel, style: theme.textTheme.titleLarge),
|
||||
const SizedBox(height: 16),
|
||||
_PickerRow(
|
||||
icon: Icons.speaker,
|
||||
label: l10n.audioRouteSystemDefault,
|
||||
selected: !devices.any((d) => d.isSelected),
|
||||
onTap: () => Navigator.of(context).pop('auto'),
|
||||
),
|
||||
if (loading)
|
||||
const Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 24),
|
||||
child: Center(child: CircularProgressIndicator()),
|
||||
)
|
||||
else if (devices.isEmpty)
|
||||
TextButton.icon(
|
||||
onPressed: onRefresh,
|
||||
icon: const Icon(Icons.refresh),
|
||||
label: Text(l10n.audioRouteRefreshDevices),
|
||||
)
|
||||
else
|
||||
for (final device in devices)
|
||||
_PickerRow(
|
||||
icon: AudioOutputTileState._androidDeviceIcon(device.type),
|
||||
label: AudioOutputTileState._androidDeviceLabel(
|
||||
device.type,
|
||||
device.name,
|
||||
l10n,
|
||||
),
|
||||
selected: device.isSelected,
|
||||
onTap: device.isAvailableForCommunication
|
||||
? () => Navigator.of(context).pop(device.id)
|
||||
: null,
|
||||
),
|
||||
selected: device.isSelected,
|
||||
onTap: device.isAvailableForCommunication
|
||||
? () => Navigator.of(context).pop(device.id)
|
||||
: null,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -201,32 +201,19 @@ bool androidShowsLimiterControl(AudioProcessingConfigState state) {
|
||||
|
||||
/// Never leave the UI on the hidden disabled backend.
|
||||
///
|
||||
/// Desktop keeps Silero as the default, but still allows a user-chosen
|
||||
/// WebRTC fallback when ONNX Runtime is unavailable.
|
||||
/// Windows and Linux use Silero as the primary VAD; WebRTC is still
|
||||
/// available internally as a runtime fallback.
|
||||
rust.BridgeVadBackend normalizedVadBackend(
|
||||
rust.BridgeVadBackend backend, {
|
||||
bool? isWindows,
|
||||
bool? isLinux,
|
||||
bool onnxRuntimeAvailable = true,
|
||||
}) {
|
||||
final normalized = backend == rust.BridgeVadBackend.disabled
|
||||
? rust.BridgeVadBackend.sileroOnnx
|
||||
: backend;
|
||||
final desktop =
|
||||
(isWindows ?? Platform.isWindows) || (isLinux ?? Platform.isLinux);
|
||||
if (desktop &&
|
||||
!onnxRuntimeAvailable &&
|
||||
normalized == rust.BridgeVadBackend.sileroOnnx) {
|
||||
return rust.BridgeVadBackend.webrtcVad;
|
||||
}
|
||||
if (!desktop) {
|
||||
return normalized;
|
||||
}
|
||||
return switch (normalized) {
|
||||
rust.BridgeVadBackend.webrtcVad ||
|
||||
rust.BridgeVadBackend.sileroOnnx => normalized,
|
||||
_ => rust.BridgeVadBackend.sileroOnnx,
|
||||
};
|
||||
if (desktop) return rust.BridgeVadBackend.sileroOnnx;
|
||||
return backend == rust.BridgeVadBackend.disabled
|
||||
? rust.BridgeVadBackend.sileroOnnx
|
||||
: backend;
|
||||
}
|
||||
|
||||
rust.BridgeIosVoiceProcessingMode normalizedIosProcessingMode(
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import 'dart:async' show unawaited;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../l10n/generated/app_localizations.dart';
|
||||
@@ -9,12 +11,9 @@ import '../src/rust/api.dart' as rust;
|
||||
import 'bbcode_text.dart';
|
||||
|
||||
const double _chatSidebarTileExtent = 92;
|
||||
const double _chatSidebarIndicatorExtent = 76;
|
||||
const double _chatSidebarIconExtent = 24;
|
||||
const double _chatSidebarIconSize = 20;
|
||||
const BorderRadius _chatSidebarIndicatorRadius = BorderRadius.all(
|
||||
Radius.circular(20),
|
||||
);
|
||||
const double _chatSidebarCompactTileExtent = 76;
|
||||
const double _chatSidebarCompactHeight = 84;
|
||||
const double _chatMobileBreakpoint = 600;
|
||||
|
||||
/// One chat/activity message shown in the chat hub.
|
||||
class ChatEntry {
|
||||
@@ -616,12 +615,22 @@ class _ChatPageState extends State<ChatPage> {
|
||||
final messages = _messages;
|
||||
final currentChannelId = _currentChannelId;
|
||||
final channelName = snapshotChannelName(snapshot, currentChannelId);
|
||||
final l10n = AppL10n.of(context);
|
||||
final detail = _ChatDetailView(
|
||||
target: _selectedTarget,
|
||||
clientName: _selectedClientName,
|
||||
snapshot: snapshot,
|
||||
messages: messages,
|
||||
currentChannelId: currentChannelId,
|
||||
channelName: channelName,
|
||||
onTs3ServerLink: widget.onTs3ServerLink,
|
||||
);
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text('Chat — ${snapshot.serverName}'),
|
||||
title: Text('${l10n.chatAction} - ${snapshot.serverName}'),
|
||||
actions: [
|
||||
IconButton(
|
||||
tooltip: 'Close chat',
|
||||
tooltip: l10n.chatCloseAction,
|
||||
icon: const Icon(Icons.close),
|
||||
onPressed: _selectedPrivateClientId == null
|
||||
? null
|
||||
@@ -629,9 +638,10 @@ class _ChatPageState extends State<ChatPage> {
|
||||
),
|
||||
],
|
||||
),
|
||||
body: Row(
|
||||
children: [
|
||||
_ChatSidebar(
|
||||
body: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final sidebar = _ChatSidebar(
|
||||
compact: constraints.maxWidth < _chatMobileBreakpoint,
|
||||
selectedTarget: _selectedTarget,
|
||||
privateChats: _privateChats,
|
||||
onSelect: _selectTarget,
|
||||
@@ -639,20 +649,24 @@ class _ChatPageState extends State<ChatPage> {
|
||||
_closedPrivateChats.remove(id);
|
||||
_selectTarget(rust.BridgeMessageTarget.client(id), name: name);
|
||||
}),
|
||||
),
|
||||
const VerticalDivider(width: 1),
|
||||
Expanded(
|
||||
child: _ChatDetailView(
|
||||
target: _selectedTarget,
|
||||
clientName: _selectedClientName,
|
||||
snapshot: snapshot,
|
||||
messages: messages,
|
||||
currentChannelId: currentChannelId,
|
||||
channelName: channelName,
|
||||
onTs3ServerLink: widget.onTs3ServerLink,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
if (constraints.maxWidth < _chatMobileBreakpoint) {
|
||||
return Column(
|
||||
children: [
|
||||
sidebar,
|
||||
const Divider(height: 1),
|
||||
Expanded(child: detail),
|
||||
],
|
||||
);
|
||||
}
|
||||
return Row(
|
||||
children: [
|
||||
sidebar,
|
||||
const VerticalDivider(width: 1),
|
||||
Expanded(child: detail),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -740,12 +754,14 @@ class _PrivateChatItem {
|
||||
|
||||
class _ChatSidebar extends StatelessWidget {
|
||||
const _ChatSidebar({
|
||||
required this.compact,
|
||||
required this.selectedTarget,
|
||||
required this.privateChats,
|
||||
required this.onSelect,
|
||||
required this.onNewPrivateChat,
|
||||
});
|
||||
|
||||
final bool compact;
|
||||
final rust.BridgeMessageTarget selectedTarget;
|
||||
final List<_PrivateChatItem> privateChats;
|
||||
final void Function(rust.BridgeMessageTarget target, {String name}) onSelect;
|
||||
@@ -753,64 +769,85 @@ class _ChatSidebar extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final dividerColor = theme.colorScheme.outlineVariant.withValues(
|
||||
alpha: 0.45,
|
||||
final l10n = AppL10n.of(context);
|
||||
final fixedItems = [
|
||||
_ChatSidebarItem(
|
||||
compact: compact,
|
||||
icon: Icons.dns_outlined,
|
||||
label: 'Server',
|
||||
selected: selectedTarget is rust.BridgeMessageTarget_Server,
|
||||
onTap: () => onSelect(const rust.BridgeMessageTarget.server()),
|
||||
),
|
||||
_ChatSidebarItem(
|
||||
compact: compact,
|
||||
icon: Icons.tag,
|
||||
label: 'Channel',
|
||||
selected: selectedTarget is rust.BridgeMessageTarget_Channel,
|
||||
onTap: () => onSelect(const rust.BridgeMessageTarget.channel()),
|
||||
),
|
||||
];
|
||||
final privateItems = [
|
||||
for (final chat in privateChats)
|
||||
_ChatSidebarItem(
|
||||
compact: compact,
|
||||
icon: Icons.person_outline,
|
||||
label: chat.name.isNotEmpty ? chat.name : 'Direct',
|
||||
selected: switch (selectedTarget) {
|
||||
rust.BridgeMessageTarget_Client(:final field0) => field0 == chat.id,
|
||||
_ => false,
|
||||
},
|
||||
onTap: () => onSelect(
|
||||
rust.BridgeMessageTarget.client(chat.id),
|
||||
name: chat.name,
|
||||
),
|
||||
),
|
||||
];
|
||||
final addButton = Padding(
|
||||
padding: EdgeInsets.all(compact ? 4 : 8),
|
||||
child: IconButton.filledTonal(
|
||||
tooltip: l10n.chatNewPrivateAction,
|
||||
icon: const Icon(Icons.add),
|
||||
onPressed: onNewPrivateChat,
|
||||
),
|
||||
);
|
||||
return SizedBox(
|
||||
width: _chatSidebarTileExtent,
|
||||
child: Material(
|
||||
color: theme.colorScheme.surfaceContainerLow,
|
||||
child: Column(
|
||||
final compactDivider = ColoredBox(
|
||||
color: Theme.of(context).dividerColor,
|
||||
child: const SizedBox(width: 1, height: double.infinity),
|
||||
);
|
||||
|
||||
if (compact) {
|
||||
return SizedBox(
|
||||
height: _chatSidebarCompactHeight,
|
||||
child: Row(
|
||||
children: [
|
||||
const SizedBox(height: 8),
|
||||
_ChatSidebarItem(
|
||||
icon: Icons.dns_outlined,
|
||||
label: 'Server',
|
||||
selected: selectedTarget is rust.BridgeMessageTarget_Server,
|
||||
onTap: () => onSelect(const rust.BridgeMessageTarget.server()),
|
||||
),
|
||||
_ChatSidebarItem(
|
||||
icon: Icons.tag,
|
||||
label: 'Channel',
|
||||
selected: selectedTarget is rust.BridgeMessageTarget_Channel,
|
||||
onTap: () => onSelect(const rust.BridgeMessageTarget.channel()),
|
||||
),
|
||||
Divider(height: 1, indent: 12, endIndent: 12, color: dividerColor),
|
||||
...fixedItems,
|
||||
compactDivider,
|
||||
Expanded(
|
||||
child: ListView.builder(
|
||||
padding: const EdgeInsets.symmetric(vertical: 6),
|
||||
itemCount: privateChats.length,
|
||||
itemBuilder: (context, index) {
|
||||
final chat = privateChats[index];
|
||||
final selected = switch (selectedTarget) {
|
||||
rust.BridgeMessageTarget_Client(:final field0) =>
|
||||
field0 == chat.id,
|
||||
_ => false,
|
||||
};
|
||||
return _ChatSidebarItem(
|
||||
icon: Icons.person_outline,
|
||||
label: chat.name.isNotEmpty ? chat.name : 'Direct',
|
||||
selected: selected,
|
||||
onTap: () => onSelect(
|
||||
rust.BridgeMessageTarget.client(chat.id),
|
||||
name: chat.name,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
Divider(height: 1, indent: 12, endIndent: 12, color: dividerColor),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(8, 10, 8, 12),
|
||||
child: IconButton.filledTonal(
|
||||
tooltip: 'New private chat',
|
||||
icon: const Icon(Icons.add),
|
||||
onPressed: onNewPrivateChat,
|
||||
child: ListView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
padding: EdgeInsets.zero,
|
||||
children: privateItems,
|
||||
),
|
||||
),
|
||||
compactDivider,
|
||||
addButton,
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return SizedBox(
|
||||
width: _chatSidebarTileExtent,
|
||||
child: Column(
|
||||
children: [
|
||||
...fixedItems,
|
||||
const Divider(height: 1),
|
||||
Expanded(
|
||||
child: ListView(padding: EdgeInsets.zero, children: privateItems),
|
||||
),
|
||||
const Divider(height: 1),
|
||||
addButton,
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -818,12 +855,14 @@ class _ChatSidebar extends StatelessWidget {
|
||||
|
||||
class _ChatSidebarItem extends StatelessWidget {
|
||||
const _ChatSidebarItem({
|
||||
required this.compact,
|
||||
required this.icon,
|
||||
required this.label,
|
||||
required this.selected,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
final bool compact;
|
||||
final IconData icon;
|
||||
final String label;
|
||||
final bool selected;
|
||||
@@ -832,63 +871,57 @@ class _ChatSidebarItem extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final scheme = theme.colorScheme;
|
||||
final indicatorColor = selected
|
||||
? scheme.primaryContainer
|
||||
: Colors.transparent;
|
||||
final contentColor = selected
|
||||
? scheme.onPrimaryContainer
|
||||
: scheme.onSurfaceVariant;
|
||||
final labelStyle = theme.textTheme.labelSmall?.copyWith(
|
||||
color: contentColor,
|
||||
height: 1.15,
|
||||
fontWeight: selected ? FontWeight.w600 : FontWeight.w500,
|
||||
);
|
||||
|
||||
return SizedBox(
|
||||
width: _chatSidebarTileExtent,
|
||||
height: _chatSidebarTileExtent,
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: _chatSidebarIndicatorRadius,
|
||||
child: Center(
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 180),
|
||||
curve: Curves.easeOutCubic,
|
||||
width: _chatSidebarIndicatorExtent,
|
||||
height: _chatSidebarIndicatorExtent,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: indicatorColor,
|
||||
borderRadius: _chatSidebarIndicatorRadius,
|
||||
),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: _chatSidebarIconExtent,
|
||||
height: _chatSidebarIconExtent,
|
||||
child: Icon(
|
||||
icon,
|
||||
size: _chatSidebarIconSize,
|
||||
color: contentColor,
|
||||
),
|
||||
final colorScheme = theme.colorScheme;
|
||||
final fg = selected
|
||||
? colorScheme.onSecondaryContainer
|
||||
: colorScheme.onSurface;
|
||||
final tileExtent = compact
|
||||
? _chatSidebarCompactTileExtent
|
||||
: _chatSidebarTileExtent;
|
||||
return Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: Ink(
|
||||
width: tileExtent,
|
||||
height: compact ? 72 : tileExtent,
|
||||
decoration: BoxDecoration(
|
||||
color: selected
|
||||
? colorScheme.secondaryContainer
|
||||
: Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Padding(
|
||||
padding: EdgeInsets.symmetric(
|
||||
horizontal: compact ? 6 : 8,
|
||||
vertical: compact ? 8 : 10,
|
||||
),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 24,
|
||||
height: 24,
|
||||
child: Stack(
|
||||
clipBehavior: Clip.none,
|
||||
alignment: Alignment.center,
|
||||
children: [Icon(icon, size: 18, color: fg)],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
SizedBox(
|
||||
width: _chatSidebarIndicatorExtent - 16,
|
||||
child: Text(
|
||||
label,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
textAlign: TextAlign.center,
|
||||
style: labelStyle,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
label,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
textAlign: TextAlign.center,
|
||||
style: theme.textTheme.labelSmall?.copyWith(
|
||||
color: fg,
|
||||
height: 1.1,
|
||||
fontWeight: selected ? FontWeight.w700 : FontWeight.w500,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -926,6 +959,7 @@ class _ClientPickerDialogState extends State<_ClientPickerDialog> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppL10n.of(context);
|
||||
final groups = filterChatClientPickerGroups(
|
||||
channels: widget.channels,
|
||||
clientsByChannel: widget.byChannel,
|
||||
@@ -943,10 +977,10 @@ class _ClientPickerDialogState extends State<_ClientPickerDialog> {
|
||||
padding: const EdgeInsets.fromLTRB(20, 20, 20, 8),
|
||||
child: TextField(
|
||||
controller: _searchCtl,
|
||||
decoration: const InputDecoration(
|
||||
hintText: 'Search clients...',
|
||||
prefixIcon: Icon(Icons.search),
|
||||
border: OutlineInputBorder(),
|
||||
decoration: InputDecoration(
|
||||
hintText: l10n.chatSearchClientsHint,
|
||||
prefixIcon: const Icon(Icons.search),
|
||||
border: const OutlineInputBorder(),
|
||||
isDense: true,
|
||||
),
|
||||
onChanged: (v) => setState(() => _query = v),
|
||||
@@ -1044,7 +1078,6 @@ class _ChatDetailViewState extends State<_ChatDetailView> {
|
||||
final _scrollCtl = ScrollController();
|
||||
int _lastRenderedMessageCount = -1;
|
||||
rust.BridgeMessageTarget? _lastRenderedTarget;
|
||||
bool _sending = false;
|
||||
|
||||
Iterable<ChatEntry> get _filtered {
|
||||
if (widget.target is rust.BridgeMessageTarget_Channel) {
|
||||
@@ -1074,51 +1107,30 @@ class _ChatDetailViewState extends State<_ChatDetailView> {
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _send() async {
|
||||
void _send() {
|
||||
final text = _textCtl.text.trim();
|
||||
if (text.isEmpty || !_canSend || _sending) return;
|
||||
if (text.isEmpty || !_canSend) return;
|
||||
_textCtl.clear();
|
||||
setState(() => _sending = true);
|
||||
try {
|
||||
await rust.sendChatMessage(message: text, target: widget.target);
|
||||
if (!mounted) return;
|
||||
final ownId = widget.snapshot.ownClientId;
|
||||
setState(() {
|
||||
widget.messages.add(
|
||||
ChatEntry(
|
||||
senderId: ownId,
|
||||
senderName: widget.target is rust.BridgeMessageTarget_Poke
|
||||
? widget.clientName
|
||||
: 'You',
|
||||
message: text,
|
||||
target: widget.target,
|
||||
isSelf: true,
|
||||
timestamp: DateTime.now(),
|
||||
),
|
||||
);
|
||||
if (widget.messages.length > 200) {
|
||||
widget.messages.removeRange(0, widget.messages.length - 200);
|
||||
}
|
||||
});
|
||||
_scrollToBottom();
|
||||
} catch (error) {
|
||||
if (!mounted) return;
|
||||
_textCtl.text = text;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
behavior: SnackBarBehavior.floating,
|
||||
content: Text(
|
||||
'Could not send message: $error',
|
||||
maxLines: 3,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
unawaited(rust.sendChatMessage(message: text, target: widget.target));
|
||||
final ownId = widget.snapshot.ownClientId;
|
||||
setState(() {
|
||||
widget.messages.add(
|
||||
ChatEntry(
|
||||
senderId: ownId,
|
||||
senderName: widget.target is rust.BridgeMessageTarget_Poke
|
||||
? widget.clientName
|
||||
: 'You',
|
||||
message: text,
|
||||
target: widget.target,
|
||||
isSelf: true,
|
||||
timestamp: DateTime.now(),
|
||||
),
|
||||
);
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setState(() => _sending = false);
|
||||
if (widget.messages.length > 200) {
|
||||
widget.messages.removeRange(0, widget.messages.length - 200);
|
||||
}
|
||||
}
|
||||
});
|
||||
_scrollToBottom();
|
||||
}
|
||||
|
||||
void _scrollToBottom() {
|
||||
|
||||
@@ -0,0 +1,491 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../l10n/generated/app_localizations.dart';
|
||||
import '../src/rust/api.dart' as rust;
|
||||
|
||||
/// Bottom sheet that presents richer TeamSpeak client profile data.
|
||||
class ClientInfoSheet extends StatefulWidget {
|
||||
/// Construct a client info sheet.
|
||||
const ClientInfoSheet({
|
||||
super.key,
|
||||
required this.clientName,
|
||||
required this.loadProfile,
|
||||
});
|
||||
|
||||
/// Display name used while the profile request is loading.
|
||||
final String clientName;
|
||||
|
||||
/// Fetch richer profile data from the active protocol connection.
|
||||
final Future<rust.BridgeClientProfile> Function() loadProfile;
|
||||
|
||||
@override
|
||||
State<ClientInfoSheet> createState() => _ClientInfoSheetState();
|
||||
}
|
||||
|
||||
class _ClientInfoSheetState extends State<ClientInfoSheet> {
|
||||
late Future<rust.BridgeClientProfile> _profileFuture;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_profileFuture = widget.loadProfile();
|
||||
}
|
||||
|
||||
void _retry() {
|
||||
setState(() {
|
||||
_profileFuture = widget.loadProfile();
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return SafeArea(
|
||||
top: false,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 10, 20, 20),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(
|
||||
width: 40,
|
||||
height: 4,
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.onSurfaceVariant.withValues(
|
||||
alpha: 0.34,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Expanded(
|
||||
child: FutureBuilder<rust.BridgeClientProfile>(
|
||||
future: _profileFuture,
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.connectionState != ConnectionState.done) {
|
||||
return _ClientInfoLoading(name: widget.clientName);
|
||||
}
|
||||
if (snapshot.hasError || !snapshot.hasData) {
|
||||
return _ClientInfoError(
|
||||
name: widget.clientName,
|
||||
onRetry: _retry,
|
||||
);
|
||||
}
|
||||
return _ClientInfoContent(profile: snapshot.data!);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ClientInfoLoading extends StatelessWidget {
|
||||
const _ClientInfoLoading({required this.name});
|
||||
|
||||
final String name;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final l10n = AppL10n.of(context);
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_ClientInfoHeader(name: name, subtitle: l10n.clientInfoFetchingProfile),
|
||||
const SizedBox(height: 24),
|
||||
LinearProgressIndicator(
|
||||
minHeight: 3,
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(l10n.clientInfoLoadingProfile, style: theme.textTheme.bodyMedium),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ClientInfoError extends StatelessWidget {
|
||||
const _ClientInfoError({required this.name, required this.onRetry});
|
||||
|
||||
final String name;
|
||||
final VoidCallback onRetry;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final l10n = AppL10n.of(context);
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_ClientInfoHeader(
|
||||
name: name,
|
||||
subtitle: l10n.clientInfoProfileUnavailable,
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Icon(Icons.info_outline, color: theme.colorScheme.error, size: 32),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
l10n.clientInfoProfileUnavailableBody,
|
||||
style: theme.textTheme.bodyMedium,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
FilledButton.icon(
|
||||
onPressed: onRetry,
|
||||
icon: const Icon(Icons.refresh),
|
||||
label: Text(l10n.retryAction),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ClientInfoContent extends StatelessWidget {
|
||||
const _ClientInfoContent({required this.profile});
|
||||
|
||||
final rust.BridgeClientProfile profile;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppL10n.of(context);
|
||||
return ListView(
|
||||
children: [
|
||||
_ClientInfoHeader(
|
||||
name: profile.name,
|
||||
subtitle: _joinNonEmpty([
|
||||
profile.platform,
|
||||
profile.version,
|
||||
profile.countryCode,
|
||||
]),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
_InfoSection(
|
||||
title: l10n.clientInfoIdentitySection,
|
||||
rows: [
|
||||
_InfoRowData(l10n.clientInfoClientId, profile.id.toString()),
|
||||
_InfoRowData(
|
||||
l10n.clientInfoDatabaseId,
|
||||
_formatBigInt(profile.databaseId, l10n),
|
||||
),
|
||||
_InfoRowData(
|
||||
l10n.clientInfoUniqueId,
|
||||
_emptyAsHidden(profile.uniqueId, l10n),
|
||||
),
|
||||
_InfoRowData(
|
||||
l10n.clientInfoDescription,
|
||||
_emptyAsHidden(profile.description, l10n),
|
||||
),
|
||||
_InfoRowData(
|
||||
l10n.clientInfoAvatar,
|
||||
_emptyAsNone(profile.avatarPath, l10n),
|
||||
),
|
||||
],
|
||||
),
|
||||
_InfoSection(
|
||||
title: l10n.clientInfoMembershipSection,
|
||||
rows: [
|
||||
_InfoRowData(
|
||||
l10n.clientInfoServerGroups,
|
||||
profile.serverGroups.isEmpty
|
||||
? l10n.clientInfoUnknown
|
||||
: profile.serverGroups.join(', '),
|
||||
),
|
||||
_InfoRowData(
|
||||
l10n.clientInfoChannelGroup,
|
||||
_emptyAsHidden(profile.channelGroup, l10n),
|
||||
),
|
||||
_InfoRowData(l10n.clientInfoChannelId, profile.channel.toString()),
|
||||
],
|
||||
),
|
||||
_InfoSection(
|
||||
title: l10n.clientInfoConnectionSection,
|
||||
rows: [
|
||||
_InfoRowData(
|
||||
l10n.clientInfoOnline,
|
||||
_formatSeconds(profile.onlineSeconds, l10n),
|
||||
),
|
||||
_InfoRowData(
|
||||
l10n.clientInfoIdle,
|
||||
_formatMilliseconds(profile.idleMilliseconds, l10n),
|
||||
),
|
||||
_InfoRowData(
|
||||
l10n.clientInfoPing,
|
||||
_formatMilliseconds(profile.pingMilliseconds, l10n),
|
||||
),
|
||||
_InfoRowData(
|
||||
l10n.clientInfoAddress,
|
||||
_emptyAsHidden(profile.clientAddress, l10n),
|
||||
),
|
||||
_InfoRowData(
|
||||
l10n.clientInfoPacketLossClientToServer,
|
||||
_formatLoss(profile.packetLossClientToServerTotal),
|
||||
),
|
||||
_InfoRowData(
|
||||
l10n.clientInfoPacketLossServerToClient,
|
||||
_formatLoss(profile.packetLossServerToClientTotal),
|
||||
),
|
||||
],
|
||||
),
|
||||
_InfoSection(
|
||||
title: l10n.clientInfoHistorySection,
|
||||
rows: [
|
||||
_InfoRowData(
|
||||
l10n.clientInfoFirstConnected,
|
||||
_formatUnix(profile.createdUnixSeconds, l10n),
|
||||
),
|
||||
_InfoRowData(
|
||||
l10n.clientInfoLastConnected,
|
||||
_formatUnix(profile.lastConnectedUnixSeconds, l10n),
|
||||
),
|
||||
_InfoRowData(
|
||||
l10n.clientInfoConnections,
|
||||
_formatBigInt(profile.connectionsTotal, l10n),
|
||||
),
|
||||
],
|
||||
),
|
||||
_InfoSection(
|
||||
title: l10n.clientInfoTransferSection,
|
||||
rows: [
|
||||
_InfoRowData(
|
||||
l10n.clientInfoDownloadedMonth,
|
||||
_formatBytes(profile.bytesDownloadedMonth),
|
||||
),
|
||||
_InfoRowData(
|
||||
l10n.clientInfoUploadedMonth,
|
||||
_formatBytes(profile.bytesUploadedMonth),
|
||||
),
|
||||
_InfoRowData(
|
||||
l10n.clientInfoDownloadedTotal,
|
||||
_formatBytes(profile.bytesDownloadedTotal),
|
||||
),
|
||||
_InfoRowData(
|
||||
l10n.clientInfoUploadedTotal,
|
||||
_formatBytes(profile.bytesUploadedTotal),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ClientInfoHeader extends StatelessWidget {
|
||||
const _ClientInfoHeader({required this.name, required this.subtitle});
|
||||
|
||||
final String name;
|
||||
final String subtitle;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
CircleAvatar(
|
||||
radius: 28,
|
||||
backgroundColor: theme.colorScheme.primaryContainer,
|
||||
foregroundColor: theme.colorScheme.onPrimaryContainer,
|
||||
child: Text(
|
||||
_initials(name),
|
||||
style: theme.textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 14),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
name,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: theme.textTheme.titleLarge?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
if (subtitle.isNotEmpty) ...[
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
subtitle,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _InfoSection extends StatelessWidget {
|
||||
const _InfoSection({required this.title, required this.rows});
|
||||
|
||||
final String title;
|
||||
final List<_InfoRowData> rows;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 20),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
title,
|
||||
style: theme.textTheme.labelLarge?.copyWith(
|
||||
color: theme.colorScheme.primary,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
border: Border(
|
||||
top: BorderSide(color: theme.dividerColor),
|
||||
bottom: BorderSide(color: theme.dividerColor),
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
for (var i = 0; i < rows.length; i++) ...[
|
||||
_InfoRow(row: rows[i]),
|
||||
if (i != rows.length - 1) const Divider(height: 1),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _InfoRow extends StatelessWidget {
|
||||
const _InfoRow({required this.row});
|
||||
|
||||
final _InfoRowData row;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 11),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 132,
|
||||
child: Text(
|
||||
row.label,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: SelectableText(
|
||||
row.value,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
fontFeatures: const [FontFeature.tabularFigures()],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _InfoRowData {
|
||||
const _InfoRowData(this.label, this.value);
|
||||
|
||||
final String label;
|
||||
final String value;
|
||||
}
|
||||
|
||||
String _initials(String value) {
|
||||
final trimmed = value.trim();
|
||||
if (trimmed.isEmpty) return '?';
|
||||
final parts = trimmed.split(RegExp(r'\s+')).where((p) => p.isNotEmpty);
|
||||
final chars = parts.take(2).map((p) => p.characters.first.toUpperCase());
|
||||
return chars.join();
|
||||
}
|
||||
|
||||
String _joinNonEmpty(Iterable<String> values) {
|
||||
return values.where((value) => value.trim().isNotEmpty).join(' / ');
|
||||
}
|
||||
|
||||
String _emptyAsHidden(String value, AppL10n l10n) =>
|
||||
value.trim().isEmpty ? l10n.clientInfoHidden : value;
|
||||
|
||||
String _emptyAsNone(String value, AppL10n l10n) =>
|
||||
value.trim().isEmpty ? l10n.clientInfoNone : value;
|
||||
|
||||
String _formatBigInt(BigInt? value, AppL10n l10n) =>
|
||||
value?.toString() ?? l10n.clientInfoUnknown;
|
||||
|
||||
String _formatUnix(Object? seconds, AppL10n l10n) {
|
||||
final raw = _intFromPlatform(seconds);
|
||||
if (raw == null || raw <= 0) return l10n.clientInfoUnknown;
|
||||
final date = DateTime.fromMillisecondsSinceEpoch(
|
||||
raw * 1000,
|
||||
isUtc: true,
|
||||
).toLocal();
|
||||
return date.toString().split('.').first;
|
||||
}
|
||||
|
||||
String _formatSeconds(Object? seconds, AppL10n l10n) {
|
||||
final raw = _intFromPlatform(seconds);
|
||||
if (raw == null) return l10n.clientInfoUnknown;
|
||||
if (raw < 60) return '$raw s';
|
||||
final minutes = raw ~/ 60;
|
||||
final hours = minutes ~/ 60;
|
||||
if (hours > 0) {
|
||||
return '${hours}h ${minutes % 60}m ${raw % 60}s';
|
||||
}
|
||||
return '${minutes}m ${raw % 60}s';
|
||||
}
|
||||
|
||||
String _formatMilliseconds(Object? milliseconds, AppL10n l10n) {
|
||||
final raw = _intFromPlatform(milliseconds);
|
||||
if (raw == null) return l10n.clientInfoUnknown;
|
||||
if (raw < 1000) return '$raw ms';
|
||||
return '${(raw / 1000).toStringAsFixed(2)} s';
|
||||
}
|
||||
|
||||
String _formatBytes(BigInt? value) {
|
||||
if (value == null) return 'Unknown';
|
||||
final bytes = value.toDouble();
|
||||
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||
var amount = bytes;
|
||||
var unitIndex = 0;
|
||||
while (amount >= 1024 && unitIndex < units.length - 1) {
|
||||
amount /= 1024;
|
||||
unitIndex++;
|
||||
}
|
||||
final digits = unitIndex == 0 ? 0 : 1;
|
||||
return '${amount.toStringAsFixed(digits)} ${units[unitIndex]}';
|
||||
}
|
||||
|
||||
String _formatLoss(double? value) {
|
||||
if (value == null) return 'Unknown';
|
||||
return '${(value * 100).toStringAsFixed(2)}%';
|
||||
}
|
||||
|
||||
int? _intFromPlatform(Object? value) {
|
||||
if (value == null) return null;
|
||||
if (value is int) return value;
|
||||
if (value is BigInt) return value.toInt();
|
||||
return int.tryParse(value.toString());
|
||||
}
|
||||
@@ -65,7 +65,6 @@ class _ConnectFormState extends State<ConnectForm> {
|
||||
keyboardType: TextInputType.url,
|
||||
textCapitalization: TextCapitalization.none,
|
||||
textInputAction: TextInputAction.next,
|
||||
onSubmitted: (_) => _nickFocus.requestFocus(),
|
||||
autocorrect: false,
|
||||
enableSuggestions: false,
|
||||
inputFormatters: [
|
||||
@@ -90,7 +89,6 @@ class _ConnectFormState extends State<ConnectForm> {
|
||||
focusNode: _nickFocus,
|
||||
onTapOutside: _onTapOutside,
|
||||
textInputAction: TextInputAction.next,
|
||||
onSubmitted: (_) => _passwordFocus.requestFocus(),
|
||||
autocorrect: false,
|
||||
enableSuggestions: false,
|
||||
decoration: InputDecoration(
|
||||
@@ -105,7 +103,6 @@ class _ConnectFormState extends State<ConnectForm> {
|
||||
onTapOutside: _onTapOutside,
|
||||
obscureText: true,
|
||||
textInputAction: TextInputAction.done,
|
||||
onSubmitted: (_) => widget.onConnect(),
|
||||
autocorrect: false,
|
||||
enableSuggestions: false,
|
||||
decoration: InputDecoration(
|
||||
@@ -115,22 +112,37 @@ class _ConnectFormState extends State<ConnectForm> {
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: FilledButton.icon(
|
||||
icon: const Icon(Icons.login),
|
||||
label: Text(l10n.connectAction),
|
||||
onPressed: widget.onConnect,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
OutlinedButton.icon(
|
||||
LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
const stackedActionsMaxWidth = 400.0;
|
||||
final connectButton = FilledButton.icon(
|
||||
icon: const Icon(Icons.login),
|
||||
label: Text(l10n.connectAction),
|
||||
onPressed: widget.onConnect,
|
||||
);
|
||||
final bookmarkButton = OutlinedButton.icon(
|
||||
icon: const Icon(Icons.bookmark_add_outlined),
|
||||
label: Text(l10n.bookmarkAddAction),
|
||||
onPressed: widget.onAddBookmark,
|
||||
),
|
||||
],
|
||||
);
|
||||
if (constraints.maxWidth <= stackedActionsMaxWidth) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
connectButton,
|
||||
const SizedBox(height: 8),
|
||||
bookmarkButton,
|
||||
],
|
||||
);
|
||||
}
|
||||
return Row(
|
||||
children: [
|
||||
Expanded(child: connectButton),
|
||||
const SizedBox(width: 8),
|
||||
Flexible(child: bookmarkButton),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
@@ -4,9 +4,6 @@ import 'package:flutter/services.dart';
|
||||
import '../l10n/generated/app_localizations.dart';
|
||||
import '../src/rust/api.dart' as rust;
|
||||
|
||||
const int pttMouseBackButtonBitmask = 0x08;
|
||||
const int pttMouseForwardButtonBitmask = 0x10;
|
||||
|
||||
/// Translate a [LogicalKeyboardKey] into the platform-neutral label
|
||||
/// stored by the PTT binding flow.
|
||||
String? pttDisplayLabelForKey(LogicalKeyboardKey k) {
|
||||
@@ -49,27 +46,6 @@ String? pttDisplayLabelForKey(LogicalKeyboardKey k) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
String? pttMouseSideButtonPlatformKeyForLogicalKey(LogicalKeyboardKey key) {
|
||||
return switch (key) {
|
||||
LogicalKeyboardKey.browserBack ||
|
||||
LogicalKeyboardKey.goBack => 'mouse-side-button:$pttMouseBackButtonBitmask',
|
||||
LogicalKeyboardKey.browserForward =>
|
||||
'mouse-side-button:$pttMouseForwardButtonBitmask',
|
||||
_ => null,
|
||||
};
|
||||
}
|
||||
|
||||
String? pttMouseSideButtonPlatformKeyForButtons(int buttons) {
|
||||
if ((buttons & pttMouseBackButtonBitmask) == pttMouseBackButtonBitmask) {
|
||||
return 'mouse-side-button:$pttMouseBackButtonBitmask';
|
||||
}
|
||||
if ((buttons & pttMouseForwardButtonBitmask) ==
|
||||
pttMouseForwardButtonBitmask) {
|
||||
return 'mouse-side-button:$pttMouseForwardButtonBitmask';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Result of a successful PTT binding capture.
|
||||
class CapturedBinding {
|
||||
const CapturedBinding({required this.inputClass, required this.platformKey});
|
||||
@@ -205,16 +181,6 @@ class _PttBindingCaptureDialogState extends State<PttBindingCaptureDialog> {
|
||||
|
||||
KeyEventResult _onKeyEvent(FocusNode node, KeyEvent event) {
|
||||
if (event is! KeyDownEvent) return KeyEventResult.ignored;
|
||||
final mouseSideButton = pttMouseSideButtonPlatformKeyForLogicalKey(
|
||||
event.logicalKey,
|
||||
);
|
||||
if (mouseSideButton != null) {
|
||||
setState(() {
|
||||
_captured = mouseSideButton;
|
||||
_capturedClass = rust.BridgePttInputClass.mouseSideButton;
|
||||
});
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
final label = pttDisplayLabelForKey(event.logicalKey);
|
||||
if (label == null) return KeyEventResult.ignored;
|
||||
setState(() {
|
||||
@@ -224,9 +190,9 @@ class _PttBindingCaptureDialogState extends State<PttBindingCaptureDialog> {
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
|
||||
void _captureMouseSideButton(String platformKey) {
|
||||
void _captureMouseSideButton(int button) {
|
||||
setState(() {
|
||||
_captured = platformKey;
|
||||
_captured = 'mouse-side-button:$button';
|
||||
_capturedClass = rust.BridgePttInputClass.mouseSideButton;
|
||||
});
|
||||
}
|
||||
@@ -237,57 +203,59 @@ class _PttBindingCaptureDialogState extends State<PttBindingCaptureDialog> {
|
||||
final theme = Theme.of(context);
|
||||
return AlertDialog(
|
||||
title: Text(l10n.pttConfigureTitle),
|
||||
content: SizedBox(
|
||||
width: 360,
|
||||
child: Focus(
|
||||
focusNode: _focusNode,
|
||||
onKeyEvent: _onKeyEvent,
|
||||
autofocus: true,
|
||||
child: Listener(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onPointerDown: (e) {
|
||||
final platformKey = pttMouseSideButtonPlatformKeyForButtons(
|
||||
e.buttons,
|
||||
);
|
||||
if (platformKey != null) {
|
||||
_captureMouseSideButton(platformKey);
|
||||
}
|
||||
},
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
l10n.pttConfigurePrompt,
|
||||
style: theme.textTheme.bodyMedium,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
vertical: 12,
|
||||
horizontal: 16,
|
||||
content: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 360),
|
||||
child: SingleChildScrollView(
|
||||
child: Focus(
|
||||
focusNode: _focusNode,
|
||||
onKeyEvent: _onKeyEvent,
|
||||
autofocus: true,
|
||||
child: Listener(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onPointerDown: (e) {
|
||||
const int back = 0x08;
|
||||
const int forward = 0x10;
|
||||
if (e.buttons == back || e.buttons == forward) {
|
||||
_captureMouseSideButton(e.buttons);
|
||||
}
|
||||
},
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
l10n.pttConfigurePrompt,
|
||||
style: theme.textTheme.bodyMedium,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
child: Text(
|
||||
_captured == null
|
||||
? l10n.pttConfigureWaiting
|
||||
: '${l10n.pttConfigureCaptured}: $_captured',
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
fontFamily: 'monospace',
|
||||
const SizedBox(height: 12),
|
||||
Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.symmetric(
|
||||
vertical: 12,
|
||||
horizontal: 16,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
child: Text(
|
||||
_captured == null
|
||||
? l10n.pttConfigureWaiting
|
||||
: '${l10n.pttConfigureCaptured}: $_captured',
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
fontFamily: 'monospace',
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
l10n.pttConfigurePrivacyNote,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
l10n.pttConfigurePrivacyNote,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -54,43 +54,48 @@ class PttCapabilityBadge extends StatelessWidget {
|
||||
showModalBottomSheet<void>(
|
||||
context: context,
|
||||
showDragHandle: true,
|
||||
isScrollControlled: true,
|
||||
builder: (sheetContext) {
|
||||
final theme = Theme.of(sheetContext);
|
||||
final maxHeight = MediaQuery.sizeOf(sheetContext).height * 0.72;
|
||||
return SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 4, 20, 24),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
l10n.pttCapabilityExplainTitle,
|
||||
style: theme.textTheme.titleMedium,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
l10n.pttCapabilityExplainFocusedHeading,
|
||||
style: theme.textTheme.titleSmall,
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
l10n.pttCapabilityExplainFocusedBody,
|
||||
style: theme.textTheme.bodyMedium,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
_explainBodyForPlatform(l10n),
|
||||
style: theme.textTheme.bodyMedium,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Align(
|
||||
alignment: AlignmentDirectional.centerEnd,
|
||||
child: TextButton(
|
||||
onPressed: () => Navigator.of(sheetContext).pop(),
|
||||
child: Text(l10n.closeAction),
|
||||
child: ConstrainedBox(
|
||||
constraints: BoxConstraints(maxHeight: maxHeight),
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.fromLTRB(20, 4, 20, 24),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
l10n.pttCapabilityExplainTitle,
|
||||
style: theme.textTheme.titleMedium,
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
l10n.pttCapabilityExplainFocusedHeading,
|
||||
style: theme.textTheme.titleSmall,
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
l10n.pttCapabilityExplainFocusedBody,
|
||||
style: theme.textTheme.bodyMedium,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
_explainBodyForPlatform(l10n),
|
||||
style: theme.textTheme.bodyMedium,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Align(
|
||||
alignment: AlignmentDirectional.centerEnd,
|
||||
child: TextButton(
|
||||
onPressed: () => Navigator.of(sheetContext).pop(),
|
||||
child: Text(l10n.closeAction),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
@@ -8,17 +6,10 @@ import '../services/channel_spacer.dart';
|
||||
import '../services/link_trust_service.dart';
|
||||
import '../services/snapshot_state_mapper.dart';
|
||||
import '../services/ts3_server_link.dart';
|
||||
import '../services/ui_preferences_service.dart';
|
||||
import '../src/rust/api.dart' as rust;
|
||||
import 'bbcode_text.dart';
|
||||
import 'talk_power_warning.dart';
|
||||
|
||||
typedef ClientPlaybackPreferenceChanged =
|
||||
Future<void> Function(
|
||||
rust.BridgeClient client,
|
||||
ClientPlaybackPreference preference,
|
||||
);
|
||||
|
||||
/// Connected-server snapshot with welcome text, channels, and clients.
|
||||
class SnapshotView extends StatefulWidget {
|
||||
/// Construct a snapshot view.
|
||||
@@ -34,8 +25,10 @@ class SnapshotView extends StatefulWidget {
|
||||
required this.canJoinVoiceChannel,
|
||||
required this.onJoinChannel,
|
||||
required this.onJoinChannelWithPassword,
|
||||
this.clientPlaybackPreferences = const {},
|
||||
this.onClientPlaybackPreferenceChanged,
|
||||
this.enableClientLongPressMenu = false,
|
||||
this.onOpenClientInfo,
|
||||
this.onOpenClientChat,
|
||||
this.onOpenClientPoke,
|
||||
this.onTs3ServerLink,
|
||||
});
|
||||
|
||||
@@ -69,11 +62,17 @@ class SnapshotView extends StatefulWidget {
|
||||
/// Join a password-protected channel.
|
||||
final ValueChanged<rust.BridgeChannel> onJoinChannelWithPassword;
|
||||
|
||||
/// Persisted per-client playback preferences keyed by TeamSpeak UID.
|
||||
final Map<String, ClientPlaybackPreference> clientPlaybackPreferences;
|
||||
/// True on touch-only mobile hosts where long-press opens client actions.
|
||||
final bool enableClientLongPressMenu;
|
||||
|
||||
/// Apply an updated per-client playback preference.
|
||||
final ClientPlaybackPreferenceChanged? onClientPlaybackPreferenceChanged;
|
||||
/// Open richer profile details for a non-self client.
|
||||
final ValueChanged<rust.BridgeClient>? onOpenClientInfo;
|
||||
|
||||
/// Open a direct chat with a non-self client.
|
||||
final ValueChanged<rust.BridgeClient>? onOpenClientChat;
|
||||
|
||||
/// Open a poke composer for a non-self client.
|
||||
final ValueChanged<rust.BridgeClient>? onOpenClientPoke;
|
||||
|
||||
/// Handle TeamSpeak server links embedded in server-provided text.
|
||||
final Ts3ServerLinkHandler? onTs3ServerLink;
|
||||
@@ -88,11 +87,10 @@ class _SnapshotViewState extends State<SnapshotView> {
|
||||
static const _channelIconColumnWidth = 24.0;
|
||||
static const _channelTextGap = 8.0;
|
||||
static const _userRowStartIndent = 32.0;
|
||||
static const _clientPlaybackVolumePresets = [0.25, 0.5, 1.0, 2.0, 4.0];
|
||||
|
||||
final _scrollController = ScrollController();
|
||||
final Map<BigInt, bool> _channelExpandedById = {};
|
||||
bool _welcomeExpanded = true;
|
||||
bool _welcomeExpanded = false;
|
||||
double _welcomeHeight = 0;
|
||||
final _welcomeKey = GlobalKey();
|
||||
|
||||
@@ -293,6 +291,12 @@ class _SnapshotViewState extends State<SnapshotView> {
|
||||
fontWeight: FontWeight.w600,
|
||||
)
|
||||
: null;
|
||||
final isSelf = client.id == widget.snapshot.ownClientId;
|
||||
final canOpenPeerActions =
|
||||
!isSelf &&
|
||||
(widget.onOpenClientChat != null || widget.onOpenClientPoke != null);
|
||||
final canOpenClientMenu =
|
||||
widget.onOpenClientInfo != null || canOpenPeerActions;
|
||||
|
||||
final decoration = status.isSpeaking
|
||||
? BoxDecoration(
|
||||
@@ -311,40 +315,90 @@ class _SnapshotViewState extends State<SnapshotView> {
|
||||
],
|
||||
)
|
||||
: null;
|
||||
final canAdjustPlayback = _canAdjustClientPlayback(client);
|
||||
Offset? secondaryTapPosition;
|
||||
|
||||
return Padding(
|
||||
final tile = Padding(
|
||||
padding: EdgeInsets.only(
|
||||
left: channelIndent + _userRowStartIndent,
|
||||
right: 8,
|
||||
),
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onLongPress: canAdjustPlayback
|
||||
? () => unawaited(_showClientPlaybackMenu(client, withHaptic: true))
|
||||
: null,
|
||||
onSecondaryTapDown: canAdjustPlayback
|
||||
? (details) => secondaryTapPosition = details.globalPosition
|
||||
: null,
|
||||
onSecondaryTap: canAdjustPlayback
|
||||
? () => unawaited(
|
||||
_showClientPlaybackMenu(client, anchor: secondaryTapPosition),
|
||||
)
|
||||
: null,
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 120),
|
||||
curve: Curves.easeOut,
|
||||
decoration: decoration,
|
||||
child: ListTile(
|
||||
dense: true,
|
||||
visualDensity: VisualDensity.compact,
|
||||
leading: status.icon,
|
||||
title: Text(client.name, style: nameStyle),
|
||||
),
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 120),
|
||||
curve: Curves.easeOut,
|
||||
decoration: decoration,
|
||||
child: ListTile(
|
||||
dense: true,
|
||||
visualDensity: VisualDensity.compact,
|
||||
leading: status.icon,
|
||||
title: Text(client.name, style: nameStyle),
|
||||
),
|
||||
),
|
||||
);
|
||||
if (!canOpenClientMenu) return tile;
|
||||
|
||||
return GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onSecondaryTapDown: (details) =>
|
||||
_showClientMenu(client, details.globalPosition),
|
||||
onLongPressStart: widget.enableClientLongPressMenu
|
||||
? (details) => _showClientMenu(client, details.globalPosition)
|
||||
: null,
|
||||
child: tile,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _showClientMenu(
|
||||
rust.BridgeClient client,
|
||||
Offset globalPosition,
|
||||
) async {
|
||||
final overlay = Overlay.of(context).context.findRenderObject();
|
||||
if (overlay is! RenderBox) return;
|
||||
final isSelf = client.id == widget.snapshot.ownClientId;
|
||||
|
||||
final selected = await showMenu<_ClientMenuAction>(
|
||||
context: context,
|
||||
position: RelativeRect.fromRect(
|
||||
Rect.fromLTWH(globalPosition.dx, globalPosition.dy, 0, 0),
|
||||
Offset.zero & overlay.size,
|
||||
),
|
||||
items: [
|
||||
if (widget.onOpenClientInfo != null)
|
||||
PopupMenuItem(
|
||||
value: _ClientMenuAction.info,
|
||||
child: ListTile(
|
||||
dense: true,
|
||||
leading: const Icon(Icons.info_outline),
|
||||
title: Text(AppL10n.of(context).clientInfoAction),
|
||||
),
|
||||
),
|
||||
if (!isSelf && widget.onOpenClientChat != null)
|
||||
PopupMenuItem(
|
||||
value: _ClientMenuAction.directMessage,
|
||||
child: ListTile(
|
||||
dense: true,
|
||||
leading: const Icon(Icons.chat_bubble_outline),
|
||||
title: Text(AppL10n.of(context).chatDirectMessageAction),
|
||||
),
|
||||
),
|
||||
if (!isSelf && widget.onOpenClientPoke != null)
|
||||
PopupMenuItem(
|
||||
value: _ClientMenuAction.poke,
|
||||
child: ListTile(
|
||||
dense: true,
|
||||
leading: const Icon(Icons.notifications_active_outlined),
|
||||
title: Text(AppL10n.of(context).chatPokeAction),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
if (!mounted || selected == null) return;
|
||||
switch (selected) {
|
||||
case _ClientMenuAction.info:
|
||||
widget.onOpenClientInfo?.call(client);
|
||||
case _ClientMenuAction.directMessage:
|
||||
widget.onOpenClientChat?.call(client);
|
||||
case _ClientMenuAction.poke:
|
||||
widget.onOpenClientPoke?.call(client);
|
||||
}
|
||||
}
|
||||
|
||||
Widget _expandButton(
|
||||
@@ -386,81 +440,6 @@ class _SnapshotViewState extends State<SnapshotView> {
|
||||
});
|
||||
}
|
||||
|
||||
bool _canAdjustClientPlayback(rust.BridgeClient client) {
|
||||
return widget.onClientPlaybackPreferenceChanged != null &&
|
||||
client.id != widget.snapshot.ownClientId &&
|
||||
!client.isServerQuery &&
|
||||
client.uid.isNotEmpty;
|
||||
}
|
||||
|
||||
ClientPlaybackPreference _clientPlaybackPreference(rust.BridgeClient client) {
|
||||
return widget.clientPlaybackPreferences[client.uid] ??
|
||||
const ClientPlaybackPreference();
|
||||
}
|
||||
|
||||
Future<void> _showClientPlaybackMenu(
|
||||
rust.BridgeClient client, {
|
||||
Offset? anchor,
|
||||
bool withHaptic = false,
|
||||
}) async {
|
||||
if (!_canAdjustClientPlayback(client)) return;
|
||||
|
||||
final onChanged = widget.onClientPlaybackPreferenceChanged;
|
||||
if (onChanged == null) return;
|
||||
|
||||
if (withHaptic) {
|
||||
unawaited(HapticFeedback.selectionClick().catchError((_) {}));
|
||||
}
|
||||
if (!mounted) return;
|
||||
|
||||
final preference = _clientPlaybackPreference(client);
|
||||
final overlay = Overlay.of(context).context.findRenderObject() as RenderBox;
|
||||
final position = anchor ?? overlay.size.center(Offset.zero);
|
||||
final selected = await showMenu<_ClientPlaybackMenuAction>(
|
||||
context: context,
|
||||
position: RelativeRect.fromLTRB(
|
||||
position.dx,
|
||||
position.dy,
|
||||
overlay.size.width - position.dx,
|
||||
overlay.size.height - position.dy,
|
||||
),
|
||||
items: [
|
||||
PopupMenuItem<_ClientPlaybackMenuAction>(
|
||||
value: const _ToggleMuteClientPlaybackMenuAction(),
|
||||
child: ListTile(
|
||||
dense: true,
|
||||
contentPadding: EdgeInsets.zero,
|
||||
leading: Icon(
|
||||
preference.muted ? Icons.volume_off : Icons.volume_up,
|
||||
),
|
||||
title: Text(preference.muted ? 'Unmute playback' : 'Mute playback'),
|
||||
subtitle: Text(client.name),
|
||||
),
|
||||
),
|
||||
const PopupMenuDivider(),
|
||||
..._clientPlaybackVolumePresets.map(
|
||||
(preset) => CheckedPopupMenuItem<_ClientPlaybackMenuAction>(
|
||||
value: _VolumeClientPlaybackMenuAction(preset),
|
||||
checked:
|
||||
!preference.muted && (preference.volume - preset).abs() < 0.001,
|
||||
child: Text('Volume ${(preset * 100).round()}%'),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
if (selected == null || !mounted) return;
|
||||
final next = switch (selected) {
|
||||
_ToggleMuteClientPlaybackMenuAction() => preference.copyWith(
|
||||
muted: !preference.muted,
|
||||
),
|
||||
_VolumeClientPlaybackMenuAction(:final volume) => preference.copyWith(
|
||||
volume: volume,
|
||||
muted: false,
|
||||
),
|
||||
};
|
||||
await onChanged(client, next);
|
||||
}
|
||||
|
||||
({Widget icon, bool isSpeaking}) _clientVoiceStatusIcon(
|
||||
ThemeData theme,
|
||||
rust.BridgeClient client,
|
||||
@@ -530,6 +509,8 @@ class _SnapshotViewState extends State<SnapshotView> {
|
||||
}
|
||||
}
|
||||
|
||||
enum _ClientMenuAction { info, directMessage, poke }
|
||||
|
||||
class _SpacerChannelContent extends StatelessWidget {
|
||||
const _SpacerChannelContent({required this.spacer});
|
||||
|
||||
@@ -673,20 +654,6 @@ class _ChannelTreeNode {
|
||||
final List<_ChannelTreeNode> children = [];
|
||||
}
|
||||
|
||||
sealed class _ClientPlaybackMenuAction {
|
||||
const _ClientPlaybackMenuAction();
|
||||
}
|
||||
|
||||
class _ToggleMuteClientPlaybackMenuAction extends _ClientPlaybackMenuAction {
|
||||
const _ToggleMuteClientPlaybackMenuAction();
|
||||
}
|
||||
|
||||
class _VolumeClientPlaybackMenuAction extends _ClientPlaybackMenuAction {
|
||||
const _VolumeClientPlaybackMenuAction(this.volume);
|
||||
|
||||
final double volume;
|
||||
}
|
||||
|
||||
_ChannelTree _buildChannelTree(List<rust.BridgeChannel> channels) {
|
||||
final byParent = <BigInt, List<rust.BridgeChannel>>{};
|
||||
final knownIds = {for (final channel in channels) channel.id};
|
||||
@@ -757,7 +724,7 @@ class _WelcomeMessageTile extends StatelessWidget {
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
'Server welcome message',
|
||||
AppL10n.of(context).serverWelcomeHeading,
|
||||
style: theme.textTheme.labelMedium?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
@@ -766,20 +733,22 @@ class _WelcomeMessageTile extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
),
|
||||
AnimatedCrossFade(
|
||||
firstChild: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(8, 0, 8, 8),
|
||||
child: BbCodeText(
|
||||
welcomeMessage,
|
||||
linkTrust: LinkTrustService.instance,
|
||||
onTs3ServerLink: onTs3ServerLink,
|
||||
),
|
||||
),
|
||||
secondChild: const SizedBox(width: double.infinity),
|
||||
crossFadeState: expanded
|
||||
? CrossFadeState.showFirst
|
||||
: CrossFadeState.showSecond,
|
||||
AnimatedSwitcher(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
child: expanded
|
||||
? Padding(
|
||||
key: const ValueKey('welcome-expanded'),
|
||||
padding: const EdgeInsets.fromLTRB(8, 0, 8, 8),
|
||||
child: BbCodeText(
|
||||
welcomeMessage,
|
||||
linkTrust: LinkTrustService.instance,
|
||||
onTs3ServerLink: onTs3ServerLink,
|
||||
),
|
||||
)
|
||||
: const SizedBox(
|
||||
key: ValueKey('welcome-collapsed'),
|
||||
width: double.infinity,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -1,468 +0,0 @@
|
||||
import 'dart:async' show unawaited;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
|
||||
import '../services/startup_dependency_check.dart';
|
||||
|
||||
class StartupDependencyGate extends StatefulWidget {
|
||||
const StartupDependencyGate({
|
||||
required this.child,
|
||||
this.checker = checkStartupDependencies,
|
||||
this.logger = logStartupDependencyIssues,
|
||||
super.key,
|
||||
});
|
||||
|
||||
final Widget child;
|
||||
final Future<StartupDependencyCheckResult> Function() checker;
|
||||
final Future<void> Function(StartupDependencyCheckResult result) logger;
|
||||
|
||||
@override
|
||||
State<StartupDependencyGate> createState() => _StartupDependencyGateState();
|
||||
}
|
||||
|
||||
class StartupDependencyScope extends InheritedWidget {
|
||||
const StartupDependencyScope({
|
||||
required this.result,
|
||||
required super.child,
|
||||
super.key,
|
||||
});
|
||||
|
||||
final StartupDependencyCheckResult result;
|
||||
|
||||
static StartupDependencyCheckResult? maybeOf(BuildContext context) {
|
||||
return context
|
||||
.dependOnInheritedWidgetOfExactType<StartupDependencyScope>()
|
||||
?.result;
|
||||
}
|
||||
|
||||
@override
|
||||
bool updateShouldNotify(StartupDependencyScope oldWidget) {
|
||||
return result != oldWidget.result;
|
||||
}
|
||||
}
|
||||
|
||||
class _StartupDependencyGateState extends State<StartupDependencyGate> {
|
||||
late Future<StartupDependencyCheckResult> _future;
|
||||
bool _dismissedForSession = false;
|
||||
String? _lastLoggedIssueSignature;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_future = widget.checker();
|
||||
}
|
||||
|
||||
void _recheck() {
|
||||
setState(() {
|
||||
_future = widget.checker();
|
||||
_dismissedForSession = false;
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (_dismissedForSession) {
|
||||
return FutureBuilder<StartupDependencyCheckResult>(
|
||||
future: _future,
|
||||
builder: (context, snapshot) {
|
||||
return StartupDependencyScope(
|
||||
result:
|
||||
snapshot.data ??
|
||||
const StartupDependencyCheckResult(
|
||||
issues: [],
|
||||
platformLabel: 'default',
|
||||
),
|
||||
child: widget.child,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return FutureBuilder<StartupDependencyCheckResult>(
|
||||
future: _future,
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.connectionState != ConnectionState.done) {
|
||||
return const _StartupCheckLoadingView();
|
||||
}
|
||||
|
||||
final result = snapshot.data;
|
||||
if (result == null || !result.hasIssues) {
|
||||
_lastLoggedIssueSignature = null;
|
||||
return StartupDependencyScope(
|
||||
result:
|
||||
result ??
|
||||
const StartupDependencyCheckResult(
|
||||
issues: [],
|
||||
platformLabel: 'default',
|
||||
),
|
||||
child: widget.child,
|
||||
);
|
||||
}
|
||||
|
||||
_logIssueScreenShown(result);
|
||||
return StartupDependencyScope(
|
||||
result: result,
|
||||
child: StartupDependencyScreen(
|
||||
result: result,
|
||||
onContinue: () {
|
||||
setState(() {
|
||||
_dismissedForSession = true;
|
||||
});
|
||||
},
|
||||
onRecheck: _recheck,
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
void _logIssueScreenShown(StartupDependencyCheckResult result) {
|
||||
final signature = [
|
||||
result.platformLabel,
|
||||
for (final issue in result.issues)
|
||||
'${issue.id}:${issue.isRequired ? 'required' : 'recommended'}',
|
||||
].join('|');
|
||||
if (_lastLoggedIssueSignature == signature) {
|
||||
return;
|
||||
}
|
||||
_lastLoggedIssueSignature = signature;
|
||||
unawaited(widget.logger(result));
|
||||
}
|
||||
}
|
||||
|
||||
class StartupDependencyScreen extends StatelessWidget {
|
||||
const StartupDependencyScreen({
|
||||
required this.result,
|
||||
required this.onContinue,
|
||||
required this.onRecheck,
|
||||
super.key,
|
||||
});
|
||||
|
||||
final StartupDependencyCheckResult result;
|
||||
final VoidCallback onContinue;
|
||||
final VoidCallback onRecheck;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final hasBlockingIssues = result.hasBlockingIssues;
|
||||
final content = Column(
|
||||
children: [
|
||||
Icon(
|
||||
hasBlockingIssues
|
||||
? Icons.warning_amber_rounded
|
||||
: Icons.info_outline_rounded,
|
||||
size: 52,
|
||||
color: hasBlockingIssues
|
||||
? theme.colorScheme.error
|
||||
: theme.colorScheme.primary,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'Finish Linux setup',
|
||||
style: theme.textTheme.headlineMedium,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
hasBlockingIssues
|
||||
? 'Chanora started, but some Linux runtime packages are still missing. Install them, then recheck.'
|
||||
: 'Chanora started, but a few optional Linux runtime components are still missing. You can install them now or continue with limited functionality.',
|
||||
style: theme.textTheme.bodyLarge,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Detected platform: ${result.platformLabel}',
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
...result.issues.map((issue) => _DependencyIssueCard(issue: issue)),
|
||||
const SizedBox(height: 16),
|
||||
Wrap(
|
||||
alignment: WrapAlignment.center,
|
||||
spacing: 12,
|
||||
runSpacing: 12,
|
||||
children: [
|
||||
FilledButton.icon(
|
||||
onPressed: onRecheck,
|
||||
icon: const Icon(Icons.refresh_rounded),
|
||||
label: const Text('Recheck'),
|
||||
),
|
||||
OutlinedButton.icon(
|
||||
onPressed: onContinue,
|
||||
icon: const Icon(Icons.arrow_forward_rounded),
|
||||
label: Text(
|
||||
hasBlockingIssues
|
||||
? 'Continue with limited mode'
|
||||
: 'Continue anyway',
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
return Scaffold(
|
||||
body: SafeArea(
|
||||
child: Scrollbar(
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 24),
|
||||
children: [
|
||||
Align(
|
||||
alignment: Alignment.topCenter,
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 880),
|
||||
child: content,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _DependencyIssueCard extends StatelessWidget {
|
||||
const _DependencyIssueCard({required this.issue});
|
||||
|
||||
final StartupDependencyIssue issue;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final scheme = theme.colorScheme;
|
||||
final containerColor = issue.isRequired
|
||||
? scheme.errorContainer
|
||||
: scheme.secondaryContainer;
|
||||
final onContainerColor = issue.isRequired
|
||||
? scheme.onErrorContainer
|
||||
: scheme.onSecondaryContainer;
|
||||
|
||||
return Card(
|
||||
margin: const EdgeInsets.only(bottom: 16),
|
||||
color: containerColor,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Icon(
|
||||
issue.isRequired
|
||||
? Icons.error_outline_rounded
|
||||
: Icons.settings_suggest_rounded,
|
||||
color: onContainerColor,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
issue.title,
|
||||
style: theme.textTheme.titleMedium?.copyWith(
|
||||
color: onContainerColor,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
issue.summary,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: onContainerColor,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
_SeverityBadge(issue: issue),
|
||||
],
|
||||
),
|
||||
if (issue.details.isNotEmpty) ...[
|
||||
const SizedBox(height: 16),
|
||||
...issue.details.map(
|
||||
(detail) => Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8),
|
||||
child: Text(
|
||||
'• $detail',
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: onContainerColor,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
if (issue.installHints.isNotEmpty) ...[
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Install help',
|
||||
style: theme.textTheme.titleSmall?.copyWith(
|
||||
color: onContainerColor,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
...issue.installHints.map(
|
||||
(hint) => _InstallHintTile(
|
||||
hint: hint,
|
||||
foregroundColor: onContainerColor,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _InstallHintTile extends StatelessWidget {
|
||||
const _InstallHintTile({required this.hint, required this.foregroundColor});
|
||||
|
||||
final StartupInstallHint hint;
|
||||
final Color foregroundColor;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final messenger = ScaffoldMessenger.of(context);
|
||||
final isUrl = _isWebUrl(hint.command);
|
||||
final VoidCallback? onTap = isUrl
|
||||
? () => unawaited(_openUrl(context, Uri.parse(hint.command)))
|
||||
: null;
|
||||
return InkWell(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
onTap: onTap,
|
||||
child: Container(
|
||||
margin: const EdgeInsets.only(bottom: 12),
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
color: foregroundColor.withValues(alpha: 0.08),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
hint.label,
|
||||
style: Theme.of(context).textTheme.labelLarge?.copyWith(
|
||||
color: foregroundColor,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
),
|
||||
TextButton.icon(
|
||||
onPressed: () async {
|
||||
if (isUrl) {
|
||||
await _openUrl(context, Uri.parse(hint.command));
|
||||
return;
|
||||
}
|
||||
await Clipboard.setData(ClipboardData(text: hint.command));
|
||||
messenger.showSnackBar(
|
||||
const SnackBar(content: Text('Install command copied')),
|
||||
);
|
||||
},
|
||||
icon: Icon(
|
||||
isUrl
|
||||
? Icons.open_in_new_rounded
|
||||
: Icons.content_copy_rounded,
|
||||
),
|
||||
label: Text(isUrl ? 'Open' : 'Copy'),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
SelectableText(
|
||||
hint.command,
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
fontFamily: 'monospace',
|
||||
color: foregroundColor,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
bool _isWebUrl(String value) {
|
||||
final uri = Uri.tryParse(value);
|
||||
return uri != null &&
|
||||
(uri.scheme == 'http' || uri.scheme == 'https') &&
|
||||
uri.hasAuthority;
|
||||
}
|
||||
|
||||
Future<void> _openUrl(BuildContext context, Uri uri) async {
|
||||
final messenger = ScaffoldMessenger.of(context);
|
||||
final opened = await launchUrl(uri, mode: LaunchMode.externalApplication);
|
||||
if (!opened && context.mounted) {
|
||||
messenger.showSnackBar(
|
||||
SnackBar(content: Text('Could not open ${uri.toString()}')),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class _SeverityBadge extends StatelessWidget {
|
||||
const _SeverityBadge({required this.issue});
|
||||
|
||||
final StartupDependencyIssue issue;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final scheme = theme.colorScheme;
|
||||
final foregroundColor = issue.isRequired
|
||||
? scheme.onErrorContainer
|
||||
: scheme.onSecondaryContainer;
|
||||
|
||||
return DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
color: foregroundColor.withValues(alpha: 0.10),
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
border: Border.all(color: foregroundColor.withValues(alpha: 0.18)),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
|
||||
child: Text(
|
||||
issue.isRequired ? 'Required' : 'Recommended',
|
||||
style: theme.textTheme.labelMedium?.copyWith(
|
||||
color: foregroundColor,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _StartupCheckLoadingView extends StatelessWidget {
|
||||
const _StartupCheckLoadingView();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return const Scaffold(
|
||||
body: Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
CircularProgressIndicator(),
|
||||
SizedBox(height: 16),
|
||||
Text('Checking Linux runtime dependencies…'),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -358,7 +358,6 @@ Future<void> showVoiceDetailsSheet(
|
||||
required String pttBoundInputClass,
|
||||
required bool isTouchOnly,
|
||||
required rust.BridgeAudioProcessingConfig initialAudioConfig,
|
||||
bool onnxRuntimeAvailable = true,
|
||||
required ValueChanged<rust.BridgeTransmitMode> onModeChanged,
|
||||
required ValueChanged<int> onReleaseTailChanged,
|
||||
required ValueChanged<rust.BridgeAudioProcessingConfig> onAudioConfigChanged,
|
||||
@@ -389,7 +388,6 @@ Future<void> showVoiceDetailsSheet(
|
||||
pttBoundInputClass: pttBoundInputClass,
|
||||
isTouchOnly: isTouchOnly,
|
||||
initialAudioConfig: initialAudioConfig,
|
||||
onnxRuntimeAvailable: onnxRuntimeAvailable,
|
||||
onModeChanged: onModeChanged,
|
||||
onReleaseTailChanged: onReleaseTailChanged,
|
||||
onAudioConfigChanged: onAudioConfigChanged,
|
||||
@@ -413,7 +411,6 @@ class _VoiceSheetBody extends StatefulWidget {
|
||||
required this.pttBoundInputClass,
|
||||
required this.isTouchOnly,
|
||||
required this.initialAudioConfig,
|
||||
required this.onnxRuntimeAvailable,
|
||||
required this.onModeChanged,
|
||||
required this.onReleaseTailChanged,
|
||||
required this.onAudioConfigChanged,
|
||||
@@ -431,7 +428,6 @@ class _VoiceSheetBody extends StatefulWidget {
|
||||
final String pttBoundInputClass;
|
||||
final bool isTouchOnly;
|
||||
final rust.BridgeAudioProcessingConfig initialAudioConfig;
|
||||
final bool onnxRuntimeAvailable;
|
||||
final ValueChanged<rust.BridgeTransmitMode> onModeChanged;
|
||||
final ValueChanged<int> onReleaseTailChanged;
|
||||
final ValueChanged<rust.BridgeAudioProcessingConfig> onAudioConfigChanged;
|
||||
@@ -466,10 +462,6 @@ class _VoiceSheetBodyState extends State<_VoiceSheetBody> {
|
||||
_audioProcessing = AudioProcessingConfigState.fromConfig(
|
||||
widget.initialAudioConfig,
|
||||
);
|
||||
_audioProcessing.vadBackend = normalizedVadBackend(
|
||||
_audioProcessing.vadBackend,
|
||||
onnxRuntimeAvailable: widget.onnxRuntimeAvailable,
|
||||
);
|
||||
|
||||
// Poll audio stats at 250 ms so TX/RX counters and the level meter
|
||||
// update in real time while the sheet is open, independent of the parent.
|
||||
@@ -785,32 +777,15 @@ class _VoiceSheetBodyState extends State<_VoiceSheetBody> {
|
||||
const SizedBox(height: 2),
|
||||
SegmentedButton<rust.BridgeVadBackend>(
|
||||
style: voiceSegmentedButtonStyle(theme),
|
||||
segments: vadBackendSegmentsForAvailability(
|
||||
desktop: _isDesktopSileroVadHost,
|
||||
onnxRuntimeAvailable: widget.onnxRuntimeAvailable,
|
||||
),
|
||||
segments: _isDesktopSileroVadHost
|
||||
? desktopVadBackendSegments
|
||||
: vadBackendSegments,
|
||||
selected: {_audioProcessing.vadBackend},
|
||||
onSelectionChanged: (s) {
|
||||
setState(
|
||||
() => _audioProcessing.vadBackend = normalizedVadBackend(
|
||||
s.first,
|
||||
onnxRuntimeAvailable: widget.onnxRuntimeAvailable,
|
||||
),
|
||||
);
|
||||
setState(() => _audioProcessing.vadBackend = s.first);
|
||||
_notifyAudioConfig();
|
||||
},
|
||||
),
|
||||
if (_isDesktopSileroVadHost) ...[
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
widget.onnxRuntimeAvailable
|
||||
? 'Silero needs ONNX Runtime. WebRTC works without it.'
|
||||
: 'Silero is unavailable because ONNX Runtime was not found. WebRTC is selected.',
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
// Debug.
|
||||
const SizedBox(height: 8),
|
||||
|
||||
@@ -67,7 +67,6 @@ class VoiceSettingsDialog extends StatefulWidget {
|
||||
this.pttLevel = '',
|
||||
this.pttBackendId = '',
|
||||
this.pttBoundInputClass = '',
|
||||
this.onnxRuntimeAvailable = true,
|
||||
this.talkPower,
|
||||
this.neededTalkPower,
|
||||
this.talkPowerGranted,
|
||||
@@ -79,7 +78,6 @@ class VoiceSettingsDialog extends StatefulWidget {
|
||||
final String pttLevel;
|
||||
final String pttBackendId;
|
||||
final String pttBoundInputClass;
|
||||
final bool onnxRuntimeAvailable;
|
||||
final int? talkPower;
|
||||
final int? neededTalkPower;
|
||||
final bool? talkPowerGranted;
|
||||
@@ -103,10 +101,6 @@ class _VoiceSettingsDialogState extends State<VoiceSettingsDialog> {
|
||||
_audioProcessing = AudioProcessingConfigState.fromConfig(
|
||||
widget.initialAudioConfig,
|
||||
);
|
||||
_audioProcessing.vadBackend = normalizedVadBackend(
|
||||
_audioProcessing.vadBackend,
|
||||
onnxRuntimeAvailable: widget.onnxRuntimeAvailable,
|
||||
);
|
||||
}
|
||||
|
||||
rust.BridgeAudioProcessingConfig _buildConfig() {
|
||||
@@ -297,29 +291,14 @@ class _VoiceSettingsDialogState extends State<VoiceSettingsDialog> {
|
||||
const VoiceSubHeader('Backend'),
|
||||
SegmentedButton<rust.BridgeVadBackend>(
|
||||
style: voiceSegmentedButtonStyle(theme),
|
||||
segments: vadBackendSegmentsForAvailability(
|
||||
desktop: _isDesktopSileroVadHost,
|
||||
onnxRuntimeAvailable: widget.onnxRuntimeAvailable,
|
||||
),
|
||||
segments: _isDesktopSileroVadHost
|
||||
? desktopVadBackendSegments
|
||||
: vadBackendSegments,
|
||||
selected: {_audioProcessing.vadBackend},
|
||||
onSelectionChanged: (s) => setState(
|
||||
() => _audioProcessing.vadBackend = normalizedVadBackend(
|
||||
s.first,
|
||||
onnxRuntimeAvailable: widget.onnxRuntimeAvailable,
|
||||
),
|
||||
),
|
||||
onSelectionChanged: (s) =>
|
||||
setState(() => _audioProcessing.vadBackend = s.first),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
if (_isDesktopSileroVadHost)
|
||||
Text(
|
||||
widget.onnxRuntimeAvailable
|
||||
? 'Silero gives the best quality when ONNX Runtime is installed. WebRTC works without ONNX Runtime and is the safer fallback if Linux setup is incomplete.'
|
||||
: 'Silero is unavailable because ONNX Runtime was not found. WebRTC is selected until libonnxruntime.so is installed or bundled.',
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
if (_isDesktopSileroVadHost) const SizedBox(height: 8),
|
||||
|
||||
// ── PTT capability badge ────────────────────────────────
|
||||
if (_mode == rust.BridgeTransmitMode.ptt &&
|
||||
|
||||
@@ -59,30 +59,15 @@ const vadBackendSegments = [
|
||||
|
||||
/// Desktop VAD selector segments.
|
||||
///
|
||||
/// Desktop keeps Silero as the default, but WebRTC remains a supported
|
||||
/// manual fallback when ONNX Runtime is missing or when the user wants a
|
||||
/// smaller dependency surface.
|
||||
const desktopVadBackendSegments = vadBackendSegments;
|
||||
|
||||
List<ButtonSegment<rust.BridgeVadBackend>> vadBackendSegmentsForAvailability({
|
||||
required bool desktop,
|
||||
required bool onnxRuntimeAvailable,
|
||||
}) {
|
||||
final segments = desktop ? desktopVadBackendSegments : vadBackendSegments;
|
||||
if (!desktop || onnxRuntimeAvailable) return segments;
|
||||
return [
|
||||
for (final segment in segments)
|
||||
if (segment.value == rust.BridgeVadBackend.sileroOnnx)
|
||||
ButtonSegment<rust.BridgeVadBackend>(
|
||||
value: segment.value,
|
||||
label: segment.label,
|
||||
icon: segment.icon,
|
||||
enabled: false,
|
||||
)
|
||||
else
|
||||
segment,
|
||||
];
|
||||
}
|
||||
/// Windows and Linux use Silero as the primary VAD. WebRTC remains an
|
||||
/// internal runtime fallback when the model/runtime is unavailable.
|
||||
const desktopVadBackendSegments = [
|
||||
ButtonSegment(
|
||||
value: rust.BridgeVadBackend.sileroOnnx,
|
||||
label: Text('Silero'),
|
||||
icon: Icon(Icons.psychology, size: 14),
|
||||
),
|
||||
];
|
||||
|
||||
/// Section subheader used by both voice settings surfaces.
|
||||
class VoiceSubHeader extends StatelessWidget {
|
||||
|
||||
Reference in New Issue
Block a user