Files
chanora/apps/chanora_flutter/lib/main.dart
T
EdisonJwa 278df25fd7 feat(ui,ptt): on-screen touch-and-hold PTT button for iOS / iPadOS / Android
iOS / iPadOS / Android have no hardware keyboard for the user to
bind a PTT key on. Up to now the VoiceBar showed only a
'Push to talk: bound key —' hint that didn't lead anywhere usable.

Add a touch-and-hold on-screen PTT button rendered only on
touch-only platforms (Platform.isIOS || Platform.isAndroid; web
hosts and desktop continue to use the hardware-key path
unchanged).

apps/chanora_flutter/lib/widgets/voice_bar.dart:
  * New module-private `_isTouchOnlyPttHost` predicate.
  * VoiceBar gains an `onPttHeldChanged: ValueChanged<bool>`
    constructor param. Desktop callers wire it but never invoke it
    because the button is not rendered there.
  * Row 3 (the PTT-only secondary content) now branches:
    - on touch-only hosts -> renders the new `_PttHoldButton` plus
      a small release-tail hint underneath
    - on hardware-keyboard hosts -> renders the same bound-key +
      release-tail one-liner as before, unchanged.
  * New `_PttHoldButton` StatefulWidget. Uses a single
    GestureDetector covering onTapDown / onTapUp / onTapCancel /
    onPanDown / onPanEnd / onPanCancel so the held edges fire on
    finger-down and the released edge fires when the user lifts
    OR drags off OR another gesture in the arena wins. Visual
    feedback mirrors the level-meter active flag.

apps/chanora_flutter/lib/main.dart:
  * New `_onOnscreenPttHeldChanged(bool held)` method that calls
    `rust.setPtt(active: held)`. The bridge's set_ptt routes the
    edge through the same release-tail timer + transmit-mode
    selector that desktop hardware keys use (SDD-096 / SAD-083),
    so behaviour parity is preserved.

flutter analyze: clean (6 pre-existing Radio.groupValue infos).
flutter build ios --release --no-codesign: clean (Runner.app 29.9 MB).

DEC-025: iPhone + iPad + Android in scope; this commit makes PTT
mode actually usable on those platforms. The 'Focused' capability
badge wording in ios-p0-acceptance.md / ipad-p0-acceptance.md
already documents the on-screen button as the only PTT input;
this commit makes that documentation true.
2026-05-16 15:15:38 +08:00

1622 lines
54 KiB
Dart

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