feat(ios,p0): iOS P0 platform, audio fixes, channel UX
This commit is contained in:
+317
-249
@@ -67,9 +67,43 @@ Future<void> main() async {
|
||||
await _resolveAppVersion();
|
||||
unawaited(_wireStorage());
|
||||
unawaited(_wireConnectivity());
|
||||
_wireIosAudioLifecycle();
|
||||
runApp(const ChanoraApp());
|
||||
}
|
||||
|
||||
/// Wire the iOS AVAudioSession lifecycle MethodChannel.
|
||||
///
|
||||
/// Swift side (AppDelegate) posts route-change and interruption
|
||||
/// events through `FlutterMethodChannel` named
|
||||
/// `"chanora/ios_audio_lifecycle"`. This handler dispatches them to
|
||||
/// the FRB bridge functions on the Rust side.
|
||||
void _wireIosAudioLifecycle() {
|
||||
const channel = MethodChannel('chanora/ios_audio_lifecycle');
|
||||
channel.setMethodCallHandler((call) async {
|
||||
try {
|
||||
switch (call.method) {
|
||||
case 'handleRouteChange':
|
||||
rust.handleRouteChange();
|
||||
break;
|
||||
case 'handleInterruptionBegan':
|
||||
rust.handleInterruptionBegan();
|
||||
break;
|
||||
case 'handleInterruptionEnded':
|
||||
// `shouldResume` is passed from Swift as a bool argument.
|
||||
final shouldResume = call.arguments as bool? ?? false;
|
||||
rust.handleInterruptionEnded(shouldResume: shouldResume);
|
||||
break;
|
||||
default:
|
||||
// Unknown method — ignore gracefully rather than crashing.
|
||||
break;
|
||||
}
|
||||
} catch (_) {
|
||||
// Errors from the Rust side are already logged there;
|
||||
// don't propagate exceptions to the iOS framework.
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Populate `_kAppVersion` by suffixing the platform-canonical
|
||||
/// build number to `_kSemverBaseline`. Format:
|
||||
/// `v1.0.0-rc.8+<build>` (e.g. `v1.0.0-rc.8+64`). The build
|
||||
@@ -186,6 +220,12 @@ class _BetaHomeState extends State<_BetaHome> {
|
||||
String _pttLevel = 'L0Focused';
|
||||
String _pttBackendId = 'focused';
|
||||
String _pttBoundInputClass = 'keyboard';
|
||||
// iOS interruption state from BridgeEvent::InterruptionState
|
||||
// (SDD-101). Can be used by UI surfaces (banner/snackbar).
|
||||
// ignore: unused_field
|
||||
bool _iosAudioInterrupted = false;
|
||||
// ignore: unused_field
|
||||
bool _iosInterruptionShouldResume = false;
|
||||
// Last platform-neutral key label the user saved in the
|
||||
// `_PttBindingCaptureDialog` (e.g. "Space", "F10",
|
||||
// "mouse-side-button:8"). Surfaced next to the capability
|
||||
@@ -272,21 +312,21 @@ class _BetaHomeState extends State<_BetaHome> {
|
||||
case rust.BridgeEvent_SnapshotChanged():
|
||||
unawaited(_onRefresh());
|
||||
case rust.BridgeEvent_PttCapability(
|
||||
:final level,
|
||||
:final backendId,
|
||||
:final boundInputClass,
|
||||
):
|
||||
: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,
|
||||
):
|
||||
:final inChannel,
|
||||
:final transmitMode,
|
||||
:final mute,
|
||||
:final releaseTailMs,
|
||||
):
|
||||
setState(() {
|
||||
_inChannel = inChannel;
|
||||
_transmitMode = transmitMode;
|
||||
@@ -300,6 +340,39 @@ class _BetaHomeState extends State<_BetaHome> {
|
||||
_statsTimer?.cancel();
|
||||
_statsTimer = null;
|
||||
}
|
||||
case rust.BridgeEvent_InterruptionState(
|
||||
:final began,
|
||||
:final shouldResume,
|
||||
):
|
||||
setState(() {
|
||||
_iosAudioInterrupted = began;
|
||||
_iosInterruptionShouldResume = shouldResume;
|
||||
});
|
||||
// Surface iOS audio interruption to the user (SDD-101).
|
||||
// Use unawaited to stay inside the sync _onEvent stream
|
||||
// without blocking it.
|
||||
if (!mounted) return;
|
||||
unawaited(() async {
|
||||
if (!mounted) return;
|
||||
final messenger = ScaffoldMessenger.of(context);
|
||||
if (began) {
|
||||
messenger.showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Audio interrupted by system (phone call)'),
|
||||
duration: Duration(seconds: 3),
|
||||
backgroundColor: Colors.orange,
|
||||
),
|
||||
);
|
||||
} else if (shouldResume) {
|
||||
messenger.showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Audio resuming'),
|
||||
duration: Duration(seconds: 2),
|
||||
backgroundColor: Colors.green,
|
||||
),
|
||||
);
|
||||
}
|
||||
}());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -410,6 +483,7 @@ class _BetaHomeState extends State<_BetaHome> {
|
||||
}
|
||||
|
||||
Future<void> _onJoinChannel(rust.BridgeChannel ch) async {
|
||||
if (ch.id == _currentVoiceChannelId) return;
|
||||
final l10n = AppL10n.of(context);
|
||||
final messenger = ScaffoldMessenger.of(context);
|
||||
String? password;
|
||||
@@ -419,10 +493,7 @@ class _BetaHomeState extends State<_BetaHome> {
|
||||
if (password == null) return; // cancelled
|
||||
}
|
||||
try {
|
||||
await rust.voiceJoin(
|
||||
channelId: ch.id,
|
||||
password: password ?? '',
|
||||
);
|
||||
await rust.voiceJoin(channelId: ch.id, password: password ?? '');
|
||||
if (!mounted) return;
|
||||
setState(() => _currentVoiceChannelId = ch.id);
|
||||
} catch (e) {
|
||||
@@ -455,7 +526,8 @@ class _BetaHomeState extends State<_BetaHome> {
|
||||
l10n.channelJoinFailedGeneric('$host: $reason'),
|
||||
connection: (msg) => l10n.channelJoinFailedGeneric(msg),
|
||||
notConnected: () => l10n.channelJoinFailedGeneric('not connected'),
|
||||
alreadyConnected: () => l10n.channelJoinFailedGeneric('already connected'),
|
||||
alreadyConnected: () =>
|
||||
l10n.channelJoinFailedGeneric('already connected'),
|
||||
serverRejected: (code, message) {
|
||||
// Canonical TS3 error codes per ReSpeak/tsdeclarations
|
||||
// Errors.csv.
|
||||
@@ -465,6 +537,8 @@ class _BetaHomeState extends State<_BetaHome> {
|
||||
return l10n.channelJoinFailedTimeout;
|
||||
case 0x0a08: // permissions_client_insufficient
|
||||
return l10n.channelJoinFailedPermission;
|
||||
case 0x0302: // channel_already_in
|
||||
return l10n.channelJoinAlreadyIn;
|
||||
case 0x030d: // channel_invalid_password
|
||||
return l10n.channelJoinFailedPassword;
|
||||
case 0x0309: // channel_maxclients_reached
|
||||
@@ -702,9 +776,9 @@ class _BetaHomeState extends State<_BetaHome> {
|
||||
if (_pttBackendId == 'gnome-wayland-portal') {
|
||||
try {
|
||||
final messenger = ScaffoldMessenger.of(context);
|
||||
messenger.showSnackBar(SnackBar(
|
||||
content: Text(l10n.pttConfigurePortalRedirect),
|
||||
));
|
||||
messenger.showSnackBar(
|
||||
SnackBar(content: Text(l10n.pttConfigurePortalRedirect)),
|
||||
);
|
||||
await rust.setPttBinding(
|
||||
inputClass: rust.BridgePttInputClass.keyboard,
|
||||
platformKey: 'portal',
|
||||
@@ -712,9 +786,9 @@ class _BetaHomeState extends State<_BetaHome> {
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
final messenger = ScaffoldMessenger.of(this.context);
|
||||
messenger.showSnackBar(SnackBar(
|
||||
content: Text(l10n.statusError(e.toString())),
|
||||
));
|
||||
messenger.showSnackBar(
|
||||
SnackBar(content: Text(l10n.statusError(e.toString()))),
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -750,9 +824,9 @@ class _BetaHomeState extends State<_BetaHome> {
|
||||
// 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())),
|
||||
));
|
||||
messenger.showSnackBar(
|
||||
SnackBar(content: Text(l10n.statusError(e.toString()))),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -773,40 +847,25 @@ class _BetaHomeState extends State<_BetaHome> {
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
l10n.appTitle,
|
||||
style: theme.textTheme.titleLarge,
|
||||
),
|
||||
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,
|
||||
),
|
||||
Text(l10n.aboutNonAffiliation, style: theme.textTheme.bodyMedium),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
l10n.aboutLicenseHeading,
|
||||
style: theme.textTheme.titleSmall,
|
||||
),
|
||||
Text(l10n.aboutLicenseHeading, style: theme.textTheme.titleSmall),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
l10n.aboutLicenseBody,
|
||||
style: theme.textTheme.bodySmall,
|
||||
),
|
||||
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,
|
||||
),
|
||||
Text(l10n.aboutThirdPartyBody, style: theme.textTheme.bodySmall),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -879,11 +938,7 @@ class _BetaHomeState extends State<_BetaHome> {
|
||||
_hostCtl.text = b.host;
|
||||
_nickCtl.text = b.nickname;
|
||||
_passwordCtl.text = b.password;
|
||||
await _onConnect(
|
||||
host: b.host,
|
||||
nickname: b.nickname,
|
||||
password: b.password,
|
||||
);
|
||||
await _onConnect(host: b.host, nickname: b.nickname, password: b.password);
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -994,191 +1049,191 @@ class _BetaHomeState extends State<_BetaHome> {
|
||||
),
|
||||
);
|
||||
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,
|
||||
),
|
||||
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),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Text(
|
||||
_reconnectAttempt != null
|
||||
? l10n.statusReconnecting(
|
||||
_reconnectAttempt!,
|
||||
_reconnectDelay ?? 0,
|
||||
)
|
||||
: l10n.statusConnectionLost(_lostReason ?? ''),
|
||||
style: TextStyle(
|
||||
color: theme.colorScheme.onErrorContainer,
|
||||
child: Row(
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
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: 10),
|
||||
Expanded(
|
||||
child: Text(
|
||||
_reconnectAttempt != null
|
||||
? l10n.statusReconnecting(
|
||||
_reconnectAttempt!,
|
||||
_reconnectDelay ?? 0,
|
||||
)
|
||||
: l10n.statusConnectionLost(_lostReason ?? ''),
|
||||
style: TextStyle(
|
||||
color: theme.colorScheme.onErrorContainer,
|
||||
),
|
||||
),
|
||||
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,
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 12),
|
||||
if (_phase == _Phase.idle) ...[
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
// Dismiss keyboard when the user drags away from
|
||||
// a focused field — friendlier mobile UX than
|
||||
// forcing them to tap outside.
|
||||
keyboardDismissBehavior:
|
||||
ScrollViewKeyboardDismissBehavior.onDrag,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
_ConnectForm(
|
||||
hostCtl: _hostCtl,
|
||||
nickCtl: _nickCtl,
|
||||
passwordCtl: _passwordCtl,
|
||||
onConnect: () => _onConnect(),
|
||||
onAddBookmark: _onAddCurrentBookmark,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
_BookmarkList(
|
||||
bookmarks: _bookmarks,
|
||||
onConnect: _onUseBookmark,
|
||||
onDelete: _onDeleteBookmark,
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
),
|
||||
),
|
||||
),
|
||||
] else if (_phase == _Phase.connecting) ...[
|
||||
const Center(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(32),
|
||||
child: CircularProgressIndicator(),
|
||||
),
|
||||
),
|
||||
] else if (_phase == _Phase.connected && _snapshot != null) ...[
|
||||
Expanded(
|
||||
child: LayoutBuilder(
|
||||
builder: (ctx, constraints) {
|
||||
final voiceBar = VoiceBar(
|
||||
inChannel: _inChannel,
|
||||
transmitMode: _transmitMode,
|
||||
hardMute: _hardMute,
|
||||
outputMuted: _outputMuted,
|
||||
releaseTailMs: _releaseTailMs,
|
||||
channelName: _currentVoiceChannelName(),
|
||||
audioStats: _audioStats,
|
||||
pttLevel: _pttLevel,
|
||||
pttBackendId: _pttBackendId,
|
||||
pttBoundInputClass: _pttBoundInputClass,
|
||||
pttBoundKeyLabel: _pttBoundKeyLabel,
|
||||
onToggleMute: _onToggleHardMute,
|
||||
onToggleOutputMute: _toggleOutputMute,
|
||||
onConfigure: _onOpenVoiceSettings,
|
||||
onPttHeldChanged: _onOnscreenPttHeldChanged,
|
||||
);
|
||||
final snapshotView = _SnapshotView(
|
||||
snapshot: _snapshot!,
|
||||
currentVoiceChannelId: _currentVoiceChannelId,
|
||||
onJoinChannel: _onJoinChannel,
|
||||
onLeaveVoice: _onLeaveVoice,
|
||||
);
|
||||
// Responsive: at <840 dp use a stacked layout
|
||||
// (Voice Bar on top, channel tree below). At
|
||||
// ≥840 dp use a side-by-side layout with the
|
||||
// Voice Bar pinned to 320 dp on the left and
|
||||
// the channel tree expanding on the right.
|
||||
// 840 dp matches Material's tablet / desktop
|
||||
// breakpoint.
|
||||
const wideBreakpoint = 840.0;
|
||||
const voiceBarWidthWide = 320.0;
|
||||
if (constraints.maxWidth >= wideBreakpoint) {
|
||||
return Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: voiceBarWidthWide,
|
||||
child: Column(
|
||||
crossAxisAlignment:
|
||||
CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
banner,
|
||||
const SizedBox(height: 12),
|
||||
voiceBar,
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(child: snapshotView),
|
||||
],
|
||||
);
|
||||
}
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
// In narrow / one-column layout the layout
|
||||
// is:
|
||||
//
|
||||
// [ channel tree (Expanded) ]
|
||||
// [ status chip (2-line live readout) ]
|
||||
// [ PTT button (mobile-only,
|
||||
// PTT-mode-only) ]
|
||||
//
|
||||
// The chip is a tap target that opens
|
||||
// `showVoiceDetailsSheet` with the mic
|
||||
// level meter + TX/RX counts + capability
|
||||
// badge. Mode + bind key + release-tail
|
||||
// live in `VoiceSettingsDialog`
|
||||
// (gear icon in AppBar).
|
||||
//
|
||||
// Wide mode (Row branch above) is
|
||||
// unchanged from rc.8.
|
||||
Expanded(child: snapshotView),
|
||||
const SizedBox(height: 8),
|
||||
VoiceStatusChip(
|
||||
transmitMode: _transmitMode,
|
||||
releaseTailMs: _releaseTailMs,
|
||||
pttBoundKeyLabel: _pttBoundKeyLabel,
|
||||
audioStats: _audioStats,
|
||||
isTouchOnly: _isTouchOnlyPttHost,
|
||||
onTap: () => _onOpenVoiceDetailsSheet(),
|
||||
),
|
||||
// PTT button only when PTT mode is active
|
||||
// AND the user is in a voice channel. In
|
||||
// Continuous mode there is nothing to
|
||||
// hold; the chip alone surfaces the
|
||||
// "Mic on / off" state.
|
||||
if (_inChannel &&
|
||||
_transmitMode ==
|
||||
rust.BridgeTransmitMode.ptt) ...[
|
||||
const SizedBox(height: 8),
|
||||
VoicePttButton(
|
||||
active: _audioStats?.pttActive ?? false,
|
||||
onHeldChanged: _onOnscreenPttHeldChanged,
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
@@ -1225,10 +1280,7 @@ class _AppBarTitle extends StatelessWidget {
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (!isNarrow) ...[
|
||||
Text(l10n.appTitle),
|
||||
const SizedBox(width: 12),
|
||||
],
|
||||
if (!isNarrow) ...[Text(l10n.appTitle), const SizedBox(width: 12)],
|
||||
Flexible(
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
|
||||
@@ -1288,8 +1340,9 @@ class _BookmarkNameDialog extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _BookmarkNameDialogState extends State<_BookmarkNameDialog> {
|
||||
late final TextEditingController _ctl =
|
||||
TextEditingController(text: widget.initialName);
|
||||
late final TextEditingController _ctl = TextEditingController(
|
||||
text: widget.initialName,
|
||||
);
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
@@ -1330,8 +1383,7 @@ class _ChannelPasswordDialog extends StatefulWidget {
|
||||
const _ChannelPasswordDialog();
|
||||
|
||||
@override
|
||||
State<_ChannelPasswordDialog> createState() =>
|
||||
_ChannelPasswordDialogState();
|
||||
State<_ChannelPasswordDialog> createState() => _ChannelPasswordDialogState();
|
||||
}
|
||||
|
||||
class _ChannelPasswordDialogState extends State<_ChannelPasswordDialog> {
|
||||
@@ -1624,10 +1676,7 @@ class _BookmarkList extends StatelessWidget {
|
||||
if (bookmarks.isEmpty) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
child: Text(
|
||||
l10n.bookmarksEmpty,
|
||||
style: theme.textTheme.bodySmall,
|
||||
),
|
||||
child: Text(l10n.bookmarksEmpty, style: theme.textTheme.bodySmall),
|
||||
);
|
||||
}
|
||||
return Column(
|
||||
@@ -1663,7 +1712,6 @@ class _BookmarkList extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/// PTT capability badge (gen2 v0.9.3 / SDD-091).
|
||||
///
|
||||
/// Renders the active PTT level + backend in the Voice Bar so the
|
||||
@@ -1710,6 +1758,8 @@ class PttCapabilityBadge extends StatelessWidget {
|
||||
return l10n.pttCapabilityExplainGoGlobalMacos;
|
||||
case TargetPlatform.linux:
|
||||
return l10n.pttCapabilityExplainGoGlobalLinux;
|
||||
case TargetPlatform.iOS:
|
||||
return l10n.pttCapabilityExplainGoGlobalIos;
|
||||
default:
|
||||
return l10n.pttCapabilityExplainGoGlobalGeneric;
|
||||
}
|
||||
@@ -1816,11 +1866,15 @@ class PttCapabilityBadge extends StatelessWidget {
|
||||
class _SnapshotView extends StatelessWidget {
|
||||
const _SnapshotView({
|
||||
required this.snapshot,
|
||||
required this.currentVoiceChannelId,
|
||||
required this.onJoinChannel,
|
||||
required this.onLeaveVoice,
|
||||
});
|
||||
|
||||
final rust.BridgeSnapshot snapshot;
|
||||
final BigInt? currentVoiceChannelId;
|
||||
final ValueChanged<rust.BridgeChannel> onJoinChannel;
|
||||
final VoidCallback onLeaveVoice;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -1876,7 +1930,10 @@ class _SnapshotView extends StatelessWidget {
|
||||
color: theme.colorScheme.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
child: Text(snapshot.welcomeMessage, style: theme.textTheme.bodySmall),
|
||||
child: Text(
|
||||
snapshot.welcomeMessage,
|
||||
style: theme.textTheme.bodySmall,
|
||||
),
|
||||
),
|
||||
],
|
||||
const Divider(height: 24),
|
||||
@@ -1889,15 +1946,26 @@ class _SnapshotView extends StatelessWidget {
|
||||
),
|
||||
child: ListTile(
|
||||
dense: true,
|
||||
leading: const Icon(Icons.tag),
|
||||
leading: Icon(
|
||||
ch.id == currentVoiceChannelId ? Icons.volume_up : Icons.tag,
|
||||
),
|
||||
title: Text(ch.name),
|
||||
subtitle: Text('id=${ch.id} parent=${ch.parent}'),
|
||||
trailing: IconButton(
|
||||
icon: const Icon(Icons.login),
|
||||
tooltip: l10n.joinChannelAction,
|
||||
onPressed: () => onJoinChannel(ch),
|
||||
icon: Icon(
|
||||
ch.id == currentVoiceChannelId ? Icons.logout : Icons.login,
|
||||
),
|
||||
tooltip: ch.id == currentVoiceChannelId
|
||||
? l10n.leaveChannelAction
|
||||
: l10n.joinChannelAction,
|
||||
onPressed: ch.id == currentVoiceChannelId
|
||||
? onLeaveVoice
|
||||
: () => onJoinChannel(ch),
|
||||
),
|
||||
onTap: () => onJoinChannel(ch),
|
||||
selected: ch.id == currentVoiceChannelId,
|
||||
onTap: ch.id == currentVoiceChannelId
|
||||
? null
|
||||
: () => onJoinChannel(ch),
|
||||
),
|
||||
),
|
||||
for (final cl in byChannel[ch.id] ?? const <rust.BridgeClient>[])
|
||||
@@ -2121,11 +2189,11 @@ class _PttBindingCaptureDialogState extends State<_PttBindingCaptureDialog> {
|
||||
onPressed: _captured == null
|
||||
? null
|
||||
: () => Navigator.of(context).pop(
|
||||
_CapturedBinding(
|
||||
inputClass: _capturedClass,
|
||||
platformKey: _captured!,
|
||||
),
|
||||
_CapturedBinding(
|
||||
inputClass: _capturedClass,
|
||||
platformKey: _captured!,
|
||||
),
|
||||
),
|
||||
child: Text(l10n.pttConfigureSaveAction),
|
||||
),
|
||||
],
|
||||
|
||||
Reference in New Issue
Block a user