Files
chanora/apps/chanora_flutter/lib/main.dart
T
EdisonJwa 82d012a46b feat(ptt): live Linux GNOME-Wayland portal session flow (DEC-025)
Promotes the Linux backend from probe-only to a live
`org.freedesktop.portal.GlobalShortcuts` session, closing the
gen2 v0.9.3 baseline's last Linux-side code item. Both gaps I
flagged on the review pass are addressed:

  * Stop now closes the portal session through the dedicated
    `org.freedesktop.portal.Session` interface (not the
    request-cancel `Request` interface — that would only abort a
    pending Request, not release the bound shortcuts).
  * Ten new unit tests cover `classify_shortcuts_value`,
    `publish_bound`, `publish_l0`, and the `SHORTCUT_ID` stability
    contract using synthesised `OwnedValue` payloads. Live D-Bus
    coverage stays in the `linux_portal_smoke` ignored
    integration test (RR-PTT-004).

Live session lifecycle (gen2 Q5b — lazy, single backend instance):

  1. `start(gate, binding)` spawns one `tokio::spawn` worker that
     owns an async `zbus::Connection` (sharing the bridge's
     tokio runtime per Q4a).
  2. `CreateSession` with fresh random `handle_token` /
     `session_handle_token` tokens. The worker awaits the portal
     `Response` signal via a `RequestProxy` subscription and
     extracts `session_handle` from the results dict.
  3. `BindShortcuts(session_handle, [("chanora-ptt", { description
     = "Chanora push-to-talk" })], "", {})`. The portal opens its
     own system-managed dialog asking the user to choose a key
     — Chanora itself never reads raw key events. The audio
     engine continues at `L0Focused` while the dialog is open;
     the descriptor watch publishes the transition once the
     portal returns.
  4. On `response_code == 0`: classify the `trigger_description`
     substring (heuristic: contains "mouse" -> MouseSideButton,
     else Keyboard), publish `L2GlobalHoldToTalk` (or `L3` for
     mouse) through the watch sender. The raw trigger_description
     string is never logged (DEC-027 / SRS-202).
  5. On `response_code == 1` (cancelled) or `>= 2` (failure):
     publish `L0Focused` through the watch sender. The user can
     retry via the UI "Configure" button (gen2 Q6a).
  6. The worker enters a `tokio::select!` loop multiplexing the
     `cmd_rx` channel (Rebind / Stop) and the `Activated` /
     `Deactivated` signals. Matching signals scoped to this
     session handle and `chanora-ptt` shortcut id drive
     `gate.set(true/false)`.
  7. `Rebind` re-runs `BindShortcuts` on the same session.
  8. `Stop` calls `org.freedesktop.portal.Session.Close()` on
     the session-handle object path, clears the gate, exits.

UX (gen2 Q3a): when `_pttBackendId == 'gnome-wayland-portal'`,
the Flutter "Configure" button skips the in-app
`_PttBindingCaptureDialog` and shows a SnackBar telling the user
their desktop environment will open its own shortcut dialog.
The button delegates to `setPttBinding(keyboard, "portal")`
which nudges the backend; the portal handles the rest. New ARB
key `pttConfigurePortalRedirect` in en + zh-Hans.

Trait surface (cross-cutting):

  * `DesktopPttBackend::descriptor_watch()` is a new trait method
    with a default impl returning a never-firing receiver.
    Backends with async capability transitions (only the Linux
    portal backend today) override it to return the live watch
    sender's receiver.
  * `chanora_core::ChanoraSession::start_audio` subscribes to the
    active backend's `descriptor_watch()` and spawns a forwarder
    task that re-emits `SessionEvent::PttCapability` on every
    transition. The initial value is emitted synchronously.

`Cargo.toml` (Linux-only):

  * `futures-util` (std features, no executor) for stream
    consumption on the portal signal subscriptions.
  * `rand 0.8` for fresh per-process portal tokens.
  * `zbus` continues at v5 with the `tokio` + `blocking-api`
    features.

Tests
-----

  * `chanora_audio` rises from 8 to 18 unit tests. New
    coverage on the Linux module:
      - `classify_returns_none_when_shortcut_id_missing`
      - `classify_returns_keyboard_for_typical_trigger_description`
      - `classify_returns_keyboard_when_trigger_description_missing`
      - `classify_detects_mouse_substring`
      - `classify_is_case_insensitive_on_mouse_substring`
      - `publish_bound_keyboard_publishes_L2_with_keyboard_class`
      - `publish_bound_mouse_publishes_L3`
      - `publish_bound_none_publishes_L2_keyboard_default`
      - `publish_l0_clears_descriptor`
      - `shortcut_id_is_stable`
  * Workspace total: 67 unit + integration tests, all green with
    `CHANORA_DISABLE_KEYRING=1` (was 57 at v1.0.0-rc.4).
  * New `crates/chanora_audio/tests/linux_portal_smoke.rs`
    ignored integration test (RR-PTT-004 evidence path). Run on
    a GNOME-on-Wayland host with
    `cargo test -p chanora_audio --test linux_portal_smoke -- --ignored --nocapture`.

Documentation
-------------

  * `docs/architecture/desktop-ptt-architecture.md` §5.3 rewritten
    to describe the realised lifecycle; v0.9.4 change-history
    entry added.
  * `docs/governance/product-decision-register.md` v0.9.10
    change-history entry recording the code-side promotion. No
    decision rows mutate.
  * `docs/release/release-readiness-go-nogo-record.md` RR-PTT-004
    flipped from `Open` to `Implemented (live trace pending)`;
    v0.9.5 change-history entry.

Verification
------------

  * `cargo test --workspace`: 67/67 green.
  * `cargo deny check`: advisories ok, bans ok, licenses ok,
    sources ok.
  * `cargo about generate --offline`: zero new warnings.
  * `tools/dump_flutter_licenses.sh`: 94 packages, 0 without
    LICENSE.
  * `flutter analyze`: clean.
  * `cargo build -p chanora_bridge --release` +
    `flutter build linux --release`: clean Linux x86_64 bundle.
  * Live portal trace (RR-PTT-004) — **not run**. The dev shell
    is a TTY without a Wayland session. The user will run the
    ignored smoke test from inside a GNOME-on-Wayland session
    when available.

No Windows / macOS / iOS live verification in this commit (hosts
unavailable). The Windows + macOS backend scaffolds remain in
place reporting their target capability honestly; live OS-call
wiring is queued for their respective platform owners'
reference hosts per `docs/governance/staged-release-plan.md`.
2026-05-15 16:43:45 +08:00

1288 lines
40 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/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/frb_generated.dart';
/// Public version string shown in the About dialog. Aligned with
/// `pubspec.yaml` and the git tag for the MVP release candidate.
const String _kAppVersion = 'v1.0.0-rc.1';
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;
bool _audioStarted = false;
rust.BridgeAudioStats? _audioStats;
Timer? _statsTimer;
StreamSubscription<rust.BridgeEvent>? _eventsSub;
String? _lostReason;
int? _reconnectAttempt;
int? _reconnectDelay;
bool _inputMuted = false;
bool _outputMuted = false;
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';
List<rust.BridgeBookmark> _bookmarks = const [];
@override
void initState() {
super.initState();
_eventsSub = rust.eventsStream().listen(_onEvent);
unawaited(_reloadBookmarks());
}
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;
});
}
}
@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();
});
}
}
Future<void> _onStartAudio() async {
try {
await rust.startAudio();
if (!mounted) return;
setState(() => _audioStarted = true);
_statsTimer?.cancel();
_statsTimer = Timer.periodic(const Duration(milliseconds: 500), (_) async {
try {
final s = await rust.audioStats();
if (!mounted) return;
setState(() => _audioStats = s);
} catch (_) {}
});
} catch (e) {
if (!mounted) return;
setState(() => _error = e.toString());
}
}
Future<void> _setPtt(bool active) async {
try {
await rust.setPtt(active: active);
} catch (e) {
if (!mounted) return;
setState(() => _error = e.toString());
}
}
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());
}
}
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);
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.moveToChannel(
channelId: ch.id,
password: password ?? '',
);
} catch (e) {
if (!mounted) return;
setState(() => _error = e.toString());
}
}
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;
});
}
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,
);
} 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: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
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),
),
),
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) ...[
if (!_audioStarted) ...[
FilledButton.icon(
icon: const Icon(Icons.mic_none),
label: Text(l10n.startAudioAction),
onPressed: _onStartAudio,
),
const SizedBox(height: 12),
] else ...[
_AudioControls(
stats: _audioStats,
inputMuted: _inputMuted,
outputMuted: _outputMuted,
outputGain: _outputGain,
pttLevel: _pttLevel,
pttBackendId: _pttBackendId,
pttBoundInputClass: _pttBoundInputClass,
onPttDown: () => _setPtt(true),
onPttUp: () => _setPtt(false),
onToggleInputMute: _toggleInputMute,
onToggleOutputMute: _toggleOutputMute,
onGainChanged: _setOutputGain,
onConfigurePtt: () => _onConfigurePtt(context),
),
const SizedBox(height: 12),
],
Expanded(
child: _SnapshotView(
snapshot: _snapshot!,
onJoinChannel: _onJoinChannel,
),
),
],
],
),
),
);
}
}
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),
),
],
),
),
),
],
);
}
}
class _AudioControls extends StatefulWidget {
const _AudioControls({
required this.stats,
required this.inputMuted,
required this.outputMuted,
required this.outputGain,
required this.pttLevel,
required this.pttBackendId,
required this.pttBoundInputClass,
required this.onPttDown,
required this.onPttUp,
required this.onToggleInputMute,
required this.onToggleOutputMute,
required this.onGainChanged,
required this.onConfigurePtt,
});
final rust.BridgeAudioStats? stats;
final bool inputMuted;
final bool outputMuted;
final double outputGain;
final String pttLevel;
final String pttBackendId;
final String pttBoundInputClass;
final VoidCallback onPttDown;
final VoidCallback onPttUp;
final VoidCallback onToggleInputMute;
final VoidCallback onToggleOutputMute;
final ValueChanged<double> onGainChanged;
final VoidCallback onConfigurePtt;
@override
State<_AudioControls> createState() => _AudioControlsState();
}
class _AudioControlsState extends State<_AudioControls> {
bool _pressed = false;
@override
Widget build(BuildContext context) {
final l10n = AppL10n.of(context);
final theme = Theme.of(context);
final stats = widget.stats;
final statsText = stats == null
? '—'
: l10n.audioStatsLine(
stats.framesSent,
stats.framesReceived,
stats.pttActive ? 'on' : 'off',
);
final isGlobal = widget.pttLevel != 'L0Focused';
final badgeLabel = l10n.pttCapabilityBadge(
widget.pttLevel,
widget.pttBackendId,
);
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Capability badge (gen2 v0.9.3 / SDD-091). Renders the
// active PTT level + backend so the user understands when
// Global PTT has fallen back to Focused PTT.
Padding(
padding: const EdgeInsets.only(bottom: 6),
child: Tooltip(
message: widget.pttBoundInputClass.isEmpty
? badgeLabel
: '$badgeLabel\n(${widget.pttBoundInputClass})',
child: Row(
children: [
Icon(
isGlobal ? Icons.public : Icons.crop_free,
size: 14,
color: theme.colorScheme.onSurfaceVariant,
),
const SizedBox(width: 4),
Expanded(
child: Text(
badgeLabel,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
),
TextButton.icon(
icon: const Icon(Icons.tune, size: 14),
label: Text(l10n.pttConfigureAction),
onPressed: widget.onConfigurePtt,
),
],
),
),
),
Listener(
onPointerDown: (_) {
setState(() => _pressed = true);
widget.onPttDown();
},
onPointerUp: (_) {
setState(() => _pressed = false);
widget.onPttUp();
},
onPointerCancel: (_) {
setState(() => _pressed = false);
widget.onPttUp();
},
child: Container(
padding: const EdgeInsets.symmetric(vertical: 16),
decoration: BoxDecoration(
color: _pressed
? theme.colorScheme.primary
: theme.colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(12),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
_pressed ? Icons.mic : Icons.mic_off,
color: _pressed
? theme.colorScheme.onPrimary
: theme.colorScheme.onPrimaryContainer,
),
const SizedBox(width: 8),
Text(
_pressed ? l10n.pttTransmitting : l10n.pttHoldToTalk,
style: TextStyle(
color: _pressed
? theme.colorScheme.onPrimary
: theme.colorScheme.onPrimaryContainer,
fontWeight: FontWeight.w600,
),
),
],
),
),
),
const SizedBox(height: 8),
Row(
children: [
Expanded(
child: FilterChip(
avatar: Icon(
widget.inputMuted ? Icons.mic_off : Icons.mic,
size: 18,
),
label: Text(
widget.inputMuted ? l10n.inputUnmuteAction : l10n.inputMuteAction,
),
selected: widget.inputMuted,
onSelected: (_) => widget.onToggleInputMute(),
),
),
const SizedBox(width: 8),
Expanded(
child: FilterChip(
avatar: Icon(
widget.outputMuted ? Icons.volume_off : Icons.volume_up,
size: 18,
),
label: Text(
widget.outputMuted ? l10n.outputUnmuteAction : l10n.outputMuteAction,
),
selected: widget.outputMuted,
onSelected: (_) => widget.onToggleOutputMute(),
),
),
],
),
const SizedBox(height: 4),
Row(
children: [
const Icon(Icons.volume_down, size: 18),
Expanded(
child: Slider(
value: widget.outputGain.clamp(0.0, 2.0),
min: 0.0,
max: 2.0,
divisions: 40,
label: '${(widget.outputGain * 100).round()}%',
onChanged: widget.onGainChanged,
),
),
const Icon(Icons.volume_up, size: 18),
],
),
Text(statsText, style: theme.textTheme.bodySmall),
],
);
}
}
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);
final channels = [...snapshot.channels]
..sort((a, b) => a.order.compareTo(b.order));
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) ...[
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: const EdgeInsets.only(left: 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;
// Skip modifier-only presses so the user can chord into the
// real binding.
final keyLabel = event.logicalKey.keyLabel;
if (keyLabel.isEmpty) return KeyEventResult.ignored;
setState(() {
_captured = keyLabel;
_capturedClass = rust.BridgePttInputClass.keyboard;
});
return KeyEventResult.handled;
}
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(
// 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),
),
],
);
}
}