Two related VoiceBar / scaffold issues on wide windows: 1. Banner placement The 'not production ready' tertiaryContainer banner sat full-width above the body Column. In wide layouts (>=840 dp) where the connected view splits into Voice Bar (320 dp) + channel tree (Expanded), the banner spanned both columns and dwarfed the channel-tree pane. Rework: wrap the body in an outer LayoutBuilder so the placement decision can read bodyConstraints.maxWidth. When wide AND connected AND snapshot != null, render the banner inside the left 320 dp SizedBox above the VoiceBar. In every other state (narrow, idle, connecting) the banner stays pinned full-width at the top. 2. Channel-name pill overflow The Container holding the channel pill had no width constraint and Text(channelName) had no overflow handling. Long channel names made the pill extend past the column's 320 dp; mute icons slid under the adjacent channel tree. Rework: pill wrapped in Flexible(flex: 100, fit: FlexFit.loose); inner Text gets maxLines: 1, overflow: TextOverflow.ellipsis, softWrap: false. Spacer keeps default flex 1; the 100:1 ratio means short names hug their intrinsic width and long names take ~99% of the remaining space then ellipsize. Mute icons stay pinned right. flutter analyze: clean (6 pre-existing Radio.groupValue infos only).
1596 lines
53 KiB
Dart
1596 lines
53 KiB
Dart
// Chanora Flutter application — External Beta build.
|
|
//
|
|
// Builds on Internal Beta v0.3.0-beta.1:
|
|
// * server password field
|
|
// * bookmark list with add / connect / delete actions
|
|
// * channel tap-to-join with optional channel password
|
|
// * self input + output mute toggles + master output gain slider
|
|
// * diagnostics dialog + reconnect banner + identity persistence
|
|
// (all carried over from v0.3.0-beta.1)
|
|
|
|
import 'dart:async';
|
|
|
|
import 'package:connectivity_plus/connectivity_plus.dart';
|
|
import 'package:flutter/foundation.dart';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter/services.dart';
|
|
import 'package:path_provider/path_provider.dart';
|
|
|
|
import 'l10n/generated/app_localizations.dart';
|
|
import 'src/rust/api.dart' as rust;
|
|
import 'src/rust/lib.dart' as rust_err;
|
|
import 'src/rust/frb_generated.dart';
|
|
import 'widgets/voice_bar.dart';
|
|
import 'widgets/voice_settings.dart';
|
|
|
|
/// Public version string shown in the About dialog. Aligned with
|
|
/// `pubspec.yaml` and the git tag for the MVP release candidate.
|
|
/// Bumped to rc.8 for the post-rc.7 v1 audio + PTT lifecycle work
|
|
/// (SDD-094..097, DEC-029..031).
|
|
const String _kAppVersion = 'v1.0.0-rc.8';
|
|
|
|
Future<void> main() async {
|
|
WidgetsFlutterBinding.ensureInitialized();
|
|
await RustLib.init();
|
|
unawaited(_wireStorage());
|
|
unawaited(_wireConnectivity());
|
|
runApp(const ChanoraApp());
|
|
}
|
|
|
|
Future<void> _wireStorage() async {
|
|
try {
|
|
final dir = await getApplicationSupportDirectory();
|
|
await rust.initStorage(dir: dir.path);
|
|
} catch (_) {
|
|
// Best-effort; missing storage just means no identity persistence
|
|
// and no bookmark list this session.
|
|
}
|
|
}
|
|
|
|
rust.BridgeNetworkState _mapConnectivity(List<ConnectivityResult> results) {
|
|
if (results.isEmpty) return rust.BridgeNetworkState.unknown;
|
|
final allNone = results.every((r) => r == ConnectivityResult.none);
|
|
if (allNone) return rust.BridgeNetworkState.offline;
|
|
return rust.BridgeNetworkState.online;
|
|
}
|
|
|
|
Future<void> _wireConnectivity() async {
|
|
final connectivity = Connectivity();
|
|
try {
|
|
final initial = await connectivity.checkConnectivity();
|
|
rust.setNetworkState(state: _mapConnectivity(initial));
|
|
} catch (_) {}
|
|
connectivity.onConnectivityChanged.listen((results) {
|
|
rust.setNetworkState(state: _mapConnectivity(results));
|
|
});
|
|
}
|
|
|
|
class ChanoraApp extends StatelessWidget {
|
|
const ChanoraApp({super.key});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return MaterialApp(
|
|
onGenerateTitle: (ctx) => AppL10n.of(ctx).appTitle,
|
|
theme: ThemeData(
|
|
useMaterial3: true,
|
|
colorSchemeSeed: const Color(0xFF3F51B5),
|
|
),
|
|
localizationsDelegates: AppL10n.localizationsDelegates,
|
|
supportedLocales: AppL10n.supportedLocales,
|
|
home: const _BetaHome(),
|
|
);
|
|
}
|
|
}
|
|
|
|
enum _Phase { idle, connecting, connected }
|
|
|
|
class _BetaHome extends StatefulWidget {
|
|
const _BetaHome();
|
|
|
|
@override
|
|
State<_BetaHome> createState() => _BetaHomeState();
|
|
}
|
|
|
|
class _BetaHomeState extends State<_BetaHome> {
|
|
final _hostCtl = TextEditingController(text: 'cn.teamspeak.app');
|
|
final _nickCtl = TextEditingController(text: 'ChanoraBeta');
|
|
final _passwordCtl = TextEditingController();
|
|
|
|
_Phase _phase = _Phase.idle;
|
|
rust.BridgeSnapshot? _snapshot;
|
|
String? _error;
|
|
// ignore: unused_field
|
|
bool _audioStarted = false;
|
|
rust.BridgeAudioStats? _audioStats;
|
|
Timer? _statsTimer;
|
|
StreamSubscription<rust.BridgeEvent>? _eventsSub;
|
|
|
|
// v1 voice subsystem state (SDD-094/095/096/097). Driven by
|
|
// BridgeEvent::VoiceState.
|
|
bool _inChannel = false;
|
|
rust.BridgeTransmitMode _transmitMode = rust.BridgeTransmitMode.ptt;
|
|
bool _hardMute = false;
|
|
int _releaseTailMs = 200;
|
|
BigInt? _currentVoiceChannelId;
|
|
|
|
String? _lostReason;
|
|
int? _reconnectAttempt;
|
|
int? _reconnectDelay;
|
|
|
|
bool _inputMuted = false;
|
|
bool _outputMuted = false;
|
|
// ignore: unused_field
|
|
double _outputGain = 1.0;
|
|
|
|
// Desktop PTT capability badge state (gen2 v0.9.3 / SDD-091).
|
|
// Populated by `BridgeEvent.PttCapability`. Defaults match the
|
|
// universal `FocusedPttBackend::focused()` descriptor so the UI
|
|
// shows an honest baseline even before the first event arrives.
|
|
String _pttLevel = 'L0Focused';
|
|
String _pttBackendId = 'focused';
|
|
String _pttBoundInputClass = 'keyboard';
|
|
// Last platform-neutral key label the user saved in the
|
|
// `_PttBindingCaptureDialog` (e.g. "Space", "F10",
|
|
// "mouse-side-button:8"). Surfaced next to the capability
|
|
// badge so the user can remember which key drives PTT. The
|
|
// bridge-side `PttController` (SDD-088) holds the authoritative
|
|
// binding; this is a display-only cache that resets on app
|
|
// restart. Per DEC-027 the raw OS key code never lives here —
|
|
// only the platform-neutral label that already crossed into
|
|
// the Rust side.
|
|
String _pttBoundKeyLabel = '';
|
|
|
|
List<rust.BridgeBookmark> _bookmarks = const [];
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_eventsSub = rust.eventsStream().listen(_onEvent);
|
|
unawaited(_reloadBookmarks());
|
|
unawaited(_hydratePttBinding());
|
|
}
|
|
|
|
/// Hydrate the bound-key display state from the bridge so the
|
|
/// Voice Bar shows the user's persisted hotkey label immediately
|
|
/// at launch — without having to wait for them to open the
|
|
/// binding dialog. The bridge returns empty strings when no
|
|
/// binding has ever been saved.
|
|
Future<void> _hydratePttBinding() async {
|
|
try {
|
|
final (inputClass, keyLabel) = await rust.getPttBinding();
|
|
if (!mounted) return;
|
|
if (keyLabel.isNotEmpty || inputClass.isNotEmpty) {
|
|
setState(() {
|
|
_pttBoundKeyLabel = keyLabel;
|
|
_pttBoundInputClass = inputClass;
|
|
});
|
|
}
|
|
} catch (_) {
|
|
// No persisted binding or storage not yet wired; not an error.
|
|
}
|
|
}
|
|
|
|
Future<void> _reloadBookmarks() async {
|
|
try {
|
|
final list = await rust.listBookmarks();
|
|
if (!mounted) return;
|
|
setState(() => _bookmarks = list);
|
|
} catch (_) {
|
|
// Bookmark store missing on this platform — empty list is fine.
|
|
}
|
|
}
|
|
|
|
void _onEvent(rust.BridgeEvent evt) {
|
|
if (!mounted) return;
|
|
switch (evt) {
|
|
case rust.BridgeEvent_Connected():
|
|
setState(() {
|
|
_phase = _Phase.connected;
|
|
_lostReason = null;
|
|
_reconnectAttempt = null;
|
|
_reconnectDelay = null;
|
|
});
|
|
case rust.BridgeEvent_Lost(:final reason):
|
|
setState(() {
|
|
_lostReason = reason;
|
|
_reconnectAttempt = null;
|
|
_reconnectDelay = null;
|
|
});
|
|
case rust.BridgeEvent_Reconnecting(:final attempt, :final delaySecs):
|
|
setState(() {
|
|
_reconnectAttempt = attempt;
|
|
_reconnectDelay = delaySecs;
|
|
});
|
|
case rust.BridgeEvent_Disconnected():
|
|
setState(() {
|
|
_phase = _Phase.idle;
|
|
_lostReason = null;
|
|
_reconnectAttempt = null;
|
|
_reconnectDelay = null;
|
|
});
|
|
case rust.BridgeEvent_AudioStarted():
|
|
setState(() => _audioStarted = true);
|
|
case rust.BridgeEvent_AudioStopped():
|
|
setState(() => _audioStarted = false);
|
|
case rust.BridgeEvent_SnapshotChanged():
|
|
unawaited(_onRefresh());
|
|
case rust.BridgeEvent_PttCapability(
|
|
:final level,
|
|
:final backendId,
|
|
:final boundInputClass,
|
|
):
|
|
setState(() {
|
|
_pttLevel = level;
|
|
_pttBackendId = backendId;
|
|
_pttBoundInputClass = boundInputClass;
|
|
});
|
|
case rust.BridgeEvent_VoiceState(
|
|
:final inChannel,
|
|
:final transmitMode,
|
|
:final mute,
|
|
:final releaseTailMs,
|
|
):
|
|
setState(() {
|
|
_inChannel = inChannel;
|
|
_transmitMode = transmitMode;
|
|
_hardMute = mute;
|
|
_releaseTailMs = releaseTailMs;
|
|
_audioStarted = inChannel;
|
|
});
|
|
if (inChannel) {
|
|
_ensureStatsTimer();
|
|
} else {
|
|
_statsTimer?.cancel();
|
|
_statsTimer = null;
|
|
}
|
|
}
|
|
}
|
|
|
|
void _ensureStatsTimer() {
|
|
if (_statsTimer != null) return;
|
|
// Poll at 80 ms (~12 Hz) — this is the loop that drives the
|
|
// Voice Bar's "PTT=on/off" indicator and the level meter, so
|
|
// it needs to be fast enough that users don't perceive a lag
|
|
// between physically pressing the bound key and seeing the
|
|
// UI change. 80 ms is well below the ~150 ms perceptual
|
|
// delay threshold and adds only a dozen tiny FFI calls per
|
|
// second to the load. A future push-based BridgeEvent for
|
|
// transmit-active transitions would let us drop this poll
|
|
// entirely.
|
|
_statsTimer = Timer.periodic(const Duration(milliseconds: 80), (_) async {
|
|
try {
|
|
final s = await rust.audioStats();
|
|
if (!mounted) return;
|
|
setState(() => _audioStats = s);
|
|
} catch (_) {}
|
|
});
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_eventsSub?.cancel();
|
|
_statsTimer?.cancel();
|
|
_hostCtl.dispose();
|
|
_nickCtl.dispose();
|
|
_passwordCtl.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
Future<void> _onConnect({
|
|
String? host,
|
|
String? nickname,
|
|
String? password,
|
|
}) async {
|
|
setState(() {
|
|
_phase = _Phase.connecting;
|
|
_error = null;
|
|
_snapshot = null;
|
|
});
|
|
try {
|
|
final snap = await rust.connect(
|
|
host: (host ?? _hostCtl.text).trim(),
|
|
nickname: (nickname ?? _nickCtl.text).trim(),
|
|
password: password ?? _passwordCtl.text,
|
|
);
|
|
if (!mounted) return;
|
|
setState(() {
|
|
_phase = _Phase.connected;
|
|
_snapshot = snap;
|
|
});
|
|
} catch (e) {
|
|
if (!mounted) return;
|
|
setState(() {
|
|
_phase = _Phase.idle;
|
|
_error = e.toString();
|
|
});
|
|
}
|
|
}
|
|
|
|
// ignore: unused_element
|
|
Future<void> _setPtt(bool active) async {
|
|
try {
|
|
await rust.setPtt(active: active);
|
|
} catch (e) {
|
|
if (!mounted) return;
|
|
setState(() => _error = e.toString());
|
|
}
|
|
}
|
|
|
|
// ignore: unused_element
|
|
Future<void> _toggleInputMute() async {
|
|
final next = !_inputMuted;
|
|
try {
|
|
await rust.setInputMuted(muted: next);
|
|
if (!mounted) return;
|
|
setState(() => _inputMuted = next);
|
|
} catch (e) {
|
|
if (!mounted) return;
|
|
setState(() => _error = e.toString());
|
|
}
|
|
}
|
|
|
|
Future<void> _toggleOutputMute() async {
|
|
final next = !_outputMuted;
|
|
try {
|
|
await rust.setOutputMuted(muted: next);
|
|
if (!mounted) return;
|
|
setState(() => _outputMuted = next);
|
|
} catch (e) {
|
|
if (!mounted) return;
|
|
setState(() => _error = e.toString());
|
|
}
|
|
}
|
|
|
|
// ignore: unused_element
|
|
Future<void> _setOutputGain(double value) async {
|
|
setState(() => _outputGain = value);
|
|
try {
|
|
await rust.setOutputGain(gain: value);
|
|
} catch (_) {
|
|
// Audio may not be started yet — that's fine; the next start_audio
|
|
// picks up the current slider position next time we plumb it.
|
|
}
|
|
}
|
|
|
|
Future<void> _onJoinChannel(rust.BridgeChannel ch) async {
|
|
final l10n = AppL10n.of(context);
|
|
final messenger = ScaffoldMessenger.of(context);
|
|
String? password;
|
|
// Heuristic: a channel name annotated with a lock prompts.
|
|
if (ch.name.contains('🔒') || ch.name.toLowerCase().contains('password')) {
|
|
password = await _askChannelPassword(l10n);
|
|
if (password == null) return; // cancelled
|
|
}
|
|
try {
|
|
await rust.voiceJoin(
|
|
channelId: ch.id,
|
|
password: password ?? '',
|
|
);
|
|
if (!mounted) return;
|
|
setState(() => _currentVoiceChannelId = ch.id);
|
|
} catch (e) {
|
|
if (!mounted) return;
|
|
// Surface as a SnackBar so the user sees it even while
|
|
// connected (the persistent _error string lives in the
|
|
// pre-connect area and is hidden post-connect). The message
|
|
// is selected by TS3 error code per the canonical
|
|
// catalogue at https://github.com/ReSpeak/tsdeclarations.
|
|
final message = _channelJoinErrorMessage(l10n, e);
|
|
messenger.showSnackBar(
|
|
SnackBar(
|
|
content: Text(message),
|
|
behavior: SnackBarBehavior.floating,
|
|
duration: const Duration(seconds: 4),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Map a `voiceJoin` error to a localised user-facing message.
|
|
/// Recognises the typed `BridgeError.serverRejected` variant
|
|
/// (with a TS3 error code) and falls back to a generic message
|
|
/// for everything else.
|
|
String _channelJoinErrorMessage(AppL10n l10n, Object e) {
|
|
if (e is rust_err.BridgeError) {
|
|
return e.when(
|
|
invalidCommand: (msg) => l10n.channelJoinFailedGeneric(msg),
|
|
dnsFailed: (host, reason) =>
|
|
l10n.channelJoinFailedGeneric('$host: $reason'),
|
|
connection: (msg) => l10n.channelJoinFailedGeneric(msg),
|
|
notConnected: () => l10n.channelJoinFailedGeneric('not connected'),
|
|
alreadyConnected: () => l10n.channelJoinFailedGeneric('already connected'),
|
|
serverRejected: (code, message) {
|
|
// Canonical TS3 error codes per ReSpeak/tsdeclarations
|
|
// Errors.csv.
|
|
switch (code) {
|
|
case 0x0001:
|
|
// Our sentinel for "snapshot poll didn't confirm".
|
|
return l10n.channelJoinFailedTimeout;
|
|
case 0x0a08: // permissions_client_insufficient
|
|
return l10n.channelJoinFailedPermission;
|
|
case 0x030d: // channel_invalid_password
|
|
return l10n.channelJoinFailedPassword;
|
|
case 0x0309: // channel_maxclients_reached
|
|
return l10n.channelJoinFailedFull;
|
|
case 0x030a: // channel_maxfamily_reached
|
|
return l10n.channelJoinFailedFamilyFull;
|
|
case 0x030e: // channel_is_private_channel
|
|
return l10n.channelJoinFailedPrivate;
|
|
default:
|
|
return l10n.channelJoinFailedGeneric(message);
|
|
}
|
|
},
|
|
unmapped: (msg) => l10n.channelJoinFailedGeneric(msg),
|
|
);
|
|
}
|
|
return l10n.channelJoinFailedGeneric(e.toString());
|
|
}
|
|
|
|
// ignore: unused_element
|
|
Future<void> _onLeaveVoice() async {
|
|
try {
|
|
await rust.voiceLeave();
|
|
if (!mounted) return;
|
|
setState(() => _currentVoiceChannelId = null);
|
|
} catch (e) {
|
|
if (!mounted) return;
|
|
setState(() => _error = e.toString());
|
|
}
|
|
}
|
|
|
|
Future<void> _onToggleHardMute() async {
|
|
final next = !_hardMute;
|
|
try {
|
|
// Hard-mute is two coordinated effects:
|
|
// * setHardMute — local TransmitGate clamp; we stop sending
|
|
// Opus frames the instant this returns.
|
|
// * setInputMuted — server-side ClientMuted flag so other
|
|
// clients see the mic-off icon next to our name and the
|
|
// server stops relaying any in-flight frames.
|
|
// Sending only one of them is user-confusing; clients see
|
|
// silence but no icon, or icon but a beat of audio leaks
|
|
// through. Drive them together.
|
|
await rust.setHardMute(muted: next);
|
|
await rust.setInputMuted(muted: next);
|
|
if (!mounted) return;
|
|
setState(() => _inputMuted = next);
|
|
} catch (e) {
|
|
if (!mounted) return;
|
|
setState(() => _error = e.toString());
|
|
}
|
|
}
|
|
|
|
Future<void> _onOpenVoiceSettings() async {
|
|
final result = await showDialog<VoiceSettingsResult>(
|
|
context: context,
|
|
builder: (ctx) => VoiceSettingsDialog(
|
|
initialMode: _transmitMode,
|
|
initialReleaseTailMs: _releaseTailMs,
|
|
),
|
|
);
|
|
if (result == null) return;
|
|
try {
|
|
await rust.setTransmitMode(mode: result.mode);
|
|
await rust.setReleaseTailMs(ms: result.releaseTailMs);
|
|
} catch (e) {
|
|
if (!mounted) return;
|
|
setState(() => _error = e.toString());
|
|
}
|
|
if (result.bindKeyRequested && mounted) {
|
|
await _onConfigurePtt(context);
|
|
}
|
|
}
|
|
|
|
Future<String?> _askChannelPassword(AppL10n l10n) async {
|
|
final ctl = TextEditingController();
|
|
final result = await showDialog<String>(
|
|
context: context,
|
|
builder: (ctx) => AlertDialog(
|
|
title: Text(l10n.channelPasswordTitle),
|
|
content: TextField(
|
|
controller: ctl,
|
|
obscureText: true,
|
|
autofocus: true,
|
|
decoration: InputDecoration(labelText: l10n.fieldPassword),
|
|
),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () => Navigator.of(ctx).pop(),
|
|
child: Text(l10n.closeAction),
|
|
),
|
|
FilledButton(
|
|
onPressed: () => Navigator.of(ctx).pop(ctl.text),
|
|
child: Text(l10n.connectAction),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
ctl.dispose();
|
|
return result;
|
|
}
|
|
|
|
Future<void> _onRefresh() async {
|
|
try {
|
|
final snap = await rust.snapshot();
|
|
if (!mounted) return;
|
|
setState(() => _snapshot = snap);
|
|
} catch (e) {
|
|
if (!mounted) return;
|
|
setState(() => _error = e.toString());
|
|
}
|
|
}
|
|
|
|
Future<void> _onDisconnect() async {
|
|
_statsTimer?.cancel();
|
|
_statsTimer = null;
|
|
try {
|
|
await rust.disconnect();
|
|
} catch (_) {}
|
|
if (!mounted) return;
|
|
setState(() {
|
|
_phase = _Phase.idle;
|
|
_snapshot = null;
|
|
_audioStarted = false;
|
|
_audioStats = null;
|
|
_error = null;
|
|
_inputMuted = false;
|
|
_outputMuted = false;
|
|
_inChannel = false;
|
|
_currentVoiceChannelId = null;
|
|
});
|
|
}
|
|
|
|
String _currentVoiceChannelName() {
|
|
final id = _currentVoiceChannelId;
|
|
final snap = _snapshot;
|
|
if (id == null || snap == null) return '';
|
|
for (final ch in snap.channels) {
|
|
if (ch.id == id) return ch.name;
|
|
}
|
|
return '';
|
|
}
|
|
|
|
Future<void> _onShowDiagnostics(BuildContext context) async {
|
|
final l10n = AppL10n.of(context);
|
|
final text = rust.exportDiagnostics();
|
|
if (!mounted) return;
|
|
await showDialog<void>(
|
|
context: context,
|
|
builder: (ctx) => AlertDialog(
|
|
title: Text(l10n.diagnosticsAction),
|
|
content: SingleChildScrollView(
|
|
child: SelectableText(
|
|
text,
|
|
style: const TextStyle(fontFamily: 'monospace', fontSize: 11),
|
|
),
|
|
),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () async {
|
|
await Clipboard.setData(ClipboardData(text: text));
|
|
if (!ctx.mounted) return;
|
|
Navigator.of(ctx).pop();
|
|
},
|
|
child: Text(l10n.copyAction),
|
|
),
|
|
TextButton(
|
|
onPressed: () => Navigator.of(ctx).pop(),
|
|
child: Text(l10n.closeAction),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Future<void> _onConfigurePtt(BuildContext context) async {
|
|
// On the Linux GNOME-Wayland portal backend, the portal hosts
|
|
// its own system-managed binding dialog (gen2 v0.9.3 / Q3a).
|
|
// Skip the in-app capture dialog entirely on that backend and
|
|
// delegate to the portal via `setPttBinding` with a sentinel
|
|
// platform_key. Show a SnackBar so the user isn't surprised
|
|
// when their compositor opens a separate dialog.
|
|
final l10n = AppL10n.of(context);
|
|
if (!mounted) return;
|
|
if (_pttBackendId == 'gnome-wayland-portal') {
|
|
try {
|
|
final messenger = ScaffoldMessenger.of(context);
|
|
messenger.showSnackBar(SnackBar(
|
|
content: Text(l10n.pttConfigurePortalRedirect),
|
|
));
|
|
await rust.setPttBinding(
|
|
inputClass: rust.BridgePttInputClass.keyboard,
|
|
platformKey: 'portal',
|
|
);
|
|
} catch (e) {
|
|
if (!mounted) return;
|
|
final messenger = ScaffoldMessenger.of(this.context);
|
|
messenger.showSnackBar(SnackBar(
|
|
content: Text(l10n.statusError(e.toString())),
|
|
));
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Other backends: open the in-app focus-scoped capture
|
|
// dialog. The bridge carries only the coarse input class and
|
|
// an opaque platform-key string; the actual key value never
|
|
// appears in any log record (DEC-027 / SRS-202).
|
|
final binding = await showDialog<_CapturedBinding>(
|
|
context: context,
|
|
builder: (ctx) => const _PttBindingCaptureDialog(),
|
|
);
|
|
if (binding == null) return;
|
|
try {
|
|
await rust.setPttBinding(
|
|
inputClass: binding.inputClass,
|
|
platformKey: binding.platformKey,
|
|
);
|
|
// Cache the captured label so the badge can surface "Key:
|
|
// Space" / "Key: Mouse4" next to the capability descriptor.
|
|
// The bridge holds the authoritative binding; this is only
|
|
// for display continuity until the next app restart.
|
|
if (mounted) {
|
|
setState(() {
|
|
_pttBoundKeyLabel = binding.platformKey;
|
|
});
|
|
}
|
|
} catch (e) {
|
|
if (!mounted) return;
|
|
// Surface the failure as a snackbar so the user sees that
|
|
// their binding did not stick.
|
|
if (!mounted) return;
|
|
// Use the State's context (guaranteed valid because we
|
|
// re-checked `mounted` immediately above).
|
|
final messenger = ScaffoldMessenger.of(this.context);
|
|
messenger.showSnackBar(SnackBar(
|
|
content: Text(l10n.statusError(e.toString())),
|
|
));
|
|
}
|
|
}
|
|
|
|
Future<void> _onShowAbout(BuildContext context) async {
|
|
// DEC-018 / DEC-019 / DEC-020 surface: public name, non-
|
|
// affiliation statement, dual-license declaration. The Flutter
|
|
// showAboutDialog widget is intentionally bare so the legal
|
|
// text comes from us, not a framework default.
|
|
final l10n = AppL10n.of(context);
|
|
final theme = Theme.of(context);
|
|
if (!mounted) return;
|
|
await showDialog<void>(
|
|
context: context,
|
|
builder: (ctx) => AlertDialog(
|
|
title: Text(l10n.aboutAction),
|
|
content: SingleChildScrollView(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Text(
|
|
l10n.appTitle,
|
|
style: theme.textTheme.titleLarge,
|
|
),
|
|
const SizedBox(height: 4),
|
|
Text(
|
|
l10n.aboutVersion(_kAppVersion),
|
|
style: theme.textTheme.bodySmall,
|
|
),
|
|
const SizedBox(height: 16),
|
|
Text(
|
|
l10n.aboutNonAffiliation,
|
|
style: theme.textTheme.bodyMedium,
|
|
),
|
|
const SizedBox(height: 12),
|
|
Text(
|
|
l10n.aboutLicenseHeading,
|
|
style: theme.textTheme.titleSmall,
|
|
),
|
|
const SizedBox(height: 4),
|
|
Text(
|
|
l10n.aboutLicenseBody,
|
|
style: theme.textTheme.bodySmall,
|
|
),
|
|
const SizedBox(height: 12),
|
|
Text(
|
|
l10n.aboutThirdPartyHeading,
|
|
style: theme.textTheme.titleSmall,
|
|
),
|
|
const SizedBox(height: 4),
|
|
Text(
|
|
l10n.aboutThirdPartyBody,
|
|
style: theme.textTheme.bodySmall,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () => Navigator.of(ctx).pop(),
|
|
child: Text(l10n.closeAction),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Future<void> _onAddCurrentBookmark() async {
|
|
final l10n = AppL10n.of(context);
|
|
final nameCtl = TextEditingController(text: _hostCtl.text.trim());
|
|
final name = await showDialog<String>(
|
|
context: context,
|
|
builder: (ctx) => AlertDialog(
|
|
title: Text(l10n.bookmarkAddTitle),
|
|
content: TextField(
|
|
controller: nameCtl,
|
|
autofocus: true,
|
|
decoration: InputDecoration(labelText: l10n.fieldDisplayName),
|
|
),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () => Navigator.of(ctx).pop(),
|
|
child: Text(l10n.closeAction),
|
|
),
|
|
FilledButton(
|
|
onPressed: () => Navigator.of(ctx).pop(nameCtl.text),
|
|
child: Text(l10n.bookmarkAddAction),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
nameCtl.dispose();
|
|
if (name == null || name.trim().isEmpty) return;
|
|
try {
|
|
await rust.addBookmark(
|
|
b: rust.BridgeBookmark(
|
|
id: 0,
|
|
displayName: name.trim(),
|
|
host: _hostCtl.text.trim(),
|
|
nickname: _nickCtl.text.trim(),
|
|
password: _passwordCtl.text,
|
|
),
|
|
);
|
|
await _reloadBookmarks();
|
|
} catch (e) {
|
|
if (!mounted) return;
|
|
setState(() => _error = e.toString());
|
|
}
|
|
}
|
|
|
|
Future<void> _onDeleteBookmark(rust.BridgeBookmark b) async {
|
|
try {
|
|
await rust.deleteBookmark(id: b.id);
|
|
await _reloadBookmarks();
|
|
} catch (e) {
|
|
if (!mounted) return;
|
|
setState(() => _error = e.toString());
|
|
}
|
|
}
|
|
|
|
Future<void> _onUseBookmark(rust.BridgeBookmark b) async {
|
|
_hostCtl.text = b.host;
|
|
_nickCtl.text = b.nickname;
|
|
_passwordCtl.text = b.password;
|
|
await _onConnect(
|
|
host: b.host,
|
|
nickname: b.nickname,
|
|
password: b.password,
|
|
);
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final l10n = AppL10n.of(context);
|
|
final theme = Theme.of(context);
|
|
|
|
String statusText() {
|
|
switch (_phase) {
|
|
case _Phase.idle:
|
|
return _error != null ? l10n.statusError(_error!) : l10n.statusIdle;
|
|
case _Phase.connecting:
|
|
return l10n.statusConnecting;
|
|
case _Phase.connected:
|
|
return l10n.statusConnected(_snapshot?.serverName ?? '');
|
|
}
|
|
}
|
|
|
|
return Scaffold(
|
|
appBar: AppBar(
|
|
title: Text(l10n.appTitle),
|
|
actions: [
|
|
IconButton(
|
|
tooltip: l10n.aboutAction,
|
|
icon: const Icon(Icons.info_outline),
|
|
onPressed: () => _onShowAbout(context),
|
|
),
|
|
IconButton(
|
|
tooltip: l10n.diagnosticsAction,
|
|
icon: const Icon(Icons.bug_report_outlined),
|
|
onPressed: () => _onShowDiagnostics(context),
|
|
),
|
|
if (_phase == _Phase.connected) ...[
|
|
IconButton(
|
|
tooltip: l10n.refreshAction,
|
|
icon: const Icon(Icons.refresh),
|
|
onPressed: _onRefresh,
|
|
),
|
|
IconButton(
|
|
tooltip: l10n.disconnectAction,
|
|
icon: const Icon(Icons.logout),
|
|
onPressed: _onDisconnect,
|
|
),
|
|
],
|
|
],
|
|
),
|
|
body: Padding(
|
|
padding: const EdgeInsets.all(16),
|
|
child: LayoutBuilder(
|
|
builder: (ctx, bodyConstraints) {
|
|
// Shared breakpoint with the inner snapshot LayoutBuilder
|
|
// below — when the UI is wide enough to split into two
|
|
// columns AND we are showing the snapshot, the
|
|
// not-production-ready banner is moved into the left
|
|
// column above the Voice Bar so it doesn't span the
|
|
// wider channel-tree area. In every other state (narrow,
|
|
// or idle / connecting at any width) the banner stays
|
|
// pinned to the top of the body.
|
|
const wideBreakpoint = 840.0;
|
|
final isWideSnapshot =
|
|
bodyConstraints.maxWidth >= wideBreakpoint &&
|
|
_phase == _Phase.connected &&
|
|
_snapshot != null;
|
|
final banner = Container(
|
|
padding: const EdgeInsets.all(10),
|
|
decoration: BoxDecoration(
|
|
color: theme.colorScheme.tertiaryContainer,
|
|
borderRadius: BorderRadius.circular(8),
|
|
),
|
|
child: Text(
|
|
l10n.homeNotProductionReadyBanner,
|
|
style: TextStyle(color: theme.colorScheme.onTertiaryContainer),
|
|
),
|
|
);
|
|
return Column(
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: [
|
|
if (!isWideSnapshot) ...[
|
|
banner,
|
|
const SizedBox(height: 12),
|
|
],
|
|
Text(statusText(), style: theme.textTheme.titleMedium),
|
|
if (_lostReason != null || _reconnectAttempt != null) ...[
|
|
const SizedBox(height: 8),
|
|
Container(
|
|
padding: const EdgeInsets.all(10),
|
|
decoration: BoxDecoration(
|
|
color: theme.colorScheme.errorContainer,
|
|
borderRadius: BorderRadius.circular(8),
|
|
),
|
|
child: Row(
|
|
children: [
|
|
SizedBox(
|
|
width: 16,
|
|
height: 16,
|
|
child: CircularProgressIndicator(
|
|
strokeWidth: 2,
|
|
color: theme.colorScheme.onErrorContainer,
|
|
),
|
|
),
|
|
const SizedBox(width: 10),
|
|
Expanded(
|
|
child: Text(
|
|
_reconnectAttempt != null
|
|
? l10n.statusReconnecting(
|
|
_reconnectAttempt!,
|
|
_reconnectDelay ?? 0,
|
|
)
|
|
: l10n.statusConnectionLost(_lostReason ?? ''),
|
|
style: TextStyle(
|
|
color: theme.colorScheme.onErrorContainer,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
const SizedBox(height: 12),
|
|
if (_phase == _Phase.idle) ...[
|
|
Expanded(
|
|
child: SingleChildScrollView(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: [
|
|
_ConnectForm(
|
|
hostCtl: _hostCtl,
|
|
nickCtl: _nickCtl,
|
|
passwordCtl: _passwordCtl,
|
|
onConnect: () => _onConnect(),
|
|
onAddBookmark: _onAddCurrentBookmark,
|
|
),
|
|
const SizedBox(height: 16),
|
|
_BookmarkList(
|
|
bookmarks: _bookmarks,
|
|
onConnect: _onUseBookmark,
|
|
onDelete: _onDeleteBookmark,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
] else if (_phase == _Phase.connecting) ...[
|
|
const Center(
|
|
child: Padding(
|
|
padding: EdgeInsets.all(32),
|
|
child: CircularProgressIndicator(),
|
|
),
|
|
),
|
|
] else if (_phase == _Phase.connected && _snapshot != null) ...[
|
|
Expanded(
|
|
child: LayoutBuilder(
|
|
builder: (ctx, constraints) {
|
|
final voiceBar = VoiceBar(
|
|
inChannel: _inChannel,
|
|
transmitMode: _transmitMode,
|
|
hardMute: _hardMute,
|
|
outputMuted: _outputMuted,
|
|
releaseTailMs: _releaseTailMs,
|
|
channelName: _currentVoiceChannelName(),
|
|
audioStats: _audioStats,
|
|
pttLevel: _pttLevel,
|
|
pttBackendId: _pttBackendId,
|
|
pttBoundInputClass: _pttBoundInputClass,
|
|
pttBoundKeyLabel: _pttBoundKeyLabel,
|
|
onToggleMute: _onToggleHardMute,
|
|
onToggleOutputMute: _toggleOutputMute,
|
|
onConfigure: _onOpenVoiceSettings,
|
|
);
|
|
final snapshotView = _SnapshotView(
|
|
snapshot: _snapshot!,
|
|
onJoinChannel: _onJoinChannel,
|
|
);
|
|
// Responsive: at <840 dp use a stacked layout
|
|
// (Voice Bar on top, channel tree below). At
|
|
// ≥840 dp use a side-by-side layout with the
|
|
// Voice Bar pinned to 320 dp on the left and
|
|
// the channel tree expanding on the right.
|
|
// 840 dp matches Material's tablet / desktop
|
|
// breakpoint.
|
|
const wideBreakpoint = 840.0;
|
|
const voiceBarWidthWide = 320.0;
|
|
if (constraints.maxWidth >= wideBreakpoint) {
|
|
return Row(
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: [
|
|
SizedBox(
|
|
width: voiceBarWidthWide,
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: [
|
|
banner,
|
|
const SizedBox(height: 12),
|
|
voiceBar,
|
|
],
|
|
),
|
|
),
|
|
const SizedBox(width: 12),
|
|
Expanded(child: snapshotView),
|
|
],
|
|
);
|
|
}
|
|
return Column(
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: [
|
|
voiceBar,
|
|
const SizedBox(height: 12),
|
|
Expanded(child: snapshotView),
|
|
],
|
|
);
|
|
},
|
|
),
|
|
),
|
|
],
|
|
],
|
|
);
|
|
},
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _ConnectForm extends StatelessWidget {
|
|
const _ConnectForm({
|
|
required this.hostCtl,
|
|
required this.nickCtl,
|
|
required this.passwordCtl,
|
|
required this.onConnect,
|
|
required this.onAddBookmark,
|
|
});
|
|
|
|
final TextEditingController hostCtl;
|
|
final TextEditingController nickCtl;
|
|
final TextEditingController passwordCtl;
|
|
final VoidCallback onConnect;
|
|
final VoidCallback onAddBookmark;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final l10n = AppL10n.of(context);
|
|
return Column(
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: [
|
|
TextField(
|
|
controller: hostCtl,
|
|
decoration: InputDecoration(
|
|
labelText: l10n.fieldServerHost,
|
|
border: const OutlineInputBorder(),
|
|
),
|
|
),
|
|
const SizedBox(height: 8),
|
|
TextField(
|
|
controller: nickCtl,
|
|
decoration: InputDecoration(
|
|
labelText: l10n.fieldNickname,
|
|
border: const OutlineInputBorder(),
|
|
),
|
|
),
|
|
const SizedBox(height: 8),
|
|
TextField(
|
|
controller: passwordCtl,
|
|
obscureText: true,
|
|
decoration: InputDecoration(
|
|
labelText: l10n.fieldServerPassword,
|
|
helperText: l10n.fieldServerPasswordHelp,
|
|
border: const OutlineInputBorder(),
|
|
),
|
|
),
|
|
const SizedBox(height: 16),
|
|
Row(
|
|
children: [
|
|
Expanded(
|
|
child: FilledButton.icon(
|
|
icon: const Icon(Icons.login),
|
|
label: Text(l10n.connectAction),
|
|
onPressed: onConnect,
|
|
),
|
|
),
|
|
const SizedBox(width: 8),
|
|
OutlinedButton.icon(
|
|
icon: const Icon(Icons.bookmark_add_outlined),
|
|
label: Text(l10n.bookmarkAddAction),
|
|
onPressed: onAddBookmark,
|
|
),
|
|
],
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
class _BookmarkList extends StatelessWidget {
|
|
const _BookmarkList({
|
|
required this.bookmarks,
|
|
required this.onConnect,
|
|
required this.onDelete,
|
|
});
|
|
|
|
final List<rust.BridgeBookmark> bookmarks;
|
|
final ValueChanged<rust.BridgeBookmark> onConnect;
|
|
final ValueChanged<rust.BridgeBookmark> onDelete;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final l10n = AppL10n.of(context);
|
|
final theme = Theme.of(context);
|
|
if (bookmarks.isEmpty) {
|
|
return Padding(
|
|
padding: const EdgeInsets.symmetric(vertical: 8),
|
|
child: Text(
|
|
l10n.bookmarksEmpty,
|
|
style: theme.textTheme.bodySmall,
|
|
),
|
|
);
|
|
}
|
|
return Column(
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: [
|
|
Text(l10n.bookmarksHeading, style: theme.textTheme.titleSmall),
|
|
const SizedBox(height: 4),
|
|
for (final b in bookmarks)
|
|
Card(
|
|
margin: const EdgeInsets.symmetric(vertical: 4),
|
|
child: ListTile(
|
|
title: Text(b.displayName),
|
|
subtitle: Text('${b.host} — ${b.nickname}'),
|
|
trailing: Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
IconButton(
|
|
icon: const Icon(Icons.login),
|
|
tooltip: l10n.connectAction,
|
|
onPressed: () => onConnect(b),
|
|
),
|
|
IconButton(
|
|
icon: const Icon(Icons.delete_outline),
|
|
tooltip: l10n.bookmarkDeleteAction,
|
|
onPressed: () => onDelete(b),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
|
|
/// PTT capability badge (gen2 v0.9.3 / SDD-091).
|
|
///
|
|
/// Renders the active PTT level + backend in the Voice Bar so the
|
|
/// user understands which input path is in effect. When the
|
|
/// resolved capability is `L0Focused` an info icon appears that
|
|
/// opens a per-platform explanation sheet describing why Global
|
|
/// PTT is not active and what the user can do to engage it.
|
|
///
|
|
/// Driven by the `BridgeEvent::PttCapability` stream published by
|
|
/// the `PttController` (SDD-088). The `_BetaHomeState` listener
|
|
/// updates the props on each transition.
|
|
class PttCapabilityBadge extends StatelessWidget {
|
|
/// Construct a badge.
|
|
const PttCapabilityBadge({
|
|
super.key,
|
|
required this.level,
|
|
required this.backendId,
|
|
required this.boundInputClass,
|
|
});
|
|
|
|
/// Resolved capability level as the bridge emits it
|
|
/// (`L0Focused` / `L1WindowsHook` / `L2WindowsRawInput` /
|
|
/// `L1MacOSEventTap` / `L1LinuxGnomeWaylandPortal`).
|
|
final String level;
|
|
|
|
/// Stable backend identifier (`focused`, `windows-raw-input`, …).
|
|
final String backendId;
|
|
|
|
/// Privacy-safe input class (`keyboard`, `mouse-side-button`,
|
|
/// or empty when no binding is set).
|
|
final String boundInputClass;
|
|
|
|
bool get _isFocused => level == 'L0Focused';
|
|
|
|
String _explainBodyForPlatform(AppL10n l10n) {
|
|
// Use `defaultTargetPlatform` rather than `Theme.of(context).platform`
|
|
// because the latter is influenced by debug platform overrides
|
|
// that callers may toggle in dev mode. We want the badge's
|
|
// explanation to match the actual host OS.
|
|
switch (defaultTargetPlatform) {
|
|
case TargetPlatform.windows:
|
|
return l10n.pttCapabilityExplainGoGlobalWindows;
|
|
case TargetPlatform.macOS:
|
|
return l10n.pttCapabilityExplainGoGlobalMacos;
|
|
case TargetPlatform.linux:
|
|
return l10n.pttCapabilityExplainGoGlobalLinux;
|
|
default:
|
|
return l10n.pttCapabilityExplainGoGlobalGeneric;
|
|
}
|
|
}
|
|
|
|
void _openExplanationSheet(BuildContext context) {
|
|
final l10n = AppL10n.of(context);
|
|
showModalBottomSheet<void>(
|
|
context: context,
|
|
showDragHandle: true,
|
|
builder: (sheetContext) {
|
|
final theme = Theme.of(sheetContext);
|
|
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),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
},
|
|
);
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final l10n = AppL10n.of(context);
|
|
final theme = Theme.of(context);
|
|
final badgeLabel = l10n.pttCapabilityBadge(level, backendId);
|
|
final tooltipMessage = boundInputClass.isEmpty
|
|
? badgeLabel
|
|
: '$badgeLabel\n($boundInputClass)';
|
|
return Padding(
|
|
padding: const EdgeInsets.only(bottom: 6),
|
|
child: Tooltip(
|
|
message: tooltipMessage,
|
|
child: Row(
|
|
children: [
|
|
Icon(
|
|
_isFocused ? Icons.crop_free : Icons.public,
|
|
size: 14,
|
|
color: theme.colorScheme.onSurfaceVariant,
|
|
),
|
|
const SizedBox(width: 4),
|
|
Expanded(
|
|
child: Text(
|
|
badgeLabel,
|
|
style: theme.textTheme.bodySmall?.copyWith(
|
|
color: theme.colorScheme.onSurfaceVariant,
|
|
),
|
|
),
|
|
),
|
|
// Info icon only for L0Focused — the explanation sheet
|
|
// tells the user why their PTT may not work outside the
|
|
// app window and how to grant the permission. There is
|
|
// intentionally NO 'Configure' button here: the single
|
|
// configuration entry point is the Voice Bar's
|
|
// settings gear (onConfigure on `VoiceBar`). Having two
|
|
// identical bind-key entry points just confuses users.
|
|
if (_isFocused)
|
|
IconButton(
|
|
icon: const Icon(Icons.info_outline, size: 16),
|
|
tooltip: l10n.pttCapabilityExplainTitle,
|
|
visualDensity: VisualDensity.compact,
|
|
onPressed: () => _openExplanationSheet(context),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _SnapshotView extends StatelessWidget {
|
|
const _SnapshotView({
|
|
required this.snapshot,
|
|
required this.onJoinChannel,
|
|
});
|
|
|
|
final rust.BridgeSnapshot snapshot;
|
|
final ValueChanged<rust.BridgeChannel> onJoinChannel;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final l10n = AppL10n.of(context);
|
|
final theme = Theme.of(context);
|
|
|
|
// Channels arrive pre-sorted from the bridge: the Rust
|
|
// adapter (chanora_protocol::adapter::sort_channels_tree)
|
|
// emits them in root-first depth-first order following the
|
|
// TeamSpeak linked-list `order` predecessor pointers. We
|
|
// therefore trust the server order verbatim — re-sorting by
|
|
// `order` numerically here would re-introduce the bug fixed
|
|
// in that adapter (TS3 `order` is NOT a numeric rank).
|
|
final channels = snapshot.channels;
|
|
|
|
// Compute each channel's depth in the parent hierarchy so we
|
|
// can render it indented. Root channels (parent == 0) are
|
|
// depth 0; their children depth 1; and so on. Depth caps at
|
|
// 6 to keep the indent visually bounded on deeply-nested
|
|
// servers (the cap plateaus silently — no glyph; the channel
|
|
// is still tappable and its real depth lives in the data).
|
|
final depthById = <BigInt, int>{};
|
|
final zero = BigInt.zero;
|
|
for (final ch in channels) {
|
|
if (ch.parent == zero) {
|
|
depthById[ch.id] = 0;
|
|
} else {
|
|
final parentDepth = depthById[ch.parent] ?? 0;
|
|
depthById[ch.id] = (parentDepth + 1).clamp(0, 6);
|
|
}
|
|
}
|
|
const indentPerLevel = 18.0;
|
|
|
|
final byChannel = <BigInt, List<rust.BridgeClient>>{};
|
|
for (final c in snapshot.clients) {
|
|
byChannel.putIfAbsent(c.channel, () => []).add(c);
|
|
}
|
|
|
|
return ListView(
|
|
children: [
|
|
Text(
|
|
l10n.countChannelsAndClients(
|
|
snapshot.channels.length,
|
|
snapshot.clients.length,
|
|
),
|
|
style: theme.textTheme.bodyMedium,
|
|
),
|
|
if (snapshot.welcomeMessage.isNotEmpty) ...[
|
|
const SizedBox(height: 8),
|
|
Container(
|
|
padding: const EdgeInsets.all(8),
|
|
decoration: BoxDecoration(
|
|
color: theme.colorScheme.surfaceContainerHighest,
|
|
borderRadius: BorderRadius.circular(6),
|
|
),
|
|
child: Text(snapshot.welcomeMessage, style: theme.textTheme.bodySmall),
|
|
),
|
|
],
|
|
const Divider(height: 24),
|
|
Text(l10n.channelsHeading, style: theme.textTheme.titleMedium),
|
|
const SizedBox(height: 4),
|
|
for (final ch in channels) ...[
|
|
Padding(
|
|
padding: EdgeInsets.only(
|
|
left: (depthById[ch.id] ?? 0) * indentPerLevel,
|
|
),
|
|
child: ListTile(
|
|
dense: true,
|
|
leading: const Icon(Icons.tag),
|
|
title: Text(ch.name),
|
|
subtitle: Text('id=${ch.id} parent=${ch.parent}'),
|
|
trailing: IconButton(
|
|
icon: const Icon(Icons.login),
|
|
tooltip: l10n.joinChannelAction,
|
|
onPressed: () => onJoinChannel(ch),
|
|
),
|
|
onTap: () => onJoinChannel(ch),
|
|
),
|
|
),
|
|
for (final cl in byChannel[ch.id] ?? const <rust.BridgeClient>[])
|
|
Padding(
|
|
padding: EdgeInsets.only(
|
|
left: ((depthById[ch.id] ?? 0) * indentPerLevel) + 64,
|
|
),
|
|
child: ListTile(
|
|
dense: true,
|
|
visualDensity: VisualDensity.compact,
|
|
leading: const Icon(Icons.person, size: 18),
|
|
title: Text(cl.name),
|
|
),
|
|
),
|
|
],
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Result of a successful PTT binding capture. Carries only the
|
|
/// coarse input class and an opaque platform-key label —
|
|
/// per DEC-027 the actual key code never crosses out of the
|
|
/// capture dialog.
|
|
class _CapturedBinding {
|
|
const _CapturedBinding({required this.inputClass, required this.platformKey});
|
|
final rust.BridgePttInputClass inputClass;
|
|
final String platformKey;
|
|
}
|
|
|
|
/// Focus-scoped dialog that captures the next key press or mouse
|
|
/// side-button click and returns it as a `_CapturedBinding`. The
|
|
/// captured value is the platform-neutral `LogicalKeyboardKey`
|
|
/// debug label or `"mouse-side-button:{button}"`; the platform
|
|
/// backend interprets the string and never logs it.
|
|
class _PttBindingCaptureDialog extends StatefulWidget {
|
|
const _PttBindingCaptureDialog();
|
|
|
|
@override
|
|
State<_PttBindingCaptureDialog> createState() =>
|
|
_PttBindingCaptureDialogState();
|
|
}
|
|
|
|
class _PttBindingCaptureDialogState extends State<_PttBindingCaptureDialog> {
|
|
final FocusNode _focusNode = FocusNode();
|
|
String? _captured;
|
|
rust.BridgePttInputClass _capturedClass = rust.BridgePttInputClass.none;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
_focusNode.requestFocus();
|
|
});
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_focusNode.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
KeyEventResult _onKeyEvent(FocusNode node, KeyEvent event) {
|
|
if (event is! KeyDownEvent) return KeyEventResult.ignored;
|
|
final label = _displayLabelForKey(event.logicalKey);
|
|
if (label == null) return KeyEventResult.ignored;
|
|
setState(() {
|
|
_captured = label;
|
|
_capturedClass = rust.BridgePttInputClass.keyboard;
|
|
});
|
|
return KeyEventResult.handled;
|
|
}
|
|
|
|
/// Translate a [LogicalKeyboardKey] into the platform-neutral
|
|
/// label string the bridge expects (matching the entries in
|
|
/// `crates/chanora_audio/src/ptt_backends/windows_keymap.rs`).
|
|
///
|
|
/// `LogicalKeyboardKey.keyLabel` returns `" "` for Space, empty
|
|
/// for pure modifiers (shift / ctrl / alt / meta), and localised
|
|
/// strings for some special keys; we normalise to the canonical
|
|
/// English label so the badge displays something readable AND so
|
|
/// the Windows backend's keymap can resolve to a `VK_*`. Pure
|
|
/// modifier keys are intentionally rejected — chording into the
|
|
/// real binding (e.g. Ctrl+Shift+M) is supported by ignoring the
|
|
/// individual modifier-down events.
|
|
String? _displayLabelForKey(LogicalKeyboardKey k) {
|
|
// Whitespace / common control keys whose keyLabel is unhelpful.
|
|
if (k == LogicalKeyboardKey.space) return 'Space';
|
|
if (k == LogicalKeyboardKey.enter || k == LogicalKeyboardKey.numpadEnter) {
|
|
return 'Enter';
|
|
}
|
|
if (k == LogicalKeyboardKey.tab) return 'Tab';
|
|
if (k == LogicalKeyboardKey.escape) return 'Escape';
|
|
if (k == LogicalKeyboardKey.backspace) return 'Backspace';
|
|
if (k == LogicalKeyboardKey.delete) return 'Delete';
|
|
if (k == LogicalKeyboardKey.insert) return 'Insert';
|
|
if (k == LogicalKeyboardKey.home) return 'Home';
|
|
if (k == LogicalKeyboardKey.end) return 'End';
|
|
if (k == LogicalKeyboardKey.pageUp) return 'Page Up';
|
|
if (k == LogicalKeyboardKey.pageDown) return 'Page Down';
|
|
if (k == LogicalKeyboardKey.arrowUp) return 'Arrow Up';
|
|
if (k == LogicalKeyboardKey.arrowDown) return 'Arrow Down';
|
|
if (k == LogicalKeyboardKey.arrowLeft) return 'Arrow Left';
|
|
if (k == LogicalKeyboardKey.arrowRight) return 'Arrow Right';
|
|
// Pure modifier keys are not bindable on their own (user can
|
|
// still chord by pressing a non-modifier while holding them).
|
|
if (k == LogicalKeyboardKey.shift ||
|
|
k == LogicalKeyboardKey.shiftLeft ||
|
|
k == LogicalKeyboardKey.shiftRight ||
|
|
k == LogicalKeyboardKey.control ||
|
|
k == LogicalKeyboardKey.controlLeft ||
|
|
k == LogicalKeyboardKey.controlRight ||
|
|
k == LogicalKeyboardKey.alt ||
|
|
k == LogicalKeyboardKey.altLeft ||
|
|
k == LogicalKeyboardKey.altRight ||
|
|
k == LogicalKeyboardKey.meta ||
|
|
k == LogicalKeyboardKey.metaLeft ||
|
|
k == LogicalKeyboardKey.metaRight ||
|
|
k == LogicalKeyboardKey.capsLock ||
|
|
k == LogicalKeyboardKey.numLock ||
|
|
k == LogicalKeyboardKey.scrollLock) {
|
|
return null;
|
|
}
|
|
// Fall back to keyLabel for letters, digits, function keys,
|
|
// numpad digits, and punctuation. Trim whitespace as a final
|
|
// belt-and-braces guard.
|
|
final fallback = k.keyLabel.trim();
|
|
if (fallback.isEmpty) return null;
|
|
return fallback;
|
|
}
|
|
|
|
void _captureMouseSideButton(int button) {
|
|
setState(() {
|
|
_captured = 'mouse-side-button:$button';
|
|
_capturedClass = rust.BridgePttInputClass.mouseSideButton;
|
|
});
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final l10n = AppL10n.of(context);
|
|
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(
|
|
// HitTestBehavior.opaque so the Listener fires for
|
|
// mouse side-button presses anywhere inside the
|
|
// dialog's bounds, not just on top of a child widget.
|
|
// Default (deferToChild) only delivers events when a
|
|
// child's hit-test claims them; the surrounding padding
|
|
// and Container backgrounds don't, so the user had to
|
|
// hover over the captured-result Container before the
|
|
// side-button click would register. Opaque means the
|
|
// entire dialog content area receives PointerDown
|
|
// events.
|
|
behavior: HitTestBehavior.opaque,
|
|
// Capture mouse side buttons (4 and 5) without
|
|
// capturing primary / secondary clicks which the
|
|
// user uses to interact with the dialog itself. The
|
|
// raw button bitmask values are stable per Flutter's
|
|
// `PointerEvent.buttons` documentation (back = 0x08,
|
|
// forward = 0x10).
|
|
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,
|
|
),
|
|
const SizedBox(height: 12),
|
|
Container(
|
|
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,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () => Navigator.of(context).pop(),
|
|
child: Text(l10n.closeAction),
|
|
),
|
|
FilledButton(
|
|
onPressed: _captured == null
|
|
? null
|
|
: () => Navigator.of(context).pop(
|
|
_CapturedBinding(
|
|
inputClass: _capturedClass,
|
|
platformKey: _captured!,
|
|
),
|
|
),
|
|
child: Text(l10n.pttConfigureSaveAction),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|