User reported: 'input field still need to click twice' on iPhone + 'a bit lag after keyboard pop up'. The previous Listener-based approach (020be77\u219223bd1c7) actively made both symptoms worse. Root cause is a confirmed open Flutter framework bug: flutter/flutter#181474 \u2014 [iPadOS] Keyboard is dismissed, but the TextField keeps focus, causing subsequent taps not to trigger keyboard presentation. Open, P2, triaged-text-input, platform-ios, e: OS-version specific. Reported on iPadOS 26.2 + Flutter 3.38.1 in Jan 2026 by Crazymuyang. Reproduction matches our symptom exactly: iOS 26 dismisses the soft keyboard (e.g. on tap-outside, or in some fresh-launch states), but the EditableText's FocusNode keeps hasFocus = true. Because the node is already focused, the next user tap is a no-op from the focus system's perspective, so the platform TextInput channel is never re-opened and iOS keeps the soft keyboard hidden until a second tap finally triggers an explicit re-focus path. Why our previous attempts failed: *020be77wrapped each TextField in a Listener that called requestFocus pre-arena. That's racing the wrong layer \u2014 it doesn't help when the bug is 'node is already focused, so requestFocus is a no-op'. *23bd1c7added SystemChannels.textInput.invokeMethod('TextInput.show') to the same Listener. This forced the keyboard up but raced EditableText's own attach path, producing the post-attach typing lag the user reported. Fix: the community-recommended workaround in the issue thread \u2014 on every TextField tap, **unfocus first, then re-request focus on the next microtask**. This forces a real false\u2192true focus-change transition that re-opens TextInput on the first tap. No platform channel races, no gesture-arena fighting, no Listener wrappers. void _kickFocus(FocusNode node) { if (node.hasFocus) node.unfocus(); Future.microtask(() { if (!mounted) return; node.requestFocus(); }); } TextField( onTap: () => _kickFocus(_hostFocus), ... ) Wired into all three connect-form fields (host / nickname / password). Removed the now-redundant _focusOnTap Listener helper. No-op on hosts where #181474 doesn't reproduce \u2014 the unfocus call is a no-op when the node isn't focused, and the microtask requestFocus is what TextField would have done anyway via its own TapGestureRecognizer. flutter analyze: 6 pre-existing Radio.groupValue deprecation infos in voice_settings.dart (unchanged). flutter build ios --release --no-codesign: 27.6 s, Runner.app 30.2 MB.
1935 lines
68 KiB
Dart
1935 lines
68 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;
|
|
|
|
import 'package:flutter/foundation.dart';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter/services.dart';
|
|
import 'package:path_provider/path_provider.dart';
|
|
|
|
import 'l10n/generated/app_localizations.dart';
|
|
import 'src/rust/api.dart' as rust;
|
|
import 'src/rust/lib.dart' as rust_err;
|
|
import 'src/rust/frb_generated.dart';
|
|
import 'widgets/voice_bar.dart';
|
|
import 'widgets/voice_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. Aligned with
|
|
/// `pubspec.yaml` and the git tag for the MVP release candidate.
|
|
/// Bumped to rc.8 for the post-rc.7 v1 audio + PTT lifecycle work
|
|
/// (SDD-094..097, DEC-029..031).
|
|
const String _kAppVersion = 'v1.0.0-rc.8';
|
|
|
|
Future<void> main() async {
|
|
WidgetsFlutterBinding.ensureInitialized();
|
|
await RustLib.init();
|
|
unawaited(_wireStorage());
|
|
unawaited(_wireConnectivity());
|
|
runApp(const ChanoraApp());
|
|
}
|
|
|
|
Future<void> _wireStorage() async {
|
|
try {
|
|
final dir = await getApplicationSupportDirectory();
|
|
await rust.initStorage(dir: dir.path);
|
|
} catch (_) {
|
|
// Best-effort; missing storage just means no identity persistence
|
|
// and no bookmark list this session.
|
|
}
|
|
}
|
|
|
|
rust.BridgeNetworkState _mapConnectivity(List<ConnectivityResult> results) {
|
|
if (results.isEmpty) return rust.BridgeNetworkState.unknown;
|
|
final allNone = results.every((r) => r == ConnectivityResult.none);
|
|
if (allNone) return rust.BridgeNetworkState.offline;
|
|
return rust.BridgeNetworkState.online;
|
|
}
|
|
|
|
Future<void> _wireConnectivity() async {
|
|
final connectivity = Connectivity();
|
|
try {
|
|
final initial = await connectivity.checkConnectivity();
|
|
rust.setNetworkState(state: _mapConnectivity(initial));
|
|
} catch (_) {}
|
|
connectivity.onConnectivityChanged.listen((results) {
|
|
rust.setNetworkState(state: _mapConnectivity(results));
|
|
});
|
|
}
|
|
|
|
class ChanoraApp extends StatelessWidget {
|
|
const ChanoraApp({super.key});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return MaterialApp(
|
|
onGenerateTitle: (ctx) => AppL10n.of(ctx).appTitle,
|
|
// 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';
|
|
// Last platform-neutral key label the user saved in the
|
|
// `_PttBindingCaptureDialog` (e.g. "Space", "F10",
|
|
// "mouse-side-button:8"). Surfaced next to the capability
|
|
// badge so the user can remember which key drives PTT. The
|
|
// bridge-side `PttController` (SDD-088) holds the authoritative
|
|
// binding; this is a display-only cache that resets on app
|
|
// restart. Per DEC-027 the raw OS key code never lives here —
|
|
// only the platform-neutral label that already crossed into
|
|
// the Rust side.
|
|
String _pttBoundKeyLabel = '';
|
|
|
|
List<rust.BridgeBookmark> _bookmarks = const [];
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_eventsSub = rust.eventsStream().listen(_onEvent);
|
|
unawaited(_reloadBookmarks());
|
|
unawaited(_hydratePttBinding());
|
|
}
|
|
|
|
/// Hydrate the bound-key display state from the bridge so the
|
|
/// Voice Bar shows the user's persisted hotkey label immediately
|
|
/// at launch — without having to wait for them to open the
|
|
/// binding dialog. The bridge returns empty strings when no
|
|
/// binding has ever been saved.
|
|
Future<void> _hydratePttBinding() async {
|
|
try {
|
|
final (inputClass, keyLabel) = await rust.getPttBinding();
|
|
if (!mounted) return;
|
|
if (keyLabel.isNotEmpty || inputClass.isNotEmpty) {
|
|
setState(() {
|
|
_pttBoundKeyLabel = keyLabel;
|
|
_pttBoundInputClass = inputClass;
|
|
});
|
|
}
|
|
} catch (_) {
|
|
// No persisted binding or storage not yet wired; not an error.
|
|
}
|
|
}
|
|
|
|
Future<void> _reloadBookmarks() async {
|
|
try {
|
|
final list = await rust.listBookmarks();
|
|
if (!mounted) return;
|
|
setState(() => _bookmarks = list);
|
|
} catch (_) {
|
|
// Bookmark store missing on this platform — empty list is fine.
|
|
}
|
|
}
|
|
|
|
void _onEvent(rust.BridgeEvent evt) {
|
|
if (!mounted) return;
|
|
switch (evt) {
|
|
case rust.BridgeEvent_Connected():
|
|
setState(() {
|
|
_phase = _Phase.connected;
|
|
_lostReason = null;
|
|
_reconnectAttempt = null;
|
|
_reconnectDelay = null;
|
|
});
|
|
case rust.BridgeEvent_Lost(:final reason):
|
|
setState(() {
|
|
_lostReason = reason;
|
|
_reconnectAttempt = null;
|
|
_reconnectDelay = null;
|
|
});
|
|
case rust.BridgeEvent_Reconnecting(:final attempt, :final delaySecs):
|
|
setState(() {
|
|
_reconnectAttempt = attempt;
|
|
_reconnectDelay = delaySecs;
|
|
});
|
|
case rust.BridgeEvent_Disconnected():
|
|
setState(() {
|
|
_phase = _Phase.idle;
|
|
_lostReason = null;
|
|
_reconnectAttempt = null;
|
|
_reconnectDelay = null;
|
|
});
|
|
case rust.BridgeEvent_AudioStarted():
|
|
setState(() => _audioStarted = true);
|
|
case rust.BridgeEvent_AudioStopped():
|
|
setState(() => _audioStarted = false);
|
|
case rust.BridgeEvent_SnapshotChanged():
|
|
unawaited(_onRefresh());
|
|
case rust.BridgeEvent_PttCapability(
|
|
:final level,
|
|
:final backendId,
|
|
:final boundInputClass,
|
|
):
|
|
setState(() {
|
|
_pttLevel = level;
|
|
_pttBackendId = backendId;
|
|
_pttBoundInputClass = boundInputClass;
|
|
});
|
|
case rust.BridgeEvent_VoiceState(
|
|
:final inChannel,
|
|
:final transmitMode,
|
|
:final mute,
|
|
:final releaseTailMs,
|
|
):
|
|
setState(() {
|
|
_inChannel = inChannel;
|
|
_transmitMode = transmitMode;
|
|
_hardMute = mute;
|
|
_releaseTailMs = releaseTailMs;
|
|
_audioStarted = inChannel;
|
|
});
|
|
if (inChannel) {
|
|
_ensureStatsTimer();
|
|
} else {
|
|
_statsTimer?.cancel();
|
|
_statsTimer = null;
|
|
}
|
|
}
|
|
}
|
|
|
|
void _ensureStatsTimer() {
|
|
if (_statsTimer != null) return;
|
|
// Poll at 80 ms (~12 Hz) — this is the loop that drives the
|
|
// Voice Bar's "PTT=on/off" indicator and the level meter, so
|
|
// it needs to be fast enough that users don't perceive a lag
|
|
// between physically pressing the bound key and seeing the
|
|
// UI change. 80 ms is well below the ~150 ms perceptual
|
|
// delay threshold and adds only a dozen tiny FFI calls per
|
|
// second to the load. A future push-based BridgeEvent for
|
|
// transmit-active transitions would let us drop this poll
|
|
// entirely.
|
|
_statsTimer = Timer.periodic(const Duration(milliseconds: 80), (_) async {
|
|
try {
|
|
final s = await rust.audioStats();
|
|
if (!mounted) return;
|
|
setState(() => _audioStats = s);
|
|
} catch (_) {}
|
|
});
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_eventsSub?.cancel();
|
|
_statsTimer?.cancel();
|
|
_hostCtl.dispose();
|
|
_nickCtl.dispose();
|
|
_passwordCtl.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
Future<void> _onConnect({
|
|
String? host,
|
|
String? nickname,
|
|
String? password,
|
|
}) async {
|
|
setState(() {
|
|
_phase = _Phase.connecting;
|
|
_error = null;
|
|
_snapshot = null;
|
|
});
|
|
try {
|
|
final snap = await rust.connect(
|
|
host: (host ?? _hostCtl.text).trim(),
|
|
nickname: (nickname ?? _nickCtl.text).trim(),
|
|
password: password ?? _passwordCtl.text,
|
|
);
|
|
if (!mounted) return;
|
|
setState(() {
|
|
_phase = _Phase.connected;
|
|
_snapshot = snap;
|
|
});
|
|
} catch (e) {
|
|
if (!mounted) return;
|
|
setState(() {
|
|
_phase = _Phase.idle;
|
|
_error = e.toString();
|
|
});
|
|
}
|
|
}
|
|
|
|
// ignore: unused_element
|
|
Future<void> _setPtt(bool active) async {
|
|
try {
|
|
await rust.setPtt(active: active);
|
|
} catch (e) {
|
|
if (!mounted) return;
|
|
setState(() => _error = e.toString());
|
|
}
|
|
}
|
|
|
|
// ignore: unused_element
|
|
Future<void> _toggleInputMute() async {
|
|
final next = !_inputMuted;
|
|
try {
|
|
await rust.setInputMuted(muted: next);
|
|
if (!mounted) return;
|
|
setState(() => _inputMuted = next);
|
|
} catch (e) {
|
|
if (!mounted) return;
|
|
setState(() => _error = e.toString());
|
|
}
|
|
}
|
|
|
|
Future<void> _toggleOutputMute() async {
|
|
final next = !_outputMuted;
|
|
try {
|
|
await rust.setOutputMuted(muted: next);
|
|
if (!mounted) return;
|
|
setState(() => _outputMuted = next);
|
|
} catch (e) {
|
|
if (!mounted) return;
|
|
setState(() => _error = e.toString());
|
|
}
|
|
}
|
|
|
|
// ignore: unused_element
|
|
Future<void> _setOutputGain(double value) async {
|
|
setState(() => _outputGain = value);
|
|
try {
|
|
await rust.setOutputGain(gain: value);
|
|
} catch (_) {
|
|
// Audio may not be started yet — that's fine; the next start_audio
|
|
// picks up the current slider position next time we plumb it.
|
|
}
|
|
}
|
|
|
|
Future<void> _onJoinChannel(rust.BridgeChannel ch) async {
|
|
final l10n = AppL10n.of(context);
|
|
final messenger = ScaffoldMessenger.of(context);
|
|
String? password;
|
|
// Heuristic: a channel name annotated with a lock prompts.
|
|
if (ch.name.contains('🔒') || ch.name.toLowerCase().contains('password')) {
|
|
password = await _askChannelPassword(l10n);
|
|
if (password == null) return; // cancelled
|
|
}
|
|
try {
|
|
await rust.voiceJoin(
|
|
channelId: ch.id,
|
|
password: password ?? '',
|
|
);
|
|
if (!mounted) return;
|
|
setState(() => _currentVoiceChannelId = ch.id);
|
|
} catch (e) {
|
|
if (!mounted) return;
|
|
// Surface as a SnackBar so the user sees it even while
|
|
// connected (the persistent _error string lives in the
|
|
// pre-connect area and is hidden post-connect). The message
|
|
// is selected by TS3 error code per the canonical
|
|
// catalogue at https://github.com/ReSpeak/tsdeclarations.
|
|
final message = _channelJoinErrorMessage(l10n, e);
|
|
messenger.showSnackBar(
|
|
SnackBar(
|
|
content: Text(message),
|
|
behavior: SnackBarBehavior.floating,
|
|
duration: const Duration(seconds: 4),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Map a `voiceJoin` error to a localised user-facing message.
|
|
/// Recognises the typed `BridgeError.serverRejected` variant
|
|
/// (with a TS3 error code) and falls back to a generic message
|
|
/// for everything else.
|
|
String _channelJoinErrorMessage(AppL10n l10n, Object e) {
|
|
if (e is rust_err.BridgeError) {
|
|
return e.when(
|
|
invalidCommand: (msg) => l10n.channelJoinFailedGeneric(msg),
|
|
dnsFailed: (host, reason) =>
|
|
l10n.channelJoinFailedGeneric('$host: $reason'),
|
|
connection: (msg) => l10n.channelJoinFailedGeneric(msg),
|
|
notConnected: () => l10n.channelJoinFailedGeneric('not connected'),
|
|
alreadyConnected: () => l10n.channelJoinFailedGeneric('already connected'),
|
|
serverRejected: (code, message) {
|
|
// Canonical TS3 error codes per ReSpeak/tsdeclarations
|
|
// Errors.csv.
|
|
switch (code) {
|
|
case 0x0001:
|
|
// Our sentinel for "snapshot poll didn't confirm".
|
|
return l10n.channelJoinFailedTimeout;
|
|
case 0x0a08: // permissions_client_insufficient
|
|
return l10n.channelJoinFailedPermission;
|
|
case 0x030d: // channel_invalid_password
|
|
return l10n.channelJoinFailedPassword;
|
|
case 0x0309: // channel_maxclients_reached
|
|
return l10n.channelJoinFailedFull;
|
|
case 0x030a: // channel_maxfamily_reached
|
|
return l10n.channelJoinFailedFamilyFull;
|
|
case 0x030e: // channel_is_private_channel
|
|
return l10n.channelJoinFailedPrivate;
|
|
default:
|
|
return l10n.channelJoinFailedGeneric(message);
|
|
}
|
|
},
|
|
unmapped: (msg) => l10n.channelJoinFailedGeneric(msg),
|
|
);
|
|
}
|
|
return l10n.channelJoinFailedGeneric(e.toString());
|
|
}
|
|
|
|
// ignore: unused_element
|
|
Future<void> _onLeaveVoice() async {
|
|
try {
|
|
await rust.voiceLeave();
|
|
if (!mounted) return;
|
|
setState(() => _currentVoiceChannelId = null);
|
|
} catch (e) {
|
|
if (!mounted) return;
|
|
setState(() => _error = e.toString());
|
|
}
|
|
}
|
|
|
|
Future<void> _onToggleHardMute() async {
|
|
final next = !_hardMute;
|
|
try {
|
|
// Hard-mute is two coordinated effects:
|
|
// * setHardMute — local TransmitGate clamp; we stop sending
|
|
// Opus frames the instant this returns.
|
|
// * setInputMuted — server-side ClientMuted flag so other
|
|
// clients see the mic-off icon next to our name and the
|
|
// server stops relaying any in-flight frames.
|
|
// Sending only one of them is user-confusing; clients see
|
|
// silence but no icon, or icon but a beat of audio leaks
|
|
// through. Drive them together.
|
|
await rust.setHardMute(muted: next);
|
|
await rust.setInputMuted(muted: next);
|
|
if (!mounted) return;
|
|
setState(() => _inputMuted = next);
|
|
} catch (e) {
|
|
if (!mounted) return;
|
|
setState(() => _error = e.toString());
|
|
}
|
|
}
|
|
|
|
/// 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 + mode/tail recap + "Adjust" button to
|
|
/// [VoiceSettingsDialog] + level meter + stats + (desktop-only)
|
|
/// capability badge. The AppBar gear icon was removed in this
|
|
/// rc.8 follow-up so there is exactly one entry point to voice
|
|
/// controls on mobile.
|
|
Future<void> _onOpenVoiceDetailsSheet() async {
|
|
await showVoiceDetailsSheet(
|
|
context,
|
|
audioStats: _audioStats,
|
|
transmitMode: _transmitMode,
|
|
releaseTailMs: _releaseTailMs,
|
|
pttBoundKeyLabel: _pttBoundKeyLabel,
|
|
pttLevel: _pttLevel,
|
|
pttBackendId: _pttBackendId,
|
|
pttBoundInputClass: _pttBoundInputClass,
|
|
isTouchOnly: _isTouchOnlyPttHost,
|
|
onAdjustVoiceSettings: _onOpenVoiceSettings,
|
|
);
|
|
}
|
|
|
|
Future<void> _onOpenVoiceSettings() async {
|
|
final result = await showDialog<VoiceSettingsResult>(
|
|
context: context,
|
|
builder: (ctx) => VoiceSettingsDialog(
|
|
initialMode: _transmitMode,
|
|
initialReleaseTailMs: _releaseTailMs,
|
|
),
|
|
);
|
|
if (result == null) return;
|
|
try {
|
|
await rust.setTransmitMode(mode: result.mode);
|
|
await rust.setReleaseTailMs(ms: result.releaseTailMs);
|
|
} catch (e) {
|
|
if (!mounted) return;
|
|
setState(() => _error = e.toString());
|
|
}
|
|
if (result.bindKeyRequested && mounted) {
|
|
await _onConfigurePtt(context);
|
|
}
|
|
}
|
|
|
|
Future<String?> _askChannelPassword(AppL10n l10n) async {
|
|
final ctl = TextEditingController();
|
|
final result = await showDialog<String>(
|
|
context: context,
|
|
builder: (ctx) => AlertDialog(
|
|
title: Text(l10n.channelPasswordTitle),
|
|
content: TextField(
|
|
controller: ctl,
|
|
obscureText: true,
|
|
autofocus: true,
|
|
decoration: InputDecoration(labelText: l10n.fieldPassword),
|
|
),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () => Navigator.of(ctx).pop(),
|
|
child: Text(l10n.closeAction),
|
|
),
|
|
FilledButton(
|
|
onPressed: () => Navigator.of(ctx).pop(ctl.text),
|
|
child: Text(l10n.connectAction),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
ctl.dispose();
|
|
return result;
|
|
}
|
|
|
|
Future<void> _onRefresh() async {
|
|
try {
|
|
final snap = await rust.snapshot();
|
|
if (!mounted) return;
|
|
setState(() => _snapshot = snap);
|
|
} catch (e) {
|
|
if (!mounted) return;
|
|
setState(() => _error = e.toString());
|
|
}
|
|
}
|
|
|
|
Future<void> _onDisconnect() async {
|
|
_statsTimer?.cancel();
|
|
_statsTimer = null;
|
|
try {
|
|
await rust.disconnect();
|
|
} catch (_) {}
|
|
if (!mounted) return;
|
|
setState(() {
|
|
_phase = _Phase.idle;
|
|
_snapshot = null;
|
|
_audioStarted = false;
|
|
_audioStats = null;
|
|
_error = null;
|
|
_inputMuted = false;
|
|
_outputMuted = false;
|
|
_inChannel = false;
|
|
_currentVoiceChannelId = null;
|
|
});
|
|
}
|
|
|
|
String _currentVoiceChannelName() {
|
|
final id = _currentVoiceChannelId;
|
|
final snap = _snapshot;
|
|
if (id == null || snap == null) return '';
|
|
for (final ch in snap.channels) {
|
|
if (ch.id == id) return ch.name;
|
|
}
|
|
return '';
|
|
}
|
|
|
|
Future<void> _onShowDiagnostics(BuildContext context) async {
|
|
final l10n = AppL10n.of(context);
|
|
final text = rust.exportDiagnostics();
|
|
if (!mounted) return;
|
|
await showDialog<void>(
|
|
context: context,
|
|
builder: (ctx) => AlertDialog(
|
|
title: Text(l10n.diagnosticsAction),
|
|
content: SingleChildScrollView(
|
|
child: SelectableText(
|
|
text,
|
|
style: const TextStyle(fontFamily: 'monospace', fontSize: 11),
|
|
),
|
|
),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () async {
|
|
await Clipboard.setData(ClipboardData(text: text));
|
|
if (!ctx.mounted) return;
|
|
Navigator.of(ctx).pop();
|
|
},
|
|
child: Text(l10n.copyAction),
|
|
),
|
|
TextButton(
|
|
onPressed: () => Navigator.of(ctx).pop(),
|
|
child: Text(l10n.closeAction),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Future<void> _onConfigurePtt(BuildContext context) async {
|
|
// On the Linux GNOME-Wayland portal backend, the portal hosts
|
|
// its own system-managed binding dialog (gen2 v0.9.3 / Q3a).
|
|
// Skip the in-app capture dialog entirely on that backend and
|
|
// delegate to the portal via `setPttBinding` with a sentinel
|
|
// platform_key. Show a SnackBar so the user isn't surprised
|
|
// when their compositor opens a separate dialog.
|
|
final l10n = AppL10n.of(context);
|
|
if (!mounted) return;
|
|
if (_pttBackendId == 'gnome-wayland-portal') {
|
|
try {
|
|
final messenger = ScaffoldMessenger.of(context);
|
|
messenger.showSnackBar(SnackBar(
|
|
content: Text(l10n.pttConfigurePortalRedirect),
|
|
));
|
|
await rust.setPttBinding(
|
|
inputClass: rust.BridgePttInputClass.keyboard,
|
|
platformKey: 'portal',
|
|
);
|
|
} catch (e) {
|
|
if (!mounted) return;
|
|
final messenger = ScaffoldMessenger.of(this.context);
|
|
messenger.showSnackBar(SnackBar(
|
|
content: Text(l10n.statusError(e.toString())),
|
|
));
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Other backends: open the in-app focus-scoped capture
|
|
// dialog. The bridge carries only the coarse input class and
|
|
// an opaque platform-key string; the actual key value never
|
|
// appears in any log record (DEC-027 / SRS-202).
|
|
final binding = await showDialog<_CapturedBinding>(
|
|
context: context,
|
|
builder: (ctx) => const _PttBindingCaptureDialog(),
|
|
);
|
|
if (binding == null) return;
|
|
try {
|
|
await rust.setPttBinding(
|
|
inputClass: binding.inputClass,
|
|
platformKey: binding.platformKey,
|
|
);
|
|
// Cache the captured label so the badge can surface "Key:
|
|
// Space" / "Key: Mouse4" next to the capability descriptor.
|
|
// The bridge holds the authoritative binding; this is only
|
|
// for display continuity until the next app restart.
|
|
if (mounted) {
|
|
setState(() {
|
|
_pttBoundKeyLabel = binding.platformKey;
|
|
});
|
|
}
|
|
} catch (e) {
|
|
if (!mounted) return;
|
|
// Surface the failure as a snackbar so the user sees that
|
|
// their binding did not stick.
|
|
if (!mounted) return;
|
|
// Use the State's context (guaranteed valid because we
|
|
// re-checked `mounted` immediately above).
|
|
final messenger = ScaffoldMessenger.of(this.context);
|
|
messenger.showSnackBar(SnackBar(
|
|
content: Text(l10n.statusError(e.toString())),
|
|
));
|
|
}
|
|
}
|
|
|
|
Future<void> _onShowAbout(BuildContext context) async {
|
|
// DEC-018 / DEC-019 / DEC-020 surface: public name, non-
|
|
// affiliation statement, dual-license declaration. The Flutter
|
|
// showAboutDialog widget is intentionally bare so the legal
|
|
// text comes from us, not a framework default.
|
|
final l10n = AppL10n.of(context);
|
|
final theme = Theme.of(context);
|
|
if (!mounted) return;
|
|
await showDialog<void>(
|
|
context: context,
|
|
builder: (ctx) => AlertDialog(
|
|
title: Text(l10n.aboutAction),
|
|
content: SingleChildScrollView(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Text(
|
|
l10n.appTitle,
|
|
style: theme.textTheme.titleLarge,
|
|
),
|
|
const SizedBox(height: 4),
|
|
Text(
|
|
l10n.aboutVersion(_kAppVersion),
|
|
style: theme.textTheme.bodySmall,
|
|
),
|
|
const SizedBox(height: 16),
|
|
Text(
|
|
l10n.aboutNonAffiliation,
|
|
style: theme.textTheme.bodyMedium,
|
|
),
|
|
const SizedBox(height: 12),
|
|
Text(
|
|
l10n.aboutLicenseHeading,
|
|
style: theme.textTheme.titleSmall,
|
|
),
|
|
const SizedBox(height: 4),
|
|
Text(
|
|
l10n.aboutLicenseBody,
|
|
style: theme.textTheme.bodySmall,
|
|
),
|
|
const SizedBox(height: 12),
|
|
Text(
|
|
l10n.aboutThirdPartyHeading,
|
|
style: theme.textTheme.titleSmall,
|
|
),
|
|
const SizedBox(height: 4),
|
|
Text(
|
|
l10n.aboutThirdPartyBody,
|
|
style: theme.textTheme.bodySmall,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () => Navigator.of(ctx).pop(),
|
|
child: Text(l10n.closeAction),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Future<void> _onAddCurrentBookmark() async {
|
|
final l10n = AppL10n.of(context);
|
|
final nameCtl = TextEditingController(text: _hostCtl.text.trim());
|
|
final name = await showDialog<String>(
|
|
context: context,
|
|
builder: (ctx) => AlertDialog(
|
|
title: Text(l10n.bookmarkAddTitle),
|
|
content: TextField(
|
|
controller: nameCtl,
|
|
autofocus: true,
|
|
decoration: InputDecoration(labelText: l10n.fieldDisplayName),
|
|
),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () => Navigator.of(ctx).pop(),
|
|
child: Text(l10n.closeAction),
|
|
),
|
|
FilledButton(
|
|
onPressed: () => Navigator.of(ctx).pop(nameCtl.text),
|
|
child: Text(l10n.bookmarkAddAction),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
nameCtl.dispose();
|
|
if (name == null || name.trim().isEmpty) return;
|
|
try {
|
|
await rust.addBookmark(
|
|
b: rust.BridgeBookmark(
|
|
id: 0,
|
|
displayName: name.trim(),
|
|
host: _hostCtl.text.trim(),
|
|
nickname: _nickCtl.text.trim(),
|
|
password: _passwordCtl.text,
|
|
),
|
|
);
|
|
await _reloadBookmarks();
|
|
} catch (e) {
|
|
if (!mounted) return;
|
|
setState(() => _error = e.toString());
|
|
}
|
|
}
|
|
|
|
Future<void> _onDeleteBookmark(rust.BridgeBookmark b) async {
|
|
try {
|
|
await rust.deleteBookmark(id: b.id);
|
|
await _reloadBookmarks();
|
|
} catch (e) {
|
|
if (!mounted) return;
|
|
setState(() => _error = e.toString());
|
|
}
|
|
}
|
|
|
|
Future<void> _onUseBookmark(rust.BridgeBookmark b) async {
|
|
_hostCtl.text = b.host;
|
|
_nickCtl.text = b.nickname;
|
|
_passwordCtl.text = b.password;
|
|
await _onConnect(
|
|
host: b.host,
|
|
nickname: b.nickname,
|
|
password: b.password,
|
|
);
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final l10n = AppL10n.of(context);
|
|
final theme = Theme.of(context);
|
|
|
|
String statusText() {
|
|
switch (_phase) {
|
|
case _Phase.idle:
|
|
return _error != null ? l10n.statusError(_error!) : l10n.statusIdle;
|
|
case _Phase.connecting:
|
|
return l10n.statusConnecting;
|
|
case _Phase.connected:
|
|
return l10n.statusConnected(_snapshot?.serverName ?? '');
|
|
}
|
|
}
|
|
|
|
return Scaffold(
|
|
appBar: AppBar(
|
|
title: _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),
|
|
),
|
|
IconButton(
|
|
tooltip: l10n.diagnosticsAction,
|
|
icon: const Icon(Icons.bug_report_outlined),
|
|
onPressed: () => _onShowDiagnostics(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!,
|
|
onJoinChannel: _onJoinChannel,
|
|
);
|
|
// Responsive: at <840 dp use a stacked layout
|
|
// (Voice Bar on top, channel tree below). At
|
|
// ≥840 dp use a side-by-side layout with the
|
|
// Voice Bar pinned to 320 dp on the left and
|
|
// the channel tree expanding on the right.
|
|
// 840 dp matches Material's tablet / desktop
|
|
// breakpoint.
|
|
const wideBreakpoint = 840.0;
|
|
const voiceBarWidthWide = 320.0;
|
|
if (constraints.maxWidth >= wideBreakpoint) {
|
|
return Row(
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: [
|
|
SizedBox(
|
|
width: voiceBarWidthWide,
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: [
|
|
banner,
|
|
const SizedBox(height: 12),
|
|
voiceBar,
|
|
],
|
|
),
|
|
),
|
|
const SizedBox(width: 12),
|
|
Expanded(child: snapshotView),
|
|
],
|
|
);
|
|
}
|
|
return Column(
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: [
|
|
// 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,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
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();
|
|
|
|
/// Workaround for flutter/flutter#181474 (open, P2, triaged-text-
|
|
/// input, iOS 26+): after the soft keyboard is dismissed, the
|
|
/// FocusNode still reports `hasFocus == true`. Because the node is
|
|
/// already focused, the next user tap on the same TextField does
|
|
/// not trigger any focus-change side effect, so the platform
|
|
/// TextInput channel is never re-opened, and iOS keeps the soft
|
|
/// keyboard hidden. The user has to tap twice (the second tap
|
|
/// triggers an explicit re-focus path that re-shows the keyboard).
|
|
///
|
|
/// The community-recommended workaround in the issue thread is to
|
|
/// **unfocus first, then re-request focus** on every tap. That
|
|
/// forces a real focus-change transition (`false -> true`) which
|
|
/// re-opens TextInput and slides the keyboard up on the first tap.
|
|
///
|
|
/// This costs nothing on hosts where the bug doesn't reproduce
|
|
/// (the unfocus call is a no-op when the node isn't focused; the
|
|
/// re-request just reaffirms focus and EditableText's own attach
|
|
/// path handles the rest).
|
|
void _kickFocus(FocusNode node) {
|
|
// Drop focus synchronously, then re-grab it on the next frame.
|
|
// Doing both in one frame can race with EditableText's internal
|
|
// bookkeeping; deferring the re-grab by one microtask sidesteps
|
|
// that and matches the workaround pattern in the issue thread.
|
|
if (node.hasFocus) {
|
|
node.unfocus();
|
|
}
|
|
Future.microtask(() {
|
|
if (!mounted) return;
|
|
node.requestFocus();
|
|
});
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_hostFocus.dispose();
|
|
_nickFocus.dispose();
|
|
_passwordFocus.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final l10n = AppL10n.of(context);
|
|
return Column(
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: [
|
|
TextField(
|
|
controller: widget.hostCtl,
|
|
focusNode: _hostFocus,
|
|
onTap: () => _kickFocus(_hostFocus),
|
|
// 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,
|
|
onTap: () => _kickFocus(_nickFocus),
|
|
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,
|
|
onTap: () => _kickFocus(_passwordFocus),
|
|
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;
|
|
default:
|
|
return l10n.pttCapabilityExplainGoGlobalGeneric;
|
|
}
|
|
}
|
|
|
|
void _openExplanationSheet(BuildContext context) {
|
|
final l10n = AppL10n.of(context);
|
|
showModalBottomSheet<void>(
|
|
context: context,
|
|
showDragHandle: true,
|
|
builder: (sheetContext) {
|
|
final theme = Theme.of(sheetContext);
|
|
return SafeArea(
|
|
child: Padding(
|
|
padding: const EdgeInsets.fromLTRB(20, 4, 20, 24),
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
l10n.pttCapabilityExplainTitle,
|
|
style: theme.textTheme.titleMedium,
|
|
),
|
|
const SizedBox(height: 12),
|
|
Text(
|
|
l10n.pttCapabilityExplainFocusedHeading,
|
|
style: theme.textTheme.titleSmall,
|
|
),
|
|
const SizedBox(height: 4),
|
|
Text(
|
|
l10n.pttCapabilityExplainFocusedBody,
|
|
style: theme.textTheme.bodyMedium,
|
|
),
|
|
const SizedBox(height: 16),
|
|
Text(
|
|
_explainBodyForPlatform(l10n),
|
|
style: theme.textTheme.bodyMedium,
|
|
),
|
|
const SizedBox(height: 16),
|
|
Align(
|
|
alignment: AlignmentDirectional.centerEnd,
|
|
child: TextButton(
|
|
onPressed: () => Navigator.of(sheetContext).pop(),
|
|
child: Text(l10n.closeAction),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
},
|
|
);
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final l10n = AppL10n.of(context);
|
|
final theme = Theme.of(context);
|
|
final badgeLabel = l10n.pttCapabilityBadge(level, backendId);
|
|
final tooltipMessage = boundInputClass.isEmpty
|
|
? badgeLabel
|
|
: '$badgeLabel\n($boundInputClass)';
|
|
return Padding(
|
|
padding: const EdgeInsets.only(bottom: 6),
|
|
child: Tooltip(
|
|
message: tooltipMessage,
|
|
child: Row(
|
|
children: [
|
|
Icon(
|
|
_isFocused ? Icons.crop_free : Icons.public,
|
|
size: 14,
|
|
color: theme.colorScheme.onSurfaceVariant,
|
|
),
|
|
const SizedBox(width: 4),
|
|
Expanded(
|
|
child: Text(
|
|
badgeLabel,
|
|
style: theme.textTheme.bodySmall?.copyWith(
|
|
color: theme.colorScheme.onSurfaceVariant,
|
|
),
|
|
),
|
|
),
|
|
// Info icon only for L0Focused — the explanation sheet
|
|
// tells the user why their PTT may not work outside the
|
|
// app window and how to grant the permission. There is
|
|
// intentionally NO 'Configure' button here: the single
|
|
// configuration entry point is the Voice Bar's
|
|
// settings gear (onConfigure on `VoiceBar`). Having two
|
|
// identical bind-key entry points just confuses users.
|
|
if (_isFocused)
|
|
IconButton(
|
|
icon: const Icon(Icons.info_outline, size: 16),
|
|
tooltip: l10n.pttCapabilityExplainTitle,
|
|
visualDensity: VisualDensity.compact,
|
|
onPressed: () => _openExplanationSheet(context),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _SnapshotView extends StatelessWidget {
|
|
const _SnapshotView({
|
|
required this.snapshot,
|
|
required this.onJoinChannel,
|
|
});
|
|
|
|
final rust.BridgeSnapshot snapshot;
|
|
final ValueChanged<rust.BridgeChannel> onJoinChannel;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final l10n = AppL10n.of(context);
|
|
final theme = Theme.of(context);
|
|
|
|
// Channels arrive pre-sorted from the bridge: the Rust
|
|
// adapter (chanora_protocol::adapter::sort_channels_tree)
|
|
// emits them in root-first depth-first order following the
|
|
// TeamSpeak linked-list `order` predecessor pointers. We
|
|
// therefore trust the server order verbatim — re-sorting by
|
|
// `order` numerically here would re-introduce the bug fixed
|
|
// in that adapter (TS3 `order` is NOT a numeric rank).
|
|
final channels = snapshot.channels;
|
|
|
|
// Compute each channel's depth in the parent hierarchy so we
|
|
// can render it indented. Root channels (parent == 0) are
|
|
// depth 0; their children depth 1; and so on. Depth caps at
|
|
// 6 to keep the indent visually bounded on deeply-nested
|
|
// servers (the cap plateaus silently — no glyph; the channel
|
|
// is still tappable and its real depth lives in the data).
|
|
final depthById = <BigInt, int>{};
|
|
final zero = BigInt.zero;
|
|
for (final ch in channels) {
|
|
if (ch.parent == zero) {
|
|
depthById[ch.id] = 0;
|
|
} else {
|
|
final parentDepth = depthById[ch.parent] ?? 0;
|
|
depthById[ch.id] = (parentDepth + 1).clamp(0, 6);
|
|
}
|
|
}
|
|
const indentPerLevel = 18.0;
|
|
|
|
final byChannel = <BigInt, List<rust.BridgeClient>>{};
|
|
for (final c in snapshot.clients) {
|
|
byChannel.putIfAbsent(c.channel, () => []).add(c);
|
|
}
|
|
|
|
return ListView(
|
|
children: [
|
|
Text(
|
|
l10n.countChannelsAndClients(
|
|
snapshot.channels.length,
|
|
snapshot.clients.length,
|
|
),
|
|
style: theme.textTheme.bodyMedium,
|
|
),
|
|
if (snapshot.welcomeMessage.isNotEmpty) ...[
|
|
const SizedBox(height: 8),
|
|
Container(
|
|
padding: const EdgeInsets.all(8),
|
|
decoration: BoxDecoration(
|
|
color: theme.colorScheme.surfaceContainerHighest,
|
|
borderRadius: BorderRadius.circular(6),
|
|
),
|
|
child: Text(snapshot.welcomeMessage, style: theme.textTheme.bodySmall),
|
|
),
|
|
],
|
|
const Divider(height: 24),
|
|
Text(l10n.channelsHeading, style: theme.textTheme.titleMedium),
|
|
const SizedBox(height: 4),
|
|
for (final ch in channels) ...[
|
|
Padding(
|
|
padding: EdgeInsets.only(
|
|
left: (depthById[ch.id] ?? 0) * indentPerLevel,
|
|
),
|
|
child: ListTile(
|
|
dense: true,
|
|
leading: const Icon(Icons.tag),
|
|
title: Text(ch.name),
|
|
subtitle: Text('id=${ch.id} parent=${ch.parent}'),
|
|
trailing: IconButton(
|
|
icon: const Icon(Icons.login),
|
|
tooltip: l10n.joinChannelAction,
|
|
onPressed: () => onJoinChannel(ch),
|
|
),
|
|
onTap: () => onJoinChannel(ch),
|
|
),
|
|
),
|
|
for (final cl in byChannel[ch.id] ?? const <rust.BridgeClient>[])
|
|
Padding(
|
|
padding: EdgeInsets.only(
|
|
left: ((depthById[ch.id] ?? 0) * indentPerLevel) + 64,
|
|
),
|
|
child: ListTile(
|
|
dense: true,
|
|
visualDensity: VisualDensity.compact,
|
|
leading: const Icon(Icons.person, size: 18),
|
|
title: Text(cl.name),
|
|
),
|
|
),
|
|
],
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Result of a successful PTT binding capture. Carries only the
|
|
/// coarse input class and an opaque platform-key label —
|
|
/// per DEC-027 the actual key code never crosses out of the
|
|
/// capture dialog.
|
|
class _CapturedBinding {
|
|
const _CapturedBinding({required this.inputClass, required this.platformKey});
|
|
final rust.BridgePttInputClass inputClass;
|
|
final String platformKey;
|
|
}
|
|
|
|
/// Focus-scoped dialog that captures the next key press or mouse
|
|
/// side-button click and returns it as a `_CapturedBinding`. The
|
|
/// captured value is the platform-neutral `LogicalKeyboardKey`
|
|
/// debug label or `"mouse-side-button:{button}"`; the platform
|
|
/// backend interprets the string and never logs it.
|
|
class _PttBindingCaptureDialog extends StatefulWidget {
|
|
const _PttBindingCaptureDialog();
|
|
|
|
@override
|
|
State<_PttBindingCaptureDialog> createState() =>
|
|
_PttBindingCaptureDialogState();
|
|
}
|
|
|
|
class _PttBindingCaptureDialogState extends State<_PttBindingCaptureDialog> {
|
|
final FocusNode _focusNode = FocusNode();
|
|
String? _captured;
|
|
rust.BridgePttInputClass _capturedClass = rust.BridgePttInputClass.none;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
_focusNode.requestFocus();
|
|
});
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_focusNode.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
KeyEventResult _onKeyEvent(FocusNode node, KeyEvent event) {
|
|
if (event is! KeyDownEvent) return KeyEventResult.ignored;
|
|
final label = _displayLabelForKey(event.logicalKey);
|
|
if (label == null) return KeyEventResult.ignored;
|
|
setState(() {
|
|
_captured = label;
|
|
_capturedClass = rust.BridgePttInputClass.keyboard;
|
|
});
|
|
return KeyEventResult.handled;
|
|
}
|
|
|
|
/// Translate a [LogicalKeyboardKey] into the platform-neutral
|
|
/// label string the bridge expects (matching the entries in
|
|
/// `crates/chanora_audio/src/ptt_backends/windows_keymap.rs`).
|
|
///
|
|
/// `LogicalKeyboardKey.keyLabel` returns `" "` for Space, empty
|
|
/// for pure modifiers (shift / ctrl / alt / meta), and localised
|
|
/// strings for some special keys; we normalise to the canonical
|
|
/// English label so the badge displays something readable AND so
|
|
/// the Windows backend's keymap can resolve to a `VK_*`. Pure
|
|
/// modifier keys are intentionally rejected — chording into the
|
|
/// real binding (e.g. Ctrl+Shift+M) is supported by ignoring the
|
|
/// individual modifier-down events.
|
|
String? _displayLabelForKey(LogicalKeyboardKey k) {
|
|
// Whitespace / common control keys whose keyLabel is unhelpful.
|
|
if (k == LogicalKeyboardKey.space) return 'Space';
|
|
if (k == LogicalKeyboardKey.enter || k == LogicalKeyboardKey.numpadEnter) {
|
|
return 'Enter';
|
|
}
|
|
if (k == LogicalKeyboardKey.tab) return 'Tab';
|
|
if (k == LogicalKeyboardKey.escape) return 'Escape';
|
|
if (k == LogicalKeyboardKey.backspace) return 'Backspace';
|
|
if (k == LogicalKeyboardKey.delete) return 'Delete';
|
|
if (k == LogicalKeyboardKey.insert) return 'Insert';
|
|
if (k == LogicalKeyboardKey.home) return 'Home';
|
|
if (k == LogicalKeyboardKey.end) return 'End';
|
|
if (k == LogicalKeyboardKey.pageUp) return 'Page Up';
|
|
if (k == LogicalKeyboardKey.pageDown) return 'Page Down';
|
|
if (k == LogicalKeyboardKey.arrowUp) return 'Arrow Up';
|
|
if (k == LogicalKeyboardKey.arrowDown) return 'Arrow Down';
|
|
if (k == LogicalKeyboardKey.arrowLeft) return 'Arrow Left';
|
|
if (k == LogicalKeyboardKey.arrowRight) return 'Arrow Right';
|
|
// Pure modifier keys are not bindable on their own (user can
|
|
// still chord by pressing a non-modifier while holding them).
|
|
if (k == LogicalKeyboardKey.shift ||
|
|
k == LogicalKeyboardKey.shiftLeft ||
|
|
k == LogicalKeyboardKey.shiftRight ||
|
|
k == LogicalKeyboardKey.control ||
|
|
k == LogicalKeyboardKey.controlLeft ||
|
|
k == LogicalKeyboardKey.controlRight ||
|
|
k == LogicalKeyboardKey.alt ||
|
|
k == LogicalKeyboardKey.altLeft ||
|
|
k == LogicalKeyboardKey.altRight ||
|
|
k == LogicalKeyboardKey.meta ||
|
|
k == LogicalKeyboardKey.metaLeft ||
|
|
k == LogicalKeyboardKey.metaRight ||
|
|
k == LogicalKeyboardKey.capsLock ||
|
|
k == LogicalKeyboardKey.numLock ||
|
|
k == LogicalKeyboardKey.scrollLock) {
|
|
return null;
|
|
}
|
|
// Fall back to keyLabel for letters, digits, function keys,
|
|
// numpad digits, and punctuation. Trim whitespace as a final
|
|
// belt-and-braces guard.
|
|
final fallback = k.keyLabel.trim();
|
|
if (fallback.isEmpty) return null;
|
|
return fallback;
|
|
}
|
|
|
|
void _captureMouseSideButton(int button) {
|
|
setState(() {
|
|
_captured = 'mouse-side-button:$button';
|
|
_capturedClass = rust.BridgePttInputClass.mouseSideButton;
|
|
});
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final l10n = AppL10n.of(context);
|
|
final theme = Theme.of(context);
|
|
return AlertDialog(
|
|
title: Text(l10n.pttConfigureTitle),
|
|
content: SizedBox(
|
|
width: 360,
|
|
child: Focus(
|
|
focusNode: _focusNode,
|
|
onKeyEvent: _onKeyEvent,
|
|
autofocus: true,
|
|
child: Listener(
|
|
// HitTestBehavior.opaque so the Listener fires for
|
|
// mouse side-button presses anywhere inside the
|
|
// dialog's bounds, not just on top of a child widget.
|
|
// Default (deferToChild) only delivers events when a
|
|
// child's hit-test claims them; the surrounding padding
|
|
// and Container backgrounds don't, so the user had to
|
|
// hover over the captured-result Container before the
|
|
// side-button click would register. Opaque means the
|
|
// entire dialog content area receives PointerDown
|
|
// events.
|
|
behavior: HitTestBehavior.opaque,
|
|
// Capture mouse side buttons (4 and 5) without
|
|
// capturing primary / secondary clicks which the
|
|
// user uses to interact with the dialog itself. The
|
|
// raw button bitmask values are stable per Flutter's
|
|
// `PointerEvent.buttons` documentation (back = 0x08,
|
|
// forward = 0x10).
|
|
onPointerDown: (e) {
|
|
const int back = 0x08;
|
|
const int forward = 0x10;
|
|
if (e.buttons == back || e.buttons == forward) {
|
|
_captureMouseSideButton(e.buttons);
|
|
}
|
|
},
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
l10n.pttConfigurePrompt,
|
|
style: theme.textTheme.bodyMedium,
|
|
),
|
|
const SizedBox(height: 12),
|
|
Container(
|
|
padding: const EdgeInsets.symmetric(
|
|
vertical: 12,
|
|
horizontal: 16,
|
|
),
|
|
decoration: BoxDecoration(
|
|
color: theme.colorScheme.surfaceContainerHighest,
|
|
borderRadius: BorderRadius.circular(6),
|
|
),
|
|
child: Text(
|
|
_captured == null
|
|
? l10n.pttConfigureWaiting
|
|
: '${l10n.pttConfigureCaptured}: $_captured',
|
|
style: theme.textTheme.bodyMedium?.copyWith(
|
|
fontFamily: 'monospace',
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(height: 12),
|
|
Text(
|
|
l10n.pttConfigurePrivacyNote,
|
|
style: theme.textTheme.bodySmall?.copyWith(
|
|
color: theme.colorScheme.onSurfaceVariant,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () => Navigator.of(context).pop(),
|
|
child: Text(l10n.closeAction),
|
|
),
|
|
FilledButton(
|
|
onPressed: _captured == null
|
|
? null
|
|
: () => Navigator.of(context).pop(
|
|
_CapturedBinding(
|
|
inputClass: _capturedClass,
|
|
platformKey: _captured!,
|
|
),
|
|
),
|
|
child: Text(l10n.pttConfigureSaveAction),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|