Reduce Linux setup ambiguity and surface desktop input/message failures honestly
Clarify ONNX Runtime guidance with direct-open install hints, restore desktop WebRTC VAD visibility, map mouse side buttons through focused PTT capture/runtime paths, and wait for server acks before showing chat sends as successful. Constraint: Linux release UX must stay functional when ONNX Runtime is optional and GNOME portal availability varies Rejected: Keep desktop VAD locked to Silero only | misleads users when ONNX Runtime is skipped Confidence: medium Scope-risk: moderate Directive: Preserve the protocol send-ack wait path for chat so UI success always tracks real server acceptance Tested: flutter analyze lib/main.dart lib/widgets/chat_views.dart lib/widgets/input_dialogs.dart lib/widgets/startup_dependency_screen.dart; flutter test test/widgets/input_dialogs_test.dart test/widgets/chat_views_test.dart test/services/startup_dependency_check_test.dart test/widgets/startup_dependency_screen_test.dart test/widgets/voice_settings_controls_test.dart test/widgets/audio_processing_config_state_test.dart; cargo test -p chanora_protocol --lib; cargo test -p chanora_audio ptt_backends --lib Not-tested: Live manual GNOME portal rebind/global PTT on a real desktop session; observer-bot chat against a live server after the sender-name fallback change
This commit is contained in:
@@ -201,19 +201,26 @@ bool androidShowsLimiterControl(AudioProcessingConfigState state) {
|
||||
|
||||
/// Never leave the UI on the hidden disabled backend.
|
||||
///
|
||||
/// Windows and Linux use Silero as the primary VAD; WebRTC is still
|
||||
/// available internally as a runtime fallback.
|
||||
/// Desktop keeps Silero as the default, but still allows a user-chosen
|
||||
/// WebRTC fallback when ONNX Runtime is unavailable.
|
||||
rust.BridgeVadBackend normalizedVadBackend(
|
||||
rust.BridgeVadBackend backend, {
|
||||
bool? isWindows,
|
||||
bool? isLinux,
|
||||
}) {
|
||||
final desktop =
|
||||
(isWindows ?? Platform.isWindows) || (isLinux ?? Platform.isLinux);
|
||||
if (desktop) return rust.BridgeVadBackend.sileroOnnx;
|
||||
return backend == rust.BridgeVadBackend.disabled
|
||||
final normalized = backend == rust.BridgeVadBackend.disabled
|
||||
? rust.BridgeVadBackend.sileroOnnx
|
||||
: backend;
|
||||
final desktop =
|
||||
(isWindows ?? Platform.isWindows) || (isLinux ?? Platform.isLinux);
|
||||
if (!desktop) {
|
||||
return normalized;
|
||||
}
|
||||
return switch (normalized) {
|
||||
rust.BridgeVadBackend.webrtcVad ||
|
||||
rust.BridgeVadBackend.sileroOnnx => normalized,
|
||||
_ => rust.BridgeVadBackend.sileroOnnx,
|
||||
};
|
||||
}
|
||||
|
||||
rust.BridgeIosVoiceProcessingMode normalizedIosProcessingMode(
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import 'dart:async' show unawaited;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../l10n/generated/app_localizations.dart';
|
||||
@@ -11,7 +9,12 @@ import '../src/rust/api.dart' as rust;
|
||||
import 'bbcode_text.dart';
|
||||
|
||||
const double _chatSidebarTileExtent = 92;
|
||||
const Color _chatSidebarSelectedTileColor = Color(0xFF415366);
|
||||
const double _chatSidebarIndicatorExtent = 76;
|
||||
const double _chatSidebarIconExtent = 24;
|
||||
const double _chatSidebarIconSize = 20;
|
||||
const BorderRadius _chatSidebarIndicatorRadius = BorderRadius.all(
|
||||
Radius.circular(20),
|
||||
);
|
||||
|
||||
/// One chat/activity message shown in the chat hub.
|
||||
class ChatEntry {
|
||||
@@ -750,56 +753,64 @@ class _ChatSidebar extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final dividerColor = theme.colorScheme.outlineVariant.withValues(
|
||||
alpha: 0.45,
|
||||
);
|
||||
return SizedBox(
|
||||
width: _chatSidebarTileExtent,
|
||||
child: Column(
|
||||
children: [
|
||||
_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()),
|
||||
),
|
||||
const Divider(height: 1),
|
||||
Expanded(
|
||||
child: ListView.builder(
|
||||
padding: EdgeInsets.zero,
|
||||
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,
|
||||
),
|
||||
);
|
||||
},
|
||||
child: Material(
|
||||
color: theme.colorScheme.surfaceContainerLow,
|
||||
child: Column(
|
||||
children: [
|
||||
const SizedBox(height: 8),
|
||||
_ChatSidebarItem(
|
||||
icon: Icons.dns_outlined,
|
||||
label: 'Server',
|
||||
selected: selectedTarget is rust.BridgeMessageTarget_Server,
|
||||
onTap: () => onSelect(const rust.BridgeMessageTarget.server()),
|
||||
),
|
||||
),
|
||||
const Divider(height: 1),
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(8),
|
||||
child: IconButton.filledTonal(
|
||||
tooltip: 'New private chat',
|
||||
icon: const Icon(Icons.add),
|
||||
onPressed: onNewPrivateChat,
|
||||
_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),
|
||||
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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -821,48 +832,63 @@ class _ChatSidebarItem extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final fg = selected ? Colors.white : theme.colorScheme.onSurface;
|
||||
return Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: Ink(
|
||||
width: _chatSidebarTileExtent,
|
||||
height: _chatSidebarTileExtent,
|
||||
decoration: BoxDecoration(
|
||||
color: selected
|
||||
? _chatSidebarSelectedTileColor
|
||||
: Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 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)],
|
||||
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,
|
||||
),
|
||||
),
|
||||
),
|
||||
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,
|
||||
const SizedBox(height: 8),
|
||||
SizedBox(
|
||||
width: _chatSidebarIndicatorExtent - 16,
|
||||
child: Text(
|
||||
label,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
textAlign: TextAlign.center,
|
||||
style: labelStyle,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -1018,6 +1044,7 @@ 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) {
|
||||
@@ -1047,30 +1074,51 @@ class _ChatDetailViewState extends State<_ChatDetailView> {
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _send() {
|
||||
Future<void> _send() async {
|
||||
final text = _textCtl.text.trim();
|
||||
if (text.isEmpty || !_canSend) return;
|
||||
if (text.isEmpty || !_canSend || _sending) return;
|
||||
_textCtl.clear();
|
||||
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(),
|
||||
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,
|
||||
),
|
||||
),
|
||||
);
|
||||
if (widget.messages.length > 200) {
|
||||
widget.messages.removeRange(0, widget.messages.length - 200);
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setState(() => _sending = false);
|
||||
}
|
||||
});
|
||||
_scrollToBottom();
|
||||
}
|
||||
}
|
||||
|
||||
void _scrollToBottom() {
|
||||
|
||||
@@ -4,6 +4,9 @@ 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) {
|
||||
@@ -46,6 +49,27 @@ 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});
|
||||
@@ -181,6 +205,16 @@ 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(() {
|
||||
@@ -190,9 +224,9 @@ class _PttBindingCaptureDialogState extends State<PttBindingCaptureDialog> {
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
|
||||
void _captureMouseSideButton(int button) {
|
||||
void _captureMouseSideButton(String platformKey) {
|
||||
setState(() {
|
||||
_captured = 'mouse-side-button:$button';
|
||||
_captured = platformKey;
|
||||
_capturedClass = rust.BridgePttInputClass.mouseSideButton;
|
||||
});
|
||||
}
|
||||
@@ -212,10 +246,11 @@ class _PttBindingCaptureDialogState extends State<PttBindingCaptureDialog> {
|
||||
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);
|
||||
final platformKey = pttMouseSideButtonPlatformKeyForButtons(
|
||||
e.buttons,
|
||||
);
|
||||
if (platformKey != null) {
|
||||
_captureMouseSideButton(platformKey);
|
||||
}
|
||||
},
|
||||
child: Column(
|
||||
|
||||
@@ -0,0 +1,423 @@
|
||||
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 _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 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 widget.child;
|
||||
}
|
||||
|
||||
_logIssueScreenShown(result);
|
||||
return 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…'),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -786,6 +786,15 @@ class _VoiceSheetBodyState extends State<_VoiceSheetBody> {
|
||||
_notifyAudioConfig();
|
||||
},
|
||||
),
|
||||
if (_isDesktopSileroVadHost) ...[
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'Silero needs ONNX Runtime. WebRTC works without it.',
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
// Debug.
|
||||
const SizedBox(height: 8),
|
||||
|
||||
@@ -299,6 +299,14 @@ class _VoiceSettingsDialogState extends State<VoiceSettingsDialog> {
|
||||
setState(() => _audioProcessing.vadBackend = s.first),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
if (_isDesktopSileroVadHost)
|
||||
Text(
|
||||
'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.',
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
if (_isDesktopSileroVadHost) const SizedBox(height: 8),
|
||||
|
||||
// ── PTT capability badge ────────────────────────────────
|
||||
if (_mode == rust.BridgeTransmitMode.ptt &&
|
||||
|
||||
@@ -59,15 +59,10 @@ const vadBackendSegments = [
|
||||
|
||||
/// Desktop VAD selector segments.
|
||||
///
|
||||
/// 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),
|
||||
),
|
||||
];
|
||||
/// 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;
|
||||
|
||||
/// Section subheader used by both voice settings surfaces.
|
||||
class VoiceSubHeader extends StatelessWidget {
|
||||
|
||||
Reference in New Issue
Block a user