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
@@ -256,7 +256,7 @@ class _VoicePttButtonState extends State<VoicePttButton> {
/// 3. Release-tail slider (PTT-only).
/// 4. Mic level meter.
/// 5. TX / RX frame counts.
/// 6. PTT capability badge (desktop only).
/// 6. PTT capability badge.
///
/// Mode + release-tail are inlined directly here instead of being
/// hidden behind an "Adjust" button → nested dialog. Single-screen
@@ -355,8 +355,7 @@ class _VoiceSheetBodyState extends State<_VoiceSheetBody> {
// Route picker only meaningful on iOS + Android where the OS
// owns audio routing. Desktop hosts skip the tile entirely.
final showRoutePicker =
!kIsWeb && (Platform.isIOS || Platform.isAndroid);
final showRoutePicker = !kIsWeb && (Platform.isIOS || Platform.isAndroid);
return SafeArea(
child: SingleChildScrollView(
@@ -365,10 +364,7 @@ class _VoiceSheetBodyState extends State<_VoiceSheetBody> {
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
l10n.voiceSheetTitle,
style: theme.textTheme.titleLarge,
),
Text(l10n.voiceSheetTitle, style: theme.textTheme.titleLarge),
const SizedBox(height: 12),
// 1) Audio output route picker tile (mobile only).
@@ -428,9 +424,9 @@ class _VoiceSheetBodyState extends State<_VoiceSheetBody> {
],
),
Slider(
value: _tail.toDouble().clamp(0, 1000),
value: _tail.toDouble().clamp(0, 500),
min: 0,
max: 1000,
max: 500,
divisions: 20,
label: '$_tail ms',
onChanged: _setTail,
@@ -470,8 +466,12 @@ class _VoiceSheetBodyState extends State<_VoiceSheetBody> {
style: theme.textTheme.bodySmall,
),
// 5) PTT capability badge \u2014 desktop-only.
if (isPtt && !widget.isTouchOnly) ...[
// 5) PTT capability badge. On iOS this must remain
// visible even though the resolved level is always
// `L0Focused`, because the P0 acceptance flow requires
// honest capability advertising with an explanation of
// the sandbox limitation.
if (isPtt) ...[
const SizedBox(height: 12),
PttCapabilityBadge(
level: widget.pttLevel,
@@ -506,8 +506,8 @@ class _ModeRow extends StatelessWidget {
final color = disabled
? theme.colorScheme.onSurfaceVariant.withAlpha(120)
: selected
? theme.colorScheme.primary
: theme.colorScheme.onSurface;
? theme.colorScheme.primary
: theme.colorScheme.onSurface;
return InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(8),
@@ -604,8 +604,11 @@ class _AudioOutputTileState extends State<_AudioOutputTile> {
}
}
static String _portLabel(AVAudioSessionPort? type, String fallback,
AppL10n l10n) {
static String _portLabel(
AVAudioSessionPort? type,
String fallback,
AppL10n l10n,
) {
switch (type) {
case AVAudioSessionPort.builtInSpeaker:
return l10n.audioRouteSpeaker;
@@ -783,8 +786,9 @@ class _AudioOutputPickerSheetState extends State<_AudioOutputPickerSheet> {
// mic by default, or BT/wired if connected and selected
// elsewhere). For consistency, only switch input when the
// user explicitly picks a non-speaker input row.
await AVAudioSession()
.overrideOutputAudioPort(AVAudioSessionPortOverride.speaker);
await AVAudioSession().overrideOutputAudioPort(
AVAudioSessionPortOverride.speaker,
);
debugPrint('chanora: audio output -> speakerphone (override applied)');
} catch (e, st) {
debugPrint('chanora: _selectSpeaker FAILED: $e\n$st');
@@ -800,8 +804,9 @@ class _AudioOutputPickerSheetState extends State<_AudioOutputPickerSheet> {
// which is the built-in receiver. Calling setPreferredInput
// explicitly here is redundant and risks the same recalc
// race that broke _selectSpeaker before.
await AVAudioSession()
.overrideOutputAudioPort(AVAudioSessionPortOverride.none);
await AVAudioSession().overrideOutputAudioPort(
AVAudioSessionPortOverride.none,
);
debugPrint('chanora: audio output -> receiver (override cleared)');
} catch (e, st) {
debugPrint('chanora: _selectReceiver FAILED: $e\n$st');
@@ -817,11 +822,13 @@ class _AudioOutputPickerSheetState extends State<_AudioOutputPickerSheet> {
// OWN output (the user hears audio through the same device
// they speak into), so .none + setPreferredInput is the
// correct combo here.
await AVAudioSession()
.overrideOutputAudioPort(AVAudioSessionPortOverride.none);
await AVAudioSession().overrideOutputAudioPort(
AVAudioSessionPortOverride.none,
);
await AVAudioSession().setPreferredInput(port);
debugPrint(
'chanora: audio output -> ${port.portName} (${port.portType})');
'chanora: audio output -> ${port.portName} (${port.portType})',
);
} catch (e, st) {
debugPrint('chanora: _selectInput FAILED: $e\n$st');
}
@@ -843,10 +850,12 @@ class _AudioOutputPickerSheetState extends State<_AudioOutputPickerSheet> {
);
}
final currentOutputType =
_route?.outputs.isNotEmpty == true ? _route!.outputs.first.portType : null;
final currentInputUid =
_route?.inputs.isNotEmpty == true ? _route!.inputs.first.uid : null;
final currentOutputType = _route?.outputs.isNotEmpty == true
? _route!.outputs.first.portType
: null;
final currentInputUid = _route?.inputs.isNotEmpty == true
? _route!.inputs.first.uid
: null;
// Whether the active route is the speakerphone override (built-in
// speaker is the output but the actual session category isn't
@@ -868,10 +877,7 @@ class _AudioOutputPickerSheetState extends State<_AudioOutputPickerSheet> {
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
l10n.audioOutputLabel,
style: theme.textTheme.titleLarge,
),
Text(l10n.audioOutputLabel, style: theme.textTheme.titleLarge),
const SizedBox(height: 16),
_PickerRow(
icon: Icons.volume_up,
@@ -889,7 +895,10 @@ class _AudioOutputPickerSheetState extends State<_AudioOutputPickerSheet> {
_PickerRow(
icon: _AudioOutputTileState._portIcon(port.portType),
label: _AudioOutputTileState._portLabel(
port.portType, port.portName, l10n),
port.portType,
port.portName,
l10n,
),
selected: !isSpeaker && port.uid == currentInputUid,
onTap: () => _selectInput(port),
),
@@ -916,8 +925,9 @@ class _PickerRow extends StatelessWidget {
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final color =
selected ? theme.colorScheme.primary : theme.colorScheme.onSurface;
final color = selected
? theme.colorScheme.primary
: theme.colorScheme.onSurface;
return InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(8),
@@ -933,8 +943,7 @@ class _PickerRow extends StatelessWidget {
style: theme.textTheme.bodyLarge?.copyWith(color: color),
),
),
if (selected)
Icon(Icons.check, color: theme.colorScheme.primary),
if (selected) Icon(Icons.check, color: theme.colorScheme.primary),
],
),
),