Files
chanora/apps/chanora_flutter/lib/main.dart
T
Edison Jwa 4ecea09bbc feat: add permission-denied dialog for macOS network/mic access
When macOS denies network access (PermissionDenied), show a localized
dialog explaining how to grant permission in System Settings, with
an 'Open System Settings' button that opens directly to the Local
Network privacy pane.

Also adds author info (Edison Jwa) to About dialog and moves
diagnostics button from AppBar into About dialog.
2026-05-17 21:57:56 +09:00

2248 lines
82 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 'dart:io' show Platform, Process;
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:package_info_plus/package_info_plus.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_compact.dart';
import 'widgets/voice_settings.dart';
/// True when the host is a touch-only mobile platform without a
/// hardware keyboard. Mirrors the helpers in widgets/voice_bar.dart
/// and widgets/voice_settings.dart so the AppBar + narrow-mode
/// layout in main.dart can branch consistently.
bool get _isTouchOnlyPttHost {
if (kIsWeb) return false;
return Platform.isIOS || Platform.isAndroid;
}
/// Public version string shown in the About dialog. Resolved at
/// app init by combining a hardcoded semver baseline (kept in sync
/// with the git tag and pubspec.yaml's `version:` field) with the
/// platform-canonical build counter from `package_info_plus`.
///
/// Why hardcode the semver instead of reading the whole string
/// from `package_info_plus`: iOS rejects non-numeric characters
/// in `CFBundleShortVersionString` and Flutter therefore strips
/// `-rc.8` to `.8` when populating the Info.plist field. The
/// resulting `1.0.0.8` is technically valid on the App Store but
/// useless to humans tracking pre-release builds.
/// `package_info_plus.version` reflects that mangled value. The
/// build counter (`CFBundleVersion` / Android `versionCode`) does
/// pass through unmodified, so we use platform info for the
/// `+<build>` suffix only and pair it with the human-readable
/// semver baseline that this codebase already maintains as the
/// canonical release identity.
///
/// Bump `_kSemverBaseline` whenever the semver portion of
/// pubspec.yaml advances (e.g. rc.8 -> rc.9 -> 1.0.0). The
/// build-counter suffix changes automatically on every pubspec
/// `+<n>` bump because Flutter writes it into Info.plist.
const String _kSemverBaseline = 'v1.0.0-rc.8';
String _kAppVersion = _kSemverBaseline;
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
await RustLib.init();
await _resolveAppVersion();
unawaited(_wireStorage());
unawaited(_wireConnectivity());
_wireIosAudioLifecycle();
runApp(const ChanoraApp());
}
/// Wire the iOS AVAudioSession lifecycle MethodChannel.
///
/// Swift side (AppDelegate) posts route-change and interruption
/// events through `FlutterMethodChannel` named
/// `"chanora/ios_audio_lifecycle"`. This handler dispatches them to
/// the FRB bridge functions on the Rust side.
void _wireIosAudioLifecycle() {
const channel = MethodChannel('chanora/ios_audio_lifecycle');
channel.setMethodCallHandler((call) async {
try {
switch (call.method) {
case 'handleRouteChange':
rust.handleRouteChange();
break;
case 'handleInterruptionBegan':
rust.handleInterruptionBegan();
break;
case 'handleInterruptionEnded':
// `shouldResume` is passed from Swift as a bool argument.
final shouldResume = call.arguments as bool? ?? false;
rust.handleInterruptionEnded(shouldResume: shouldResume);
break;
default:
// Unknown method — ignore gracefully rather than crashing.
break;
}
} catch (_) {
// Errors from the Rust side are already logged there;
// don't propagate exceptions to the iOS framework.
}
});
}
/// Populate `_kAppVersion` by suffixing the platform-canonical
/// build number to `_kSemverBaseline`. Format:
/// `v1.0.0-rc.8+<build>` (e.g. `v1.0.0-rc.8+64`). The build
/// number is iOS `CFBundleVersion` / Android `versionCode`,
/// kept in sync with pubspec.yaml's `version: <semver>+<build>`.
Future<void> _resolveAppVersion() async {
try {
final info = await PackageInfo.fromPlatform();
// info.buildNumber = "64" (CFBundleVersion on iOS; survives
// the iOS version-string sanitiser that mangles
// CFBundleShortVersionString).
final build = info.buildNumber.isEmpty ? '' : '+${info.buildNumber}';
_kAppVersion = '$_kSemverBaseline$build';
} catch (_) {
// Keep the hardcoded baseline. The build counter is lost
// but the semver stays correct.
}
}
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,
// No "DEBUG" banner in the top-right corner. This is purely
// cosmetic for the developer-build experience; release builds
// never render it regardless of this flag.
debugShowCheckedModeBanner: false,
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';
// iOS interruption state from BridgeEvent::InterruptionState
// (SDD-101). Can be used by UI surfaces (banner/snackbar).
// ignore: unused_field
bool _iosAudioInterrupted = false;
// ignore: unused_field
bool _iosInterruptionShouldResume = false;
// 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;
}
case rust.BridgeEvent_InterruptionState(
:final began,
:final shouldResume,
):
setState(() {
_iosAudioInterrupted = began;
_iosInterruptionShouldResume = shouldResume;
});
// Surface iOS audio interruption to the user (SDD-101).
// Use unawaited to stay inside the sync _onEvent stream
// without blocking it.
if (!mounted) return;
unawaited(() async {
if (!mounted) return;
final messenger = ScaffoldMessenger.of(context);
if (began) {
messenger.showSnackBar(
const SnackBar(
content: Text('Audio interrupted by system (phone call)'),
duration: Duration(seconds: 3),
backgroundColor: Colors.orange,
),
);
} else if (shouldResume) {
messenger.showSnackBar(
const SnackBar(
content: Text('Audio resuming'),
duration: Duration(seconds: 2),
backgroundColor: Colors.green,
),
);
}
}());
}
}
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;
final errorStr = e.toString();
if (errorStr.contains('PermissionDenied') ||
errorStr.contains('Operation not permitted')) {
_showPermissionDeniedDialog(errorStr);
}
setState(() {
_phase = _Phase.idle;
_error = errorStr;
});
}
}
void _showPermissionDeniedDialog(String rawError) {
final l10n = AppL10n.of(context);
final isNetwork =
rawError.contains('9987') || rawError.contains('connect');
showDialog(
context: context,
builder: (ctx) => AlertDialog(
title:
Text(isNetwork ? l10n.networkPermissionTitle : l10n.permissionDenied),
content: Text(
isNetwork ? l10n.networkPermissionBody : l10n.microphonePermissionBody),
actions: [
if (Platform.isMacOS)
TextButton(
onPressed: () {
Navigator.pop(ctx);
try {
Process.run(
'open',
[
'x-apple.systempreferences:com.apple.preference.security?Privacy_LocalNetwork',
],
);
} catch (_) {}
},
child: Text(l10n.networkPermissionOpenSettings),
),
TextButton(
onPressed: () => Navigator.pop(ctx),
child: Text(MaterialLocalizations.of(context).okButtonLabel),
),
],
),
);
}
// 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 {
if (ch.id == _currentVoiceChannelId) return;
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 0x0302: // channel_already_in
return l10n.channelJoinAlreadyIn;
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());
}
}
/// Touch-only PTT (iOS / iPadOS / Android). The on-screen
/// `_PttHoldButton` calls this with `true` on finger-down and
/// `false` on finger-up (or cancel). The bridge's
/// `setPtt(active:)` routes the press edge through the same
/// release-tail timer + transmit-mode selector the desktop
/// hardware-key paths use (SDD-096 / SAD-083), so the user-
/// visible behaviour is identical across platforms — only the
/// input device changes.
///
/// Errors are swallowed silently in the held=false branch
/// because the timer's `key_up` is idempotent; a failed send
/// would still let the tail expire naturally. Errors on
/// held=true surface in the UI banner so the user knows the
/// mic didn't open.
Future<void> _onOnscreenPttHeldChanged(bool held) async {
try {
await rust.setPtt(active: held);
} catch (e) {
if (held) {
if (!mounted) return;
setState(() => _error = e.toString());
}
}
}
/// Narrow-mode voice controls modal sheet (Plan E status chip
/// trigger). On mobile this is the **single** voice-controls
/// surface: route picker + inline mode radio + inline release-tail
/// slider + level meter + stats + (desktop-only) capability badge.
/// Zero navigation depth \u2014 no nested dialog.
Future<void> _onOpenVoiceDetailsSheet() async {
await showVoiceDetailsSheet(
context,
audioStats: _audioStats,
transmitMode: _transmitMode,
releaseTailMs: _releaseTailMs,
pttBoundKeyLabel: _pttBoundKeyLabel,
pttLevel: _pttLevel,
pttBackendId: _pttBackendId,
pttBoundInputClass: _pttBoundInputClass,
isTouchOnly: _isTouchOnlyPttHost,
onModeChanged: (mode) async {
try {
await rust.setTransmitMode(mode: mode);
if (!mounted) return;
setState(() => _transmitMode = mode);
} catch (e) {
if (!mounted) return;
setState(() => _error = e.toString());
}
},
onReleaseTailChanged: (ms) async {
try {
await rust.setReleaseTailMs(ms: ms);
if (!mounted) return;
setState(() => _releaseTailMs = ms);
} 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 {
// Same pattern as _onAddCurrentBookmark: route the dialog
// through a dedicated StatefulWidget so its
// TextEditingController is disposed at unmount time, not
// synchronously after `await showDialog` resumes. Inline
// dispose-after-await caused framework.dart:6268
// _dependents.isEmpty assertions on iOS \u2014 the controller was
// torn out while EditableText still depended on InheritedWidgets
// belonging to the still-popping dialog route.
return await showDialog<String>(
context: context,
builder: (ctx) => const _ChannelPasswordDialog(),
);
}
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: 4),
Text(l10n.aboutAuthor, 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: () async {
Navigator.of(ctx).pop();
await _onShowDiagnostics(context);
},
child: Text(l10n.diagnosticsAction),
),
TextButton(
onPressed: () => Navigator.of(ctx).pop(),
child: Text(l10n.closeAction),
),
],
),
);
}
Future<void> _onAddCurrentBookmark() async {
// Use the dialog's own context for AppL10n.of(...) inside its
// builder. Capturing the outer _HomePageState context's l10n in
// a closure and reading it inside the dialog's widget tree
// caused the dialog's TextField to depend on InheritedElements
// (Localizations / _LocalizationsScope) that belong to the
// outer route. When the dialog popped, the framework
// deactivated the dialog route's elements first while those
// outer-route InheritedElements were still alive but had
// dependents from the disposed dialog tree \u2014 producing the
// assertion 'package:flutter/src/widgets/framework.dart line
// 6268 _dependents.isEmpty is not true'.
//
// Also: dispose the TextEditingController via the dialog's own
// StatefulBuilder lifecycle instead of an inline dispose() right
// after showDialog returns. The inline dispose runs synchronously
// before the dialog route is fully torn down (the route pop
// animation is still mid-flight on iOS), and tearing the
// controller out from under EditableText while it has a live
// InheritedWidget dependency was the second trigger for the
// same assertion.
final initial = _hostCtl.text.trim();
final name = await showDialog<String>(
context: context,
builder: (ctx) => _BookmarkNameDialog(initialName: initial),
);
if (name == null || name.trim().isEmpty) return;
if (!mounted) 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: _AppBarTitle(
phase: _phase,
inChannel: _inChannel,
channelName: _currentVoiceChannelName(),
),
actions: [
// Narrow-mode AppBar gains mic-mute + headset-mute icons
// when the user is in a voice channel, so the on-screen
// body can dedicate ~85% of its height to the channel tree
// + the bottom-anchored PTT button. Wide mode keeps these
// controls inside the VoiceBar widget (left column) so the
// signed-off rc.8 wide-layout doesn't shift.
//
// The gear icon (Icons.tune) that used to live here was
// **removed** in the rc.8 follow-up: it duplicated the
// "Adjust mode & release tail" button now inside the modal
// sheet, and the user reported the duplication. The modal
// is reached by tapping the status chip above the PTT
// button — there is now exactly one entry point.
if (_phase == _Phase.connected &&
_inChannel &&
MediaQuery.of(context).size.width < 840.0) ...[
IconButton(
tooltip: l10n.voiceHardMuteLabel,
icon: Icon(_hardMute ? Icons.mic_off : Icons.mic),
isSelected: _hardMute,
selectedIcon: const Icon(Icons.mic_off),
onPressed: _onToggleHardMute,
),
IconButton(
tooltip: l10n.voiceOutputMuteLabel,
icon: Icon(_outputMuted ? Icons.headset_off : Icons.headset),
isSelected: _outputMuted,
selectedIcon: const Icon(Icons.headset_off),
onPressed: _toggleOutputMute,
),
],
IconButton(
tooltip: l10n.aboutAction,
icon: const Icon(Icons.info_outline),
onPressed: () => _onShowAbout(context),
),
if (_phase == _Phase.connected) ...[
// The previous Refresh action (Icons.refresh + _onRefresh)
// was removed in v1.0.0-rc.8 — the snapshot stream the
// bridge pushes via BridgeEvent::SnapshotChanged keeps the
// tree current automatically, and a manual refresh was a
// no-op from the user's perspective.
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(
// Dismiss keyboard when the user drags away from
// a focused field — friendlier mobile UX than
// forcing them to tap outside.
keyboardDismissBehavior:
ScrollViewKeyboardDismissBehavior.onDrag,
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,
onPttHeldChanged: _onOnscreenPttHeldChanged,
);
final snapshotView = _SnapshotView(
snapshot: _snapshot!,
currentVoiceChannelId: _currentVoiceChannelId,
onJoinChannel: _onJoinChannel,
onLeaveVoice: _onLeaveVoice,
);
// 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: [
// In narrow / one-column layout the layout
// is:
//
// [ channel tree (Expanded) ]
// [ status chip (2-line live readout) ]
// [ PTT button (mobile-only,
// PTT-mode-only) ]
//
// The chip is a tap target that opens
// `showVoiceDetailsSheet` with the mic
// level meter + TX/RX counts + capability
// badge. Mode + bind key + release-tail
// live in `VoiceSettingsDialog`
// (gear icon in AppBar).
//
// Wide mode (Row branch above) is
// unchanged from rc.8.
Expanded(child: snapshotView),
const SizedBox(height: 8),
VoiceStatusChip(
transmitMode: _transmitMode,
releaseTailMs: _releaseTailMs,
pttBoundKeyLabel: _pttBoundKeyLabel,
audioStats: _audioStats,
isTouchOnly: _isTouchOnlyPttHost,
onTap: () => _onOpenVoiceDetailsSheet(),
),
// PTT button only when PTT mode is active
// AND the user is in a voice channel. In
// Continuous mode there is nothing to
// hold; the chip alone surfaces the
// "Mic on / off" state.
if (_inChannel &&
_transmitMode ==
rust.BridgeTransmitMode.ptt) ...[
const SizedBox(height: 8),
VoicePttButton(
active: _audioStats?.pttActive ?? false,
onHeldChanged: _onOnscreenPttHeldChanged,
),
],
],
);
},
),
),
],
],
);
},
),
),
);
}
}
/// AppBar title that shows just the app name when idle / connecting,
/// and 'app name · #channel' (channel as an outlined chip-style
/// pill) when the user is in a voice channel. The chip is read-
/// only — tapping does nothing because the source of truth for
/// "current channel" is the channel tree below. Long names ellipsize.
class _AppBarTitle extends StatelessWidget {
const _AppBarTitle({
required this.phase,
required this.inChannel,
required this.channelName,
});
final _Phase phase;
final bool inChannel;
final String channelName;
@override
Widget build(BuildContext context) {
final l10n = AppL10n.of(context);
final theme = Theme.of(context);
if (phase != _Phase.connected || !inChannel || channelName.isEmpty) {
return Text(l10n.appTitle);
}
// When in a voice channel on a narrow phone width, the AppBar
// is already crowded with mic / headset / settings / about /
// diagnostics / disconnect icons (5-6 action buttons). Keeping
// the "Chanora" app-name label in the title here causes the
// Row to overflow at typical iPhone widths and Flutter renders
// its yellow-and-black "OVERFLOWED BY X PIXELS" debug strip
// next to the title — the user reported seeing "RFLOWED BY"
// there. Drop the app-name label on narrow widths and let the
// channel pill be the only title content; the user knows
// they're in Chanora because they just opened it. On wide
// widths (>= 840 dp, tablet/desktop) restore the app name
// because there's plenty of room.
final isNarrow = MediaQuery.of(context).size.width < 840.0;
return Row(
mainAxisSize: MainAxisSize.min,
children: [
if (!isNarrow) ...[Text(l10n.appTitle), const SizedBox(width: 12)],
Flexible(
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
decoration: BoxDecoration(
color: theme.colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(12),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.tag,
size: 14,
color: theme.colorScheme.onPrimaryContainer,
),
const SizedBox(width: 4),
Flexible(
child: Text(
channelName,
maxLines: 1,
overflow: TextOverflow.ellipsis,
softWrap: false,
style: TextStyle(
color: theme.colorScheme.onPrimaryContainer,
fontWeight: FontWeight.w600,
fontSize: 14,
),
),
),
],
),
),
),
],
);
}
}
/// 'Save bookmark' name-entry dialog. Owns its own
/// TextEditingController via a StatefulWidget lifecycle so the
/// dispose() runs cleanly at unmount time (after the route pop
/// animation has fully detached the dialog subtree), not
/// synchronously from the caller's `await showDialog` resumption
/// point.
///
/// Inline dispose-after-showDialog pattern previously caused
/// 'framework.dart line 6268 _dependents.isEmpty' on iOS because
/// the controller was torn down mid-pop while EditableText still
/// had live InheritedWidget dependencies on the dialog route.
class _BookmarkNameDialog extends StatefulWidget {
const _BookmarkNameDialog({required this.initialName});
final String initialName;
@override
State<_BookmarkNameDialog> createState() => _BookmarkNameDialogState();
}
class _BookmarkNameDialogState extends State<_BookmarkNameDialog> {
late final TextEditingController _ctl = TextEditingController(
text: widget.initialName,
);
@override
void dispose() {
_ctl.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final l10n = AppL10n.of(context);
return AlertDialog(
title: Text(l10n.bookmarkAddTitle),
content: TextField(
controller: _ctl,
autofocus: true,
decoration: InputDecoration(labelText: l10n.fieldDisplayName),
onSubmitted: (_) => Navigator.of(context).pop(_ctl.text),
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: Text(l10n.closeAction),
),
FilledButton(
onPressed: () => Navigator.of(context).pop(_ctl.text),
child: Text(l10n.bookmarkAddAction),
),
],
);
}
}
/// Channel-password dialog. Same StatefulWidget pattern as
/// [_BookmarkNameDialog] to keep TextEditingController disposal
/// inside the dialog's own lifecycle and avoid the
/// framework.dart:6268 _dependents.isEmpty assertion.
class _ChannelPasswordDialog extends StatefulWidget {
const _ChannelPasswordDialog();
@override
State<_ChannelPasswordDialog> createState() => _ChannelPasswordDialogState();
}
class _ChannelPasswordDialogState extends State<_ChannelPasswordDialog> {
final TextEditingController _ctl = TextEditingController();
@override
void dispose() {
_ctl.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final l10n = AppL10n.of(context);
return AlertDialog(
title: Text(l10n.channelPasswordTitle),
content: TextField(
controller: _ctl,
obscureText: true,
autofocus: true,
decoration: InputDecoration(labelText: l10n.fieldPassword),
onSubmitted: (_) => Navigator.of(context).pop(_ctl.text),
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: Text(l10n.closeAction),
),
FilledButton(
onPressed: () => Navigator.of(context).pop(_ctl.text),
child: Text(l10n.connectAction),
),
],
);
}
}
class _ConnectForm extends StatefulWidget {
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
State<_ConnectForm> createState() => _ConnectFormState();
}
class _ConnectFormState extends State<_ConnectForm> {
// FocusNodes + Listener(onPointerDown) wrappers fix a Flutter-on-
// iOS gesture-arena issue where enclosing scrollables (in our
// case, the SingleChildScrollView wrapping the connect form, and
// the ListView wrapping the channel tree) absorb the first tap
// as a possible scroll-intent. The second tap then succeeds
// because the scrollable's tap-arena participant has already
// decided not to handle a drag.
//
// Two-layer fix:
// * focusNode + autofocus-on-tap inside TextField — works when
// the arena resolves the tap as a TextField gesture
// * Listener(onPointerDown) wrapping the TextField with
// HitTestBehavior.translucent — fires synchronously on
// pointer-down BEFORE arena resolution, so even if the
// scrollable were to win the arena we have already grabbed
// focus. Translucent means the pointer ALSO propagates down
// to the TextField so its normal touch handling still runs.
//
// This is the documented workaround in
// https://github.com/flutter/flutter/issues/22680 and many
// related arena-race threads.
final FocusNode _hostFocus = FocusNode();
final FocusNode _nickFocus = FocusNode();
final FocusNode _passwordFocus = FocusNode();
// _kickFocus (unfocus + Future.microtask refocus on every onTap)
// was removed after user-reported 500\u20131000 ms keyboard appearance
// latency on iPhone 16 Pro / iOS 26.
//
// The microtask deferral was the source of the lag: it forces
// EditableText's attach-to-TextInput path to wait one frame past
// the user's pointer-up, and iOS 26's keyboard slide-up animation
// then dovetails into that extra frame in a way that adds another
// 200\u2013800 ms before the keyboard actually appears.
//
// The tap-outside-to-unfocus GestureDetector wrapping the connect
// form Column (see build() below) already guarantees the FocusNode
// is in the unfocused state when the user taps a field, because
// any prior keyboard dismissal (tap outside / tap a sibling field)
// goes through FocusScope.unfocus(). Therefore TextField's native
// tap path can attach TextInput on the first frame with no help
// from us, and the keyboard appears instantly.
//
// If iOS regresses again such that focus state desyncs from
// keyboard visibility, the workaround to re-introduce here is the
// canonical pattern from flutter/flutter#181474:
//
// void _kickFocus(FocusNode node) {
// if (node.hasFocus) node.unfocus();
// Future.microtask(() {
// if (!mounted) return;
// node.requestFocus();
// });
// }
//
// and wire onTap: () => _kickFocus(_xxxFocus) on each TextField.
/// Dismisses the soft keyboard when the user taps outside any
/// TextField in this form. Wired into each TextField via the
/// built-in `onTapOutside` parameter (Flutter 3.10+), which uses
/// the framework's TapRegion machinery to detect taps outside the
/// field's region without us having to install an outer
/// GestureDetector (which previously caused a
/// '_dependents.isEmpty' assertion when modal-sheet pop sequences
/// raced the InheritedWidget dependency cleanup).
///
/// Uses FocusManager.instance \u2014 a global singleton with no
/// BuildContext dependency \u2014 instead of FocusScope.of(context),
/// to avoid subscribing this widget's Element to any
/// InheritedWidget.
void _onTapOutside(PointerDownEvent _) {
FocusManager.instance.primaryFocus?.unfocus();
}
@override
void dispose() {
_hostFocus.dispose();
_nickFocus.dispose();
_passwordFocus.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final l10n = AppL10n.of(context);
// Tap-outside-to-unfocus.
//
// Iteration history:
// * 020be77/23bd1c7: Listener pre-arena requestFocus +
// SystemChannels.textInput.show. Caused first-tap-not-focusing
// AND post-attach keystroke lag (Listener wins arena,
// EditableText loses).
// * 79f8360: _kickFocus(unfocus + Future.microtask refocus) on
// every TextField.onTap. Caused 500-1000 ms keyboard slide-up
// lag (microtask deferral collides with iOS keyboard
// CAAnimation).
// * 6955547: GestureDetector(HitTestBehavior.opaque, onTap:
// FocusScope.of(context).unfocus()) wrapping the Column.
// Caused 'package:flutter/src/widgets/framework.dart line
// 6268 _dependents.isEmpty' assertion: the
// FocusScope.of(context) call subscribes the GestureDetector's
// Element to the _FocusScopeMarker InheritedWidget on every
// build, and the modal-sheet pop sequence deactivates that
// InheritedElement before the dependents fully clear,
// tripping the debug assert.
//
// Current approach (no outer GestureDetector at all):
// * Use TextField.onTapOutside (added Flutter 3.10+). Wired
// per field. Uses FocusManager.instance (global, no
// BuildContext dependency, no InheritedWidget race).
// * iOS framework default for touch+mobile is to do NOTHING
// on tap-outside (see flutter/widgets/editable_text.dart
// _EditableTextTapOutsideAction:6748-6755) because the iOS
// UX convention is 'swipe-down on the keyboard' or 'tap
// Done' \u2014 not tap-outside. We override that explicitly
// because most users expect tap-outside-to-dismiss in a
// server-connect form context.
// * No outer GestureDetector means zero gesture-arena
// interference with the TextField's own tap recognizer \u2014
// keyboard appears synchronously on first tap, no lag.
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
TextField(
controller: widget.hostCtl,
focusNode: _hostFocus,
onTapOutside: _onTapOutside,
// Server addresses are URL-shaped: hostname or
// hostname:port, all-lowercase ASCII, never user-
// friendly prose. Configure the on-screen keyboard
// accordingly:
// * keyboardType: .url \u2014 surfaces ".", "/",
// ":" on the primary keyboard plane so the user
// doesn't have to switch to the symbols pane to
// type kr.teamspeak.app:9987.
// * textInputAction: .next \u2014 return key advances
// to the nickname field.
// * autocorrect / enableSuggestions: false \u2014 iOS
// should not autocorrect 'kr.teamspeak.app' to
// 'kr.teamspeak.lap' or suggest 'KR' in caps.
// * textCapitalization: .none \u2014 don't capitalise the
// first letter the way iOS does for sentences.
// * inputFormatters: deny whitespace and uppercase\u2014
// belt-and-braces in case the user pastes from a
// formatted source (e.g. tab-indented copy).
keyboardType: TextInputType.url,
textCapitalization: TextCapitalization.none,
textInputAction: TextInputAction.next,
autocorrect: false,
enableSuggestions: false,
inputFormatters: [
FilteringTextInputFormatter.deny(RegExp(r'\s')),
TextInputFormatter.withFunction(
(oldValue, newValue) => newValue.copyWith(
text: newValue.text.toLowerCase(),
selection: newValue.selection,
),
),
],
decoration: InputDecoration(
labelText: l10n.fieldServerHost,
hintText: 'host[:port]',
prefixIcon: const Icon(Icons.dns_outlined),
border: const OutlineInputBorder(),
),
),
const SizedBox(height: 8),
TextField(
controller: widget.nickCtl,
focusNode: _nickFocus,
onTapOutside: _onTapOutside,
textInputAction: TextInputAction.next,
autocorrect: false,
enableSuggestions: false,
decoration: InputDecoration(
labelText: l10n.fieldNickname,
border: const OutlineInputBorder(),
),
),
const SizedBox(height: 8),
TextField(
controller: widget.passwordCtl,
focusNode: _passwordFocus,
onTapOutside: _onTapOutside,
obscureText: true,
textInputAction: TextInputAction.done,
autocorrect: false,
enableSuggestions: false,
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: widget.onConnect,
),
),
const SizedBox(width: 8),
OutlinedButton.icon(
icon: const Icon(Icons.bookmark_add_outlined),
label: Text(l10n.bookmarkAddAction),
onPressed: widget.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;
case TargetPlatform.iOS:
return l10n.pttCapabilityExplainGoGlobalIos;
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.currentVoiceChannelId,
required this.onJoinChannel,
required this.onLeaveVoice,
});
final rust.BridgeSnapshot snapshot;
final BigInt? currentVoiceChannelId;
final ValueChanged<rust.BridgeChannel> onJoinChannel;
final VoidCallback onLeaveVoice;
@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: Icon(
ch.id == currentVoiceChannelId ? Icons.volume_up : Icons.tag,
),
title: Text(ch.name),
subtitle: Text('id=${ch.id} parent=${ch.parent}'),
trailing: IconButton(
icon: Icon(
ch.id == currentVoiceChannelId ? Icons.logout : Icons.login,
),
tooltip: ch.id == currentVoiceChannelId
? l10n.leaveChannelAction
: l10n.joinChannelAction,
onPressed: ch.id == currentVoiceChannelId
? onLeaveVoice
: () => onJoinChannel(ch),
),
selected: ch.id == currentVoiceChannelId,
onTap: ch.id == currentVoiceChannelId
? null
: () => 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),
),
],
);
}
}