feat(ios,p0): iOS P0 platform, audio fixes, channel UX

This commit is contained in:
Edison Jwa
2026-05-17 22:00:00 +09:00
parent a1fefc8ab6
commit 7a59f5b9a1
38 changed files with 1705 additions and 674 deletions
+317 -249
View File
@@ -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),
),
],