Two distinct fixes prompted by user reports from the iPhone build:
1. 'strange text on Chanora (RFLOWED BY)' \u2014 the Flutter debug
overlay's 'OVERFLOWED BY N PIXELS' strip was appearing next to
the AppBar title because the title Row ('Chanora' + channel
pill) plus 5-6 trailing IconButton actions exceeded a typical
iPhone AppBar width. User saw the strip clipped to '...RFLOWED
BY...' since only its end fit on screen.
_AppBarTitle now drops the 'Chanora' label on narrow widths
(<840 dp). Title shows only the channel pill when in voice
channel; the user already knows they're in Chanora because
they just opened it. Wide widths (tablet/desktop, >= 840 dp)
keep the full 'Chanora \u00b7 #channel-pill' title because there's
room. Eliminates the overflow.
Note: the OVERFLOWED-BY strip only renders in debug builds
anyway; release builds suppress the overlay. But the
underlying Row overflow was a real layout bug worth fixing.
2. First-tap TextField still failed on iPhone after the earlier
FocusNode + TextField.onTap fix. Root cause: TextField.onTap
fires AFTER the gesture-arena resolves, so if the enclosing
SingleChildScrollView wins the arena (which it does on iOS
for the very first tap), the focus request never fires.
Wrap each connect-form TextField in a Listener with
HitTestBehavior.translucent and onPointerDown: requestFocus.
Listener fires synchronously on PointerDownEvent BEFORE arena
resolution, so even if the scrollable would have won the arena
we have already grabbed focus. Translucent means the pointer
ALSO propagates down to the TextField so its normal touch
handling still runs (text selection / cursor placement).
_focusOnTap helper added; wraps all three TextFields
(host, nick, password).
flutter analyze: clean (6 pre-existing Radio.groupValue infos).
flutter build ios --release --no-codesign: 18.4 s, Runner.app
30.0 MB.
1890 lines
65 KiB
Dart
1890 lines
65 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 details modal sheet (Plan E status chip
|
|
/// trigger). Surfaces level meter + TX/RX counts + capability
|
|
/// badge in a Material 3 bottom sheet. Mode + bind-key + release-
|
|
/// tail configuration still live in [VoiceSettingsDialog] reached
|
|
/// via the AppBar gear icon — there is exactly one configuration
|
|
/// surface.
|
|
Future<void> _onOpenVoiceDetailsSheet() async {
|
|
await showVoiceDetailsSheet(
|
|
context,
|
|
audioStats: _audioStats,
|
|
transmitMode: _transmitMode,
|
|
releaseTailMs: _releaseTailMs,
|
|
pttBoundKeyLabel: _pttBoundKeyLabel,
|
|
pttLevel: _pttLevel,
|
|
pttBackendId: _pttBackendId,
|
|
pttBoundInputClass: _pttBoundInputClass,
|
|
isTouchOnly: _isTouchOnlyPttHost,
|
|
);
|
|
}
|
|
|
|
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 + voice
|
|
// settings 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.
|
|
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.voiceSettingsTitle,
|
|
icon: const Icon(Icons.tune),
|
|
onPressed: _onOpenVoiceSettings,
|
|
),
|
|
],
|
|
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();
|
|
|
|
Widget _focusOnTap(Widget child, FocusNode node) {
|
|
return Listener(
|
|
behavior: HitTestBehavior.translucent,
|
|
onPointerDown: (_) {
|
|
if (!node.hasFocus) {
|
|
node.requestFocus();
|
|
}
|
|
},
|
|
child: child,
|
|
);
|
|
}
|
|
|
|
@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: [
|
|
_focusOnTap(
|
|
TextField(
|
|
controller: widget.hostCtl,
|
|
focusNode: _hostFocus,
|
|
onTap: () => _hostFocus.requestFocus(),
|
|
textInputAction: TextInputAction.next,
|
|
autocorrect: false,
|
|
enableSuggestions: false,
|
|
decoration: InputDecoration(
|
|
labelText: l10n.fieldServerHost,
|
|
border: const OutlineInputBorder(),
|
|
),
|
|
),
|
|
_hostFocus,
|
|
),
|
|
const SizedBox(height: 8),
|
|
_focusOnTap(
|
|
TextField(
|
|
controller: widget.nickCtl,
|
|
focusNode: _nickFocus,
|
|
onTap: () => _nickFocus.requestFocus(),
|
|
textInputAction: TextInputAction.next,
|
|
autocorrect: false,
|
|
enableSuggestions: false,
|
|
decoration: InputDecoration(
|
|
labelText: l10n.fieldNickname,
|
|
border: const OutlineInputBorder(),
|
|
),
|
|
),
|
|
_nickFocus,
|
|
),
|
|
const SizedBox(height: 8),
|
|
_focusOnTap(
|
|
TextField(
|
|
controller: widget.passwordCtl,
|
|
focusNode: _passwordFocus,
|
|
onTap: () => _passwordFocus.requestFocus(),
|
|
obscureText: true,
|
|
textInputAction: TextInputAction.done,
|
|
autocorrect: false,
|
|
enableSuggestions: false,
|
|
decoration: InputDecoration(
|
|
labelText: l10n.fieldServerPassword,
|
|
helperText: l10n.fieldServerPasswordHelp,
|
|
border: const OutlineInputBorder(),
|
|
),
|
|
),
|
|
_passwordFocus,
|
|
),
|
|
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),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|