feat(ui): adaptive 3-panel layout, chat panel switching, audio metering fix

- Add responsive breakpoints (compact <600, medium 600-1023, expanded >=1024)
- Add ViewportInfo InheritedWidget for layout-aware descendants
- Add inline ChatPanel (380dp right column) for expanded desktop layout
- Add channel right-click context menu with Chat option for in-place switching
- Add per-target draft persistence via restoredDraft/onDraftChanged callbacks
- Fix header chat button to switch to current voice channel when panel open
- Fix close = dismiss (preserves last target and draft for reopen)
- Add unread dot indicator on channel tiles when chat is closed
- Fix audio regression: decimate dBFS computation to every 3rd callback (~31 Hz)
  to avoid buffer underruns on macOS CoreAudio real-time thread
- Add tools/build-macos.sh release build script (7-step process)
- Add chat panel switching implementation plan and 3-panel design spec

Tests: 183 passed, 2 skipped. Flutter analyze clean.
This commit is contained in:
Edison Jwa
2026-06-07 23:12:07 +09:00
parent e7f7c55b30
commit 5e8b7915db
14 changed files with 2187 additions and 283 deletions
+402 -223
View File
@@ -16,6 +16,8 @@ import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_foreground_task/flutter_foreground_task.dart';
import 'design/breakpoints.dart';
import 'design/viewport_info.dart';
import 'l10n/generated/app_localizations.dart';
import 'services/android_permissions_service.dart';
import 'services/app_bootstrap.dart';
@@ -31,12 +33,13 @@ import 'services/ui_preferences_service.dart';
import 'src/rust/api.dart' as rust;
import 'src/rust/frb_generated.dart';
import 'src/rust/lib.dart' as rust_err;
import 'widgets/permission_state_banner.dart';
import 'widgets/audio_processing_config_state.dart';
import 'widgets/chat_panel.dart';
import 'widgets/chat_views.dart';
import 'widgets/client_info_sheet.dart';
import 'widgets/connect_widgets.dart';
import 'widgets/input_dialogs.dart';
import 'widgets/permission_state_banner.dart';
import 'widgets/snapshot_view.dart';
import 'widgets/voice_platform.dart';
import 'widgets/voice_bar.dart';
@@ -301,8 +304,6 @@ class _ReceivedPoke {
}
class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
static const _wideBreakpoint = 600.0;
final _hostCtl = TextEditingController(text: 'cn.teamspeak.app');
final _nickCtl = TextEditingController(text: 'ChanoraBeta');
final _passwordCtl = TextEditingController();
@@ -368,6 +369,37 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
final ValueNotifier<int> _chatFeedRevision = ValueNotifier(0);
int _chatUnread = 0;
bool _chatOpen = false;
rust.BridgeMessageTarget? _inlineChatTarget;
String _inlineChatClientName = '';
bool _inlineChatCollapseNoticeShown = false;
/// Per-target draft text. Populated when switching away from a conversation
/// so the user's unfinished message is preserved.
final Map<String, String> _chatDrafts = {};
/// Channel IDs that have unread chat messages (used for dot indicators).
Set<BigInt> get _unreadChannelIds {
if (_chatOpen) return const {};
final ids = <BigInt>{};
for (final entry in _chatMessages) {
if (!entry.countsTowardUnread || entry.isSelf) continue;
final target = entry.target;
if (target is rust.BridgeMessageTarget_Channel) {
// Channel target has no ID payload — it means "current channel".
// We can't distinguish per-channel without the channel ID in the target.
// For now, if there are any unread channel messages, mark the current voice channel.
if (_currentVoiceChannelId != null) {
ids.add(_currentVoiceChannelId!);
}
}
}
return ids;
}
/// The last chat target before the panel was closed. Used to restore the
/// previous conversation when the user reopens chat.
rust.BridgeMessageTarget? _lastDismissedTarget;
String _lastDismissedClientName = '';
final ValueNotifier<List<_ReceivedPoke>> _pokeSnackBarPokes = ValueNotifier(
const [],
);
@@ -1613,16 +1645,86 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
_reconnectAttempt = null;
_reconnectDelay = null;
_chatMessages.clear();
_chatDrafts.clear();
_chatUnread = 0;
_chatOpen = false;
_inlineChatTarget = null;
_inlineChatClientName = '';
_inlineChatCollapseNoticeShown = false;
_lastDismissedTarget = null;
_lastDismissedClientName = '';
_notifyChatFeedChanged();
}
/// Converts a [rust.BridgeMessageTarget] to a stable string key for draft storage.
String _draftKeyForTarget(rust.BridgeMessageTarget target) {
return switch (target) {
rust.BridgeMessageTarget_Server() => 'server',
rust.BridgeMessageTarget_Channel() => 'channel',
rust.BridgeMessageTarget_Client(:final field0) => 'client:$field0',
rust.BridgeMessageTarget_Poke(:final field0) => 'poke:$field0',
};
}
/// Saves the current draft text for the current inline chat target.
void _saveCurrentDraft() {
// Drafts are continuously saved via onDraftChanged callback in Task 4.
// This method exists as an explicit save point.
final target = _inlineChatTarget;
if (target == null) return;
final key = _draftKeyForTarget(target);
if (_chatDrafts.containsKey(key)) {
_chatDrafts[key] = _chatDrafts[key] ?? '';
}
}
Future<void> _onOpenChat({
rust.BridgeMessageTarget? target,
String clientName = '',
}) async {
final initialSnapshot = _snapshot!;
// Resolve the new target.
// If no target passed and panel is already open, switch to current voice channel.
// If no target passed and panel is closed, resolve from history or default.
rust.BridgeMessageTarget newTarget;
if (target != null) {
newTarget = target;
} else if (_chatOpen && _currentVoiceChannelId != null) {
newTarget = const rust.BridgeMessageTarget.channel();
} else if (_inlineChatTarget != null) {
newTarget = _inlineChatTarget!;
} else {
newTarget =
resolveInitialChatTarget(
messages: _chatMessages,
currentVoiceChannelId: _currentVoiceChannelId,
) ??
const rust.BridgeMessageTarget.server();
}
final newClientName = clientName.isNotEmpty
? clientName
: (newTarget == _inlineChatTarget)
? _inlineChatClientName
: (newTarget == _lastDismissedTarget)
? _lastDismissedClientName
: '';
final isExpanded =
layoutClassFromWidth(MediaQuery.sizeOf(context).width) ==
LayoutClass.expanded;
if (isExpanded) {
setState(() {
_saveCurrentDraft();
_chatUnread = 0;
_chatOpen = true;
_inlineChatTarget = newTarget;
_inlineChatClientName = newClientName;
_inlineChatCollapseNoticeShown = false;
});
return;
}
setState(() {
_chatUnread = 0;
_chatOpen = true;
@@ -1635,13 +1737,8 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
messagesSource: () => _chatMessages,
snapshotSource: () => _snapshot ?? initialSnapshot,
refreshListenable: _chatFeedRevision,
initialTarget:
target ??
resolveInitialChatTarget(
messages: _chatMessages,
currentVoiceChannelId: _currentVoiceChannelId,
),
initialClientName: clientName,
initialTarget: newTarget,
initialClientName: newClientName,
onTs3ServerLink: _onTs3ServerLink,
),
),
@@ -1649,6 +1746,48 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
if (mounted) setState(() => _chatOpen = false);
}
void _closeInlineChat() {
setState(() {
_saveCurrentDraft();
_lastDismissedTarget = _inlineChatTarget;
_lastDismissedClientName = _inlineChatClientName;
_chatOpen = false;
// Do NOT null _inlineChatTarget — remember it for reopen.
});
}
void _handleInlineChatViewport(LayoutClass layoutClass) {
if (_inlineChatTarget == null) return;
if (layoutClass == LayoutClass.expanded) {
_inlineChatCollapseNoticeShown = false;
if (!_chatOpen) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted && _inlineChatTarget != null) {
setState(() => _chatOpen = true);
}
});
}
return;
}
if (_chatOpen) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted && _inlineChatTarget != null) {
setState(() => _chatOpen = false);
}
});
}
if (_inlineChatCollapseNoticeShown) return;
_inlineChatCollapseNoticeShown = true;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted || _inlineChatTarget == null) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Tap the chat button to continue your conversation'),
),
);
});
}
Future<void> _onOpenClientInfo(rust.BridgeClient client) async {
await showModalBottomSheet<void>(
context: context,
@@ -1757,7 +1896,7 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
const side = 16.0;
var bottom = 16.0;
final wideConnectedLayout =
MediaQuery.sizeOf(context).width >= _wideBreakpoint &&
MediaQuery.sizeOf(context).width >= ChanoraBreakpoints.medium &&
_serverReachable &&
_snapshot != null;
@@ -2245,7 +2384,8 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
)
: headerTitle;
final compactIdleChrome =
!_serverReachable && MediaQuery.sizeOf(context).width < _wideBreakpoint;
!_serverReachable &&
MediaQuery.sizeOf(context).width < ChanoraBreakpoints.medium;
final compactIdleHeader = Row(
children: [
Expanded(
@@ -2277,8 +2417,10 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
final bodyContent = LayoutBuilder(
builder: (ctx, bodyConstraints) {
final layoutClass = layoutClassFromWidth(bodyConstraints.maxWidth);
_handleInlineChatViewport(layoutClass);
final isWideSnapshot =
bodyConstraints.maxWidth >= _wideBreakpoint &&
bodyConstraints.maxWidth >= ChanoraBreakpoints.medium &&
_serverReachable &&
_snapshot != null;
final banner = Container(
@@ -2292,237 +2434,274 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
style: TextStyle(color: theme.colorScheme.onTertiaryContainer),
),
);
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (!isWideSnapshot) ...[banner, const SizedBox(height: 12)],
if (!_serverReachable)
Row(
children: [
Icon(connTokens.icon, size: 18, color: connTokens.color),
const SizedBox(width: 6),
Expanded(
child: Text(
statusText,
softWrap: true,
style: theme.textTheme.titleMedium,
),
),
],
),
if (_lostReason != null || _reconnectAttempt != null) ...[
const SizedBox(height: 8),
Container(
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color:
connTokens.background ?? theme.colorScheme.errorContainer,
borderRadius: BorderRadius.circular(8),
),
child: Row(
return ViewportInfo(
layoutClass: layoutClass,
width: bodyConstraints.maxWidth,
height: bodyConstraints.maxHeight,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (!isWideSnapshot) ...[banner, const SizedBox(height: 12)],
if (!_serverReachable)
Row(
children: [
SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(
strokeWidth: 2,
color: theme.colorScheme.onErrorContainer,
),
),
const SizedBox(width: 10),
Icon(connTokens.icon, size: 18, color: connTokens.color),
const SizedBox(width: 6),
Expanded(
child: Text(
_reconnectAttempt != null
? l10n.statusReconnecting(
_reconnectAttempt!,
_reconnectDelay ?? 0,
)
: l10n.statusConnectionLost(_lostReason ?? ''),
style: TextStyle(
color: theme.colorScheme.onErrorContainer,
),
statusText,
softWrap: true,
style: theme.textTheme.titleMedium,
),
),
],
),
),
],
const SizedBox(height: 12),
if (_phase == ConnectionPhase.idle ||
_phase == ConnectionPhase.disconnected) ...[
Expanded(
child: AnimatedPadding(
duration: const Duration(milliseconds: 180),
curve: Curves.easeOut,
padding: EdgeInsets.only(
bottom: MediaQuery.viewInsetsOf(ctx).bottom,
if (_lostReason != null || _reconnectAttempt != null) ...[
const SizedBox(height: 8),
Container(
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color:
connTokens.background ??
theme.colorScheme.errorContainer,
borderRadius: BorderRadius.circular(8),
),
child: SingleChildScrollView(
keyboardDismissBehavior:
ScrollViewKeyboardDismissBehavior.onDrag,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
ConnectForm(
hostCtl: _hostCtl,
nickCtl: _nickCtl,
passwordCtl: _passwordCtl,
onConnect: () => _onConnect(),
onAddBookmark: _onAddCurrentBookmark,
child: Row(
children: [
SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(
strokeWidth: 2,
color: theme.colorScheme.onErrorContainer,
),
const SizedBox(height: 16),
BookmarkList(
bookmarks: _bookmarks,
onConnect: _onUseBookmark,
onDelete: _onDeleteBookmark,
),
const SizedBox(width: 10),
Expanded(
child: Text(
_reconnectAttempt != null
? l10n.statusReconnecting(
_reconnectAttempt!,
_reconnectDelay ?? 0,
)
: l10n.statusConnectionLost(_lostReason ?? ''),
style: TextStyle(
color: theme.colorScheme.onErrorContainer,
),
),
],
),
),
],
),
),
),
] else if (_phase == ConnectionPhase.connecting ||
awaitingServerSnapshot) ...[
Expanded(
child: Center(
child: Padding(
padding: const EdgeInsets.all(32),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const CircularProgressIndicator(),
const SizedBox(height: 16),
Text(
statusText,
textAlign: TextAlign.center,
style: theme.textTheme.titleMedium,
),
],
],
const SizedBox(height: 12),
if (_phase == ConnectionPhase.idle ||
_phase == ConnectionPhase.disconnected) ...[
Expanded(
child: AnimatedPadding(
duration: const Duration(milliseconds: 180),
curve: Curves.easeOut,
padding: EdgeInsets.only(
bottom: MediaQuery.viewInsetsOf(ctx).bottom,
),
),
),
),
] else if (_serverReachable && _snapshot != null) ...[
Expanded(
child: LayoutBuilder(
builder: (ctx, constraints) {
final voiceBar = VoiceBar(
inChannel: _inChannel,
transmitMode: _transmitMode,
hardMute: _hardMute,
outputMuted: _outputMuted,
talkPowerBlocked: _hardMuteByTalkPower,
releaseTailMs: _releaseTailMs,
channelName: snapshotChannelName(
_snapshot,
_currentVoiceChannelId,
),
audioStats: _audioStats,
inputLevel: _inputLevel,
pttLevel: _pttLevel,
pttBackendId: _pttBackendId,
pttBoundInputClass: _pttBoundInputClass,
pttBoundKeyLabel: _pttBoundKeyLabel,
onConfigure: _onOpenVoiceSettings,
onPttHeldChanged: _onOnscreenPttHeldChanged,
);
// SDD-106 §2/§3 + SRS-209 + SRS-164: listen-only
// banner. Self-hides on granted / unknown.
final permissionBanner =
PermissionStateBanner.fromCallbacks(
recordAudioState: _activeRecordAudioState,
ensureRecordAudio: _ensureActiveRecordAudio,
openAppSettings: _openActivePermissionSettings,
);
final snapshotView = SnapshotView(
snapshot: _snapshot!,
audioStats: _audioStats,
currentVoiceChannelId: _currentVoiceChannelId,
pendingVoiceChannelId: _pendingVoiceChannelId,
localInputMuted: _inputMuted || _hardMute,
localOutputMuted: _outputMuted,
hasJoinPending: _pendingVoiceChannelId != null,
canJoinVoiceChannel: _canJoinVoiceChannel,
onJoinChannel: (ch) => _onJoinChannel(ch),
onJoinChannelWithPassword: (ch) =>
_onJoinChannel(ch, askForPassword: true),
enableClientLongPressMenu: isTouchOnlyPttHost,
onOpenClientInfo: (client) =>
unawaited(_onOpenClientInfo(client)),
onOpenClientChat: (client) => unawaited(
_onOpenChat(
target: rust.BridgeMessageTarget.client(client.id),
clientName: client.name,
),
),
onOpenClientPoke: (client) => unawaited(
_onOpenChat(
target: rust.BridgeMessageTarget.poke(client.id),
clientName: client.name,
),
),
onTs3ServerLink: _onTs3ServerLink,
);
final ownClientState = _ownClientState;
const voiceBarWidthWide = 320.0;
if (constraints.maxWidth >= _wideBreakpoint) {
return Row(
child: SingleChildScrollView(
keyboardDismissBehavior:
ScrollViewKeyboardDismissBehavior.onDrag,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
SizedBox(
width: voiceBarWidthWide,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
banner,
const SizedBox(height: 12),
permissionBanner,
voiceBar,
],
),
ConnectForm(
hostCtl: _hostCtl,
nickCtl: _nickCtl,
passwordCtl: _passwordCtl,
onConnect: () => _onConnect(),
onAddBookmark: _onAddCurrentBookmark,
),
const SizedBox(width: 12),
const SizedBox(height: 16),
BookmarkList(
bookmarks: _bookmarks,
onConnect: _onUseBookmark,
onDelete: _onDeleteBookmark,
),
],
),
),
),
),
] else if (_phase == ConnectionPhase.connecting ||
awaitingServerSnapshot) ...[
Expanded(
child: Center(
child: Padding(
padding: const EdgeInsets.all(32),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const CircularProgressIndicator(),
const SizedBox(height: 16),
Text(
statusText,
textAlign: TextAlign.center,
style: theme.textTheme.titleMedium,
),
],
),
),
),
),
] else if (_serverReachable && _snapshot != null) ...[
Expanded(
child: LayoutBuilder(
builder: (ctx, constraints) {
final voiceBar = VoiceBar(
inChannel: _inChannel,
transmitMode: _transmitMode,
hardMute: _hardMute,
outputMuted: _outputMuted,
talkPowerBlocked: _hardMuteByTalkPower,
releaseTailMs: _releaseTailMs,
channelName: snapshotChannelName(
_snapshot,
_currentVoiceChannelId,
),
audioStats: _audioStats,
inputLevel: _inputLevel,
pttLevel: _pttLevel,
pttBackendId: _pttBackendId,
pttBoundInputClass: _pttBoundInputClass,
pttBoundKeyLabel: _pttBoundKeyLabel,
onConfigure: _onOpenVoiceSettings,
onPttHeldChanged: _onOnscreenPttHeldChanged,
);
// SDD-106 §2/§3 + SRS-209 + SRS-164: listen-only
// banner. Self-hides on granted / unknown.
final permissionBanner =
PermissionStateBanner.fromCallbacks(
recordAudioState: _activeRecordAudioState,
ensureRecordAudio: _ensureActiveRecordAudio,
openAppSettings: _openActivePermissionSettings,
);
final snapshotView = SnapshotView(
snapshot: _snapshot!,
audioStats: _audioStats,
currentVoiceChannelId: _currentVoiceChannelId,
pendingVoiceChannelId: _pendingVoiceChannelId,
localInputMuted: _inputMuted || _hardMute,
localOutputMuted: _outputMuted,
hasJoinPending: _pendingVoiceChannelId != null,
canJoinVoiceChannel: _canJoinVoiceChannel,
unreadChannelIds: _unreadChannelIds,
onJoinChannel: (ch) => _onJoinChannel(ch),
onJoinChannelWithPassword: (ch) =>
_onJoinChannel(ch, askForPassword: true),
enableClientLongPressMenu: isTouchOnlyPttHost,
onOpenClientInfo: (client) =>
unawaited(_onOpenClientInfo(client)),
onOpenClientChat: (client) => unawaited(
_onOpenChat(
target: rust.BridgeMessageTarget.client(client.id),
clientName: client.name,
),
),
onOpenClientPoke: (client) => unawaited(
_onOpenChat(
target: rust.BridgeMessageTarget.poke(client.id),
clientName: client.name,
),
),
onOpenChannelChat: (channel) => unawaited(
_onOpenChat(
target: const rust.BridgeMessageTarget.channel(),
),
),
onTs3ServerLink: _onTs3ServerLink,
);
final ownClientState = _ownClientState;
final layoutInfo = ViewportInfo.of(ctx);
if (layoutInfo.isWide) {
final inlineChatTarget = _inlineChatTarget;
return Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
SizedBox(
width: ChanoraBreakpoints.voicePanelWidth,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
banner,
const SizedBox(height: 12),
permissionBanner,
voiceBar,
],
),
),
const SizedBox(width: ChanoraBreakpoints.panelGap),
Expanded(child: snapshotView),
if (layoutInfo.isExpanded &&
inlineChatTarget != null) ...[
const SizedBox(
width: ChanoraBreakpoints.panelGap,
),
ChatPanel(
messages: _chatMessages,
snapshot: _snapshot!,
target: inlineChatTarget,
clientName: _inlineChatClientName,
restoredDraft:
_chatDrafts[_draftKeyForTarget(
inlineChatTarget,
)],
onDraftChanged: (text) {
final draftKey = _draftKeyForTarget(
inlineChatTarget,
);
_chatDrafts[draftKey] = text;
},
onTs3ServerLink: _onTs3ServerLink,
onClose: _closeInlineChat,
),
],
],
);
}
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Expanded(child: snapshotView),
const SizedBox(height: 8),
permissionBanner,
VoiceStatusChip(
transmitMode: _transmitMode,
releaseTailMs: _releaseTailMs,
pttBoundKeyLabel: _pttBoundKeyLabel,
audioStats: _audioStats,
isTouchOnly: isTouchOnlyPttHost,
inputMuted: _hardMute,
outputMuted: _outputMuted,
hardMuteByTalkPower: _hardMuteByTalkPower,
talkPower: ownClientState?.talkPower,
neededTalkPower: ownClientState?.neededTalkPower,
talkPowerGranted: ownClientState?.talkPowerGranted,
onTap: () => _onOpenVoiceDetailsSheet(),
onToggleInputMute: _onToggleHardMute,
onToggleOutputMute: _toggleOutputMute,
),
if (_inChannel &&
_transmitMode == rust.BridgeTransmitMode.ptt) ...[
const SizedBox(height: 8),
VoicePttButton(
active: _audioStats?.pttActive ?? false,
onHeldChanged: _onOnscreenPttHeldChanged,
),
],
],
);
}
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Expanded(child: snapshotView),
const SizedBox(height: 8),
permissionBanner,
VoiceStatusChip(
transmitMode: _transmitMode,
releaseTailMs: _releaseTailMs,
pttBoundKeyLabel: _pttBoundKeyLabel,
audioStats: _audioStats,
isTouchOnly: isTouchOnlyPttHost,
inputMuted: _hardMute,
outputMuted: _outputMuted,
hardMuteByTalkPower: _hardMuteByTalkPower,
talkPower: ownClientState?.talkPower,
neededTalkPower: ownClientState?.neededTalkPower,
talkPowerGranted: ownClientState?.talkPowerGranted,
onTap: () => _onOpenVoiceDetailsSheet(),
onToggleInputMute: _onToggleHardMute,
onToggleOutputMute: _toggleOutputMute,
),
if (_inChannel &&
_transmitMode == rust.BridgeTransmitMode.ptt) ...[
const SizedBox(height: 8),
VoicePttButton(
active: _audioStats?.pttActive ?? false,
onHeldChanged: _onOnscreenPttHeldChanged,
),
],
],
);
},
},
),
),
),
],
],
],
),
);
},
);