Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e0060f3c19 |
@@ -1126,14 +1126,18 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
|
|||||||
|
|
||||||
Future<void> _toggleOutputMute() async {
|
Future<void> _toggleOutputMute() async {
|
||||||
final next = !_outputMuted;
|
final next = !_outputMuted;
|
||||||
|
// Optimistic: flip the UI immediately.
|
||||||
|
setState(() {
|
||||||
|
_outputMuted = next;
|
||||||
|
});
|
||||||
try {
|
try {
|
||||||
await rust.setOutputMuted(muted: next);
|
await rust.setOutputMuted(muted: next);
|
||||||
if (!mounted) return;
|
|
||||||
setState(() {
|
|
||||||
_outputMuted = next;
|
|
||||||
});
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
|
// Roll back on failure.
|
||||||
|
setState(() {
|
||||||
|
_outputMuted = !next;
|
||||||
|
});
|
||||||
_showUiError('output mute', e);
|
_showUiError('output mute', e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1260,6 +1264,13 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
final next = !_hardMute;
|
final next = !_hardMute;
|
||||||
|
// Optimistic: flip the UI immediately so the icon responds
|
||||||
|
// before the two bridge calls round-trip through FFI.
|
||||||
|
setState(() {
|
||||||
|
_inputMuted = next;
|
||||||
|
_hardMute = next;
|
||||||
|
_hardMuteByPermission = false;
|
||||||
|
});
|
||||||
try {
|
try {
|
||||||
// Hard-mute is two coordinated effects:
|
// Hard-mute is two coordinated effects:
|
||||||
// * setHardMute — local TransmitGate clamp; we stop sending
|
// * setHardMute — local TransmitGate clamp; we stop sending
|
||||||
@@ -1272,14 +1283,13 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
|
|||||||
// through. Drive them together.
|
// through. Drive them together.
|
||||||
await rust.setHardMute(muted: next);
|
await rust.setHardMute(muted: next);
|
||||||
await rust.setInputMuted(muted: next);
|
await rust.setInputMuted(muted: next);
|
||||||
if (!mounted) return;
|
|
||||||
setState(() {
|
|
||||||
_inputMuted = next;
|
|
||||||
_hardMute = next;
|
|
||||||
_hardMuteByPermission = false;
|
|
||||||
});
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
|
// Roll back on failure.
|
||||||
|
setState(() {
|
||||||
|
_inputMuted = !next;
|
||||||
|
_hardMute = !next;
|
||||||
|
});
|
||||||
_showUiError('hard mute', e);
|
_showUiError('hard mute', e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2145,26 +2155,6 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
|
|||||||
final theme = Theme.of(context);
|
final theme = Theme.of(context);
|
||||||
|
|
||||||
final headerActions = [
|
final headerActions = [
|
||||||
if (_serverReachable && _inChannel) ...[
|
|
||||||
IconButton(
|
|
||||||
tooltip: _hardMuteByTalkPower
|
|
||||||
? 'Insufficient talk power to speak in this channel'
|
|
||||||
: l10n.voiceHardMuteLabel,
|
|
||||||
icon: Icon(_hardMute ? Icons.mic_off : Icons.mic),
|
|
||||||
isSelected: _hardMute,
|
|
||||||
selectedIcon: const Icon(Icons.mic_off),
|
|
||||||
color: _hardMute ? theme.colorScheme.error : null,
|
|
||||||
onPressed: _hardMuteByTalkPower ? null : _onToggleHardMute,
|
|
||||||
),
|
|
||||||
IconButton(
|
|
||||||
tooltip: l10n.voiceOutputMuteLabel,
|
|
||||||
icon: Icon(_outputMuted ? Icons.headset_off : Icons.headset),
|
|
||||||
isSelected: _outputMuted,
|
|
||||||
selectedIcon: const Icon(Icons.headset_off),
|
|
||||||
color: _outputMuted ? theme.colorScheme.error : null,
|
|
||||||
onPressed: _toggleOutputMute,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
IconButton(
|
IconButton(
|
||||||
tooltip: l10n.aboutAction,
|
tooltip: l10n.aboutAction,
|
||||||
icon: const Icon(Icons.info_outline),
|
icon: const Icon(Icons.info_outline),
|
||||||
@@ -2449,7 +2439,8 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
|
|||||||
Expanded(child: snapshotView),
|
Expanded(child: snapshotView),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
permissionBanner,
|
permissionBanner,
|
||||||
VoiceStatusChip(
|
CompactVoiceBar(
|
||||||
|
inChannel: _inChannel,
|
||||||
transmitMode: _transmitMode,
|
transmitMode: _transmitMode,
|
||||||
releaseTailMs: _releaseTailMs,
|
releaseTailMs: _releaseTailMs,
|
||||||
pttBoundKeyLabel: _pttBoundKeyLabel,
|
pttBoundKeyLabel: _pttBoundKeyLabel,
|
||||||
@@ -2457,19 +2448,15 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
|
|||||||
isTouchOnly: isTouchOnlyPttHost,
|
isTouchOnly: isTouchOnlyPttHost,
|
||||||
inputMuted: _hardMute,
|
inputMuted: _hardMute,
|
||||||
outputMuted: _outputMuted,
|
outputMuted: _outputMuted,
|
||||||
|
onToggleInputMute: _onToggleHardMute,
|
||||||
|
onToggleOutputMute: _toggleOutputMute,
|
||||||
|
onOpenDetails: () => _onOpenVoiceDetailsSheet(),
|
||||||
|
onPttHeldChanged: _onOnscreenPttHeldChanged,
|
||||||
talkPower: ownClientState?.talkPower,
|
talkPower: ownClientState?.talkPower,
|
||||||
neededTalkPower: ownClientState?.neededTalkPower,
|
neededTalkPower: ownClientState?.neededTalkPower,
|
||||||
talkPowerGranted: ownClientState?.talkPowerGranted,
|
talkPowerGranted: ownClientState?.talkPowerGranted,
|
||||||
onTap: () => _onOpenVoiceDetailsSheet(),
|
hardMuteByTalkPower: _hardMuteByTalkPower,
|
||||||
),
|
),
|
||||||
if (_inChannel &&
|
|
||||||
_transmitMode == rust.BridgeTransmitMode.ptt) ...[
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
VoicePttButton(
|
|
||||||
active: _audioStats?.pttActive ?? false,
|
|
||||||
onHeldChanged: _onOnscreenPttHeldChanged,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,11 +1,15 @@
|
|||||||
// Compact voice UI for narrow / mobile layouts (Plan E hybrid:
|
// Compact voice UI for narrow / mobile layouts.
|
||||||
// AppBar mutes + status chip with 2-line live readout + wide bottom-
|
|
||||||
// anchored PTT button + modal sheet for non-essential controls).
|
|
||||||
//
|
//
|
||||||
// rc.8 follow-up: the AppBar gear icon was removed; the modal sheet
|
// Two-zone voice bar pinned to the bottom:
|
||||||
// is now the **single** voice-controls surface on mobile. Mode +
|
// • Control row: status text + mute + deafen + settings chevron
|
||||||
// release-tail are surfaced inline (radio buttons + slider) inside
|
// • PTT row: full-width hold-to-talk (PTT mode only)
|
||||||
// the modal.
|
// Both share a single container whose background colour reflects
|
||||||
|
// the current voice state (normal / muted / talk-power-blocked).
|
||||||
|
//
|
||||||
|
// Gesture isolation: the control row uses tap-only InkWell /
|
||||||
|
// IconButton; the PTT row uses a raw Listener for pointer-down /
|
||||||
|
// pointer-up. Because each row is a disjoint hit-test region, a
|
||||||
|
// finger holding PTT cannot accidentally toggle mute or deafen.
|
||||||
|
|
||||||
import 'dart:async' show Timer, unawaited;
|
import 'dart:async' show Timer, unawaited;
|
||||||
import 'dart:io' show Platform;
|
import 'dart:io' show Platform;
|
||||||
@@ -539,6 +543,8 @@ class _VoiceSheetBodyState extends State<_VoiceSheetBody> {
|
|||||||
Text(l10n.voiceSheetTitle, style: theme.textTheme.titleLarge),
|
Text(l10n.voiceSheetTitle, style: theme.textTheme.titleLarge),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
|
|
||||||
|
// ── Primary section (always visible) ────────────────────
|
||||||
|
|
||||||
// 1) Audio output route picker tile (mobile only).
|
// 1) Audio output route picker tile (mobile only).
|
||||||
if (showRoutePicker) ...[
|
if (showRoutePicker) ...[
|
||||||
const AudioOutputTile(),
|
const AudioOutputTile(),
|
||||||
@@ -574,7 +580,7 @@ class _VoiceSheetBodyState extends State<_VoiceSheetBody> {
|
|||||||
onTap: () => _setMode(rust.BridgeTransmitMode.voiceActivity),
|
onTap: () => _setMode(rust.BridgeTransmitMode.voiceActivity),
|
||||||
),
|
),
|
||||||
|
|
||||||
// 3) Release-tail slider (PTT only).
|
// Release-tail slider (PTT only).
|
||||||
if (isPtt) ...[
|
if (isPtt) ...[
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
Row(
|
Row(
|
||||||
@@ -624,7 +630,7 @@ class _VoiceSheetBodyState extends State<_VoiceSheetBody> {
|
|||||||
Divider(height: 1, color: theme.colorScheme.outlineVariant),
|
Divider(height: 1, color: theme.colorScheme.outlineVariant),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
|
|
||||||
// 4) Level meter + live TX/RX stats.
|
// Level meter + live TX/RX stats.
|
||||||
VoiceLevelMeter(active: levelActive),
|
VoiceLevelMeter(active: levelActive),
|
||||||
const SizedBox(height: 6),
|
const SizedBox(height: 6),
|
||||||
_StatsRow(
|
_StatsRow(
|
||||||
@@ -648,183 +654,227 @@ class _VoiceSheetBodyState extends State<_VoiceSheetBody> {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|
||||||
// Audio processing.
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
Divider(height: 1, color: theme.colorScheme.outlineVariant),
|
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
Text(
|
|
||||||
'Audio processing',
|
|
||||||
style: theme.textTheme.labelLarge?.copyWith(
|
|
||||||
color: theme.colorScheme.onSurfaceVariant,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 4),
|
|
||||||
|
|
||||||
// Android HW/SW selector.
|
// ── Collapsible: Audio processing ───────────────────────
|
||||||
if (Platform.isAndroid) ...[
|
ExpansionTile(
|
||||||
const VoiceSubHeader('Processing backend'),
|
initiallyExpanded: false,
|
||||||
SegmentedButton<bool>(
|
shape: const Border(),
|
||||||
style: voiceSegmentedButtonStyle(theme),
|
collapsedShape: const Border(),
|
||||||
segments: androidProcessingSegments,
|
tilePadding: const EdgeInsets.symmetric(horizontal: 0),
|
||||||
selected: {_audioProcessing.preferHardware},
|
title: Text(
|
||||||
onSelectionChanged: (s) {
|
'Audio processing',
|
||||||
setState(() => _audioProcessing.preferHardware = s.first);
|
style: theme.textTheme.labelLarge?.copyWith(
|
||||||
_notifyAudioConfig();
|
|
||||||
},
|
|
||||||
),
|
|
||||||
const SizedBox(height: 4),
|
|
||||||
Text(
|
|
||||||
_audioProcessing.preferHardware
|
|
||||||
? 'Hardware mode still keeps per-stage WebRTC fallback, so these controls remain effective.'
|
|
||||||
: 'Software mode applies the full WebRTC APM stage set.',
|
|
||||||
style: theme.textTheme.bodySmall?.copyWith(
|
|
||||||
color: theme.colorScheme.onSurfaceVariant,
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
children: [
|
||||||
|
_buildAudioProcessingSection(theme),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
|
||||||
if (_isIos) ...[
|
// ── Collapsible: PTT capability (PTT mode only) ─────────
|
||||||
Text(
|
if (isPtt)
|
||||||
'iOS uses Apple VoiceProcessingIO. WebRTC APM controls are '
|
ExpansionTile(
|
||||||
'hidden here; only settings that still affect the shipping '
|
initiallyExpanded: false,
|
||||||
'iOS path are shown.',
|
tilePadding: const EdgeInsets.symmetric(horizontal: 0),
|
||||||
style: theme.textTheme.bodySmall?.copyWith(
|
shape: const Border(),
|
||||||
|
collapsedShape: const Border(),
|
||||||
|
title: Text(
|
||||||
|
'PTT capability',
|
||||||
|
style: theme.textTheme.labelLarge?.copyWith(
|
||||||
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
children: [
|
||||||
|
PttCapabilityBadge(
|
||||||
|
level: widget.pttLevel,
|
||||||
|
backendId: widget.pttBackendId,
|
||||||
|
boundInputClass: widget.pttBoundInputClass,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
|
||||||
|
// ── Collapsible: Debug ──────────────────────────────────
|
||||||
|
ExpansionTile(
|
||||||
|
initiallyExpanded: false,
|
||||||
|
shape: const Border(),
|
||||||
|
collapsedShape: const Border(),
|
||||||
|
tilePadding: const EdgeInsets.symmetric(horizontal: 0),
|
||||||
|
title: Text(
|
||||||
|
'Debug',
|
||||||
|
style: theme.textTheme.labelLarge?.copyWith(
|
||||||
color: theme.colorScheme.onSurfaceVariant,
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 4),
|
children: [
|
||||||
],
|
_buildDebugSection(theme),
|
||||||
if (!_isIos &&
|
],
|
||||||
(!Platform.isAndroid ||
|
|
||||||
androidShowsNsControl(_audioProcessing)))
|
|
||||||
AudioProcessingToggleRow(
|
|
||||||
dense: true,
|
|
||||||
label: 'Noise suppression',
|
|
||||||
subtitle: 'Wiener filter',
|
|
||||||
value: _audioProcessing.nsEnabled,
|
|
||||||
onChanged: (v) {
|
|
||||||
setState(() => _audioProcessing.nsEnabled = v);
|
|
||||||
_notifyAudioConfig();
|
|
||||||
},
|
|
||||||
),
|
|
||||||
if (!_isIos &&
|
|
||||||
(!Platform.isAndroid ||
|
|
||||||
androidShowsAecControl(_audioProcessing)))
|
|
||||||
AudioProcessingToggleRow(
|
|
||||||
dense: true,
|
|
||||||
label: 'Echo cancellation',
|
|
||||||
subtitle: Platform.isAndroid
|
|
||||||
? (_audioProcessing.preferHardware
|
|
||||||
? 'Prefers device/OS effect; falls back to WebRTC AEC3'
|
|
||||||
: 'WebRTC AEC3 · adaptive filter')
|
|
||||||
: (_isMacOS
|
|
||||||
? 'Managed by platform VPIO'
|
|
||||||
: 'WebRTC AEC3 · adaptive filter'),
|
|
||||||
value: _isMacOS ? true : _audioProcessing.aecEnabled,
|
|
||||||
onChanged: _isMacOS
|
|
||||||
? null
|
|
||||||
: (v) {
|
|
||||||
setState(() => _audioProcessing.aecEnabled = v);
|
|
||||||
_notifyAudioConfig();
|
|
||||||
},
|
|
||||||
),
|
|
||||||
if (!_isIos &&
|
|
||||||
(!Platform.isAndroid ||
|
|
||||||
androidShowsAgcControl(_audioProcessing)))
|
|
||||||
AudioProcessingToggleRow(
|
|
||||||
dense: true,
|
|
||||||
label: 'Auto gain control',
|
|
||||||
subtitle: 'AGC2 · -18 dBFS target',
|
|
||||||
value: _audioProcessing.agcEnabled,
|
|
||||||
onChanged: (v) {
|
|
||||||
setState(() => _audioProcessing.agcEnabled = v);
|
|
||||||
_notifyAudioConfig();
|
|
||||||
},
|
|
||||||
),
|
|
||||||
if (!Platform.isAndroid || androidShowsHpfControl(_audioProcessing))
|
|
||||||
AudioProcessingToggleRow(
|
|
||||||
dense: true,
|
|
||||||
label: 'High-pass filter',
|
|
||||||
subtitle: '80 Hz · DC removal',
|
|
||||||
value: _audioProcessing.hpfEnabled,
|
|
||||||
onChanged: (v) {
|
|
||||||
setState(() => _audioProcessing.hpfEnabled = v);
|
|
||||||
_notifyAudioConfig();
|
|
||||||
},
|
|
||||||
),
|
|
||||||
if (!_isIos &&
|
|
||||||
(!Platform.isAndroid ||
|
|
||||||
androidShowsLimiterControl(_audioProcessing)))
|
|
||||||
AudioProcessingToggleRow(
|
|
||||||
dense: true,
|
|
||||||
label: 'Peak limiter',
|
|
||||||
subtitle: '-1 dBFS soft-knee · 2 ms look-ahead',
|
|
||||||
value: _audioProcessing.limiterEnabled,
|
|
||||||
onChanged: (v) {
|
|
||||||
setState(() => _audioProcessing.limiterEnabled = v);
|
|
||||||
_notifyAudioConfig();
|
|
||||||
},
|
|
||||||
),
|
|
||||||
|
|
||||||
// VAD backend.
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
Text(
|
|
||||||
'Voice activity detection (VAD)',
|
|
||||||
style: theme.textTheme.labelLarge?.copyWith(
|
|
||||||
color: theme.colorScheme.onSurfaceVariant,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
const SizedBox(height: 2),
|
|
||||||
SegmentedButton<rust.BridgeVadBackend>(
|
|
||||||
style: voiceSegmentedButtonStyle(theme),
|
|
||||||
segments: _isDesktopSileroVadHost
|
|
||||||
? desktopVadBackendSegments
|
|
||||||
: vadBackendSegments,
|
|
||||||
selected: {_audioProcessing.vadBackend},
|
|
||||||
onSelectionChanged: (s) {
|
|
||||||
setState(() => _audioProcessing.vadBackend = s.first);
|
|
||||||
_notifyAudioConfig();
|
|
||||||
},
|
|
||||||
),
|
|
||||||
|
|
||||||
// Debug.
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
Text(
|
|
||||||
'Debug',
|
|
||||||
style: theme.textTheme.labelLarge?.copyWith(
|
|
||||||
color: theme.colorScheme.onSurfaceVariant,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 2),
|
|
||||||
AudioProcessingToggleRow(
|
|
||||||
dense: true,
|
|
||||||
label: 'WAV dump',
|
|
||||||
subtitle: 'Record raw/processed mic to temp dir',
|
|
||||||
value: _audioProcessing.debugWavDump,
|
|
||||||
onChanged: (v) {
|
|
||||||
setState(() => _audioProcessing.debugWavDump = v);
|
|
||||||
_notifyAudioConfig();
|
|
||||||
},
|
|
||||||
),
|
|
||||||
|
|
||||||
// 6) 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,
|
|
||||||
backendId: widget.pttBackendId,
|
|
||||||
boundInputClass: widget.pttBoundInputClass,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Audio processing section (inside ExpansionTile) ─────────────────
|
||||||
|
|
||||||
|
Widget _buildAudioProcessingSection(ThemeData theme) {
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
// Android HW/SW selector.
|
||||||
|
if (Platform.isAndroid) ...[
|
||||||
|
const VoiceSubHeader('Processing backend'),
|
||||||
|
SegmentedButton<bool>(
|
||||||
|
style: voiceSegmentedButtonStyle(theme),
|
||||||
|
segments: androidProcessingSegments,
|
||||||
|
selected: {_audioProcessing.preferHardware},
|
||||||
|
onSelectionChanged: (s) {
|
||||||
|
setState(() => _audioProcessing.preferHardware = s.first);
|
||||||
|
_notifyAudioConfig();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
_audioProcessing.preferHardware
|
||||||
|
? 'Hardware mode still keeps per-stage WebRTC fallback, so these controls remain effective.'
|
||||||
|
: 'Software mode applies the full WebRTC APM stage set.',
|
||||||
|
style: theme.textTheme.bodySmall?.copyWith(
|
||||||
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
|
||||||
|
if (_isIos) ...[
|
||||||
|
Text(
|
||||||
|
'iOS uses Apple VoiceProcessingIO. WebRTC APM controls are '
|
||||||
|
'hidden here; only settings that still affect the shipping '
|
||||||
|
'iOS path are shown.',
|
||||||
|
style: theme.textTheme.bodySmall?.copyWith(
|
||||||
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
],
|
||||||
|
if (!_isIos &&
|
||||||
|
(!Platform.isAndroid ||
|
||||||
|
androidShowsNsControl(_audioProcessing)))
|
||||||
|
AudioProcessingToggleRow(
|
||||||
|
dense: true,
|
||||||
|
label: 'Noise suppression',
|
||||||
|
subtitle: 'Wiener filter',
|
||||||
|
value: _audioProcessing.nsEnabled,
|
||||||
|
onChanged: (v) {
|
||||||
|
setState(() => _audioProcessing.nsEnabled = v);
|
||||||
|
_notifyAudioConfig();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
if (!_isIos &&
|
||||||
|
(!Platform.isAndroid ||
|
||||||
|
androidShowsAecControl(_audioProcessing)))
|
||||||
|
AudioProcessingToggleRow(
|
||||||
|
dense: true,
|
||||||
|
label: 'Echo cancellation',
|
||||||
|
subtitle: Platform.isAndroid
|
||||||
|
? (_audioProcessing.preferHardware
|
||||||
|
? 'Prefers device/OS effect; falls back to WebRTC AEC3'
|
||||||
|
: 'WebRTC AEC3 · adaptive filter')
|
||||||
|
: (_isMacOS
|
||||||
|
? 'Managed by platform VPIO'
|
||||||
|
: 'WebRTC AEC3 · adaptive filter'),
|
||||||
|
value: _isMacOS ? true : _audioProcessing.aecEnabled,
|
||||||
|
onChanged: _isMacOS
|
||||||
|
? null
|
||||||
|
: (v) {
|
||||||
|
setState(() => _audioProcessing.aecEnabled = v);
|
||||||
|
_notifyAudioConfig();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
if (!_isIos &&
|
||||||
|
(!Platform.isAndroid ||
|
||||||
|
androidShowsAgcControl(_audioProcessing)))
|
||||||
|
AudioProcessingToggleRow(
|
||||||
|
dense: true,
|
||||||
|
label: 'Auto gain control',
|
||||||
|
subtitle: 'AGC2 · -18 dBFS target',
|
||||||
|
value: _audioProcessing.agcEnabled,
|
||||||
|
onChanged: (v) {
|
||||||
|
setState(() => _audioProcessing.agcEnabled = v);
|
||||||
|
_notifyAudioConfig();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
if (!Platform.isAndroid || androidShowsHpfControl(_audioProcessing))
|
||||||
|
AudioProcessingToggleRow(
|
||||||
|
dense: true,
|
||||||
|
label: 'High-pass filter',
|
||||||
|
subtitle: '80 Hz · DC removal',
|
||||||
|
value: _audioProcessing.hpfEnabled,
|
||||||
|
onChanged: (v) {
|
||||||
|
setState(() => _audioProcessing.hpfEnabled = v);
|
||||||
|
_notifyAudioConfig();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
if (!_isIos &&
|
||||||
|
(!Platform.isAndroid ||
|
||||||
|
androidShowsLimiterControl(_audioProcessing)))
|
||||||
|
AudioProcessingToggleRow(
|
||||||
|
dense: true,
|
||||||
|
label: 'Peak limiter',
|
||||||
|
subtitle: '-1 dBFS soft-knee · 2 ms look-ahead',
|
||||||
|
value: _audioProcessing.limiterEnabled,
|
||||||
|
onChanged: (v) {
|
||||||
|
setState(() => _audioProcessing.limiterEnabled = v);
|
||||||
|
_notifyAudioConfig();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
|
||||||
|
// VAD backend.
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
'Voice activity detection (VAD)',
|
||||||
|
style: theme.textTheme.labelLarge?.copyWith(
|
||||||
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
SegmentedButton<rust.BridgeVadBackend>(
|
||||||
|
style: voiceSegmentedButtonStyle(theme),
|
||||||
|
segments: _isDesktopSileroVadHost
|
||||||
|
? desktopVadBackendSegments
|
||||||
|
: vadBackendSegments,
|
||||||
|
selected: {_audioProcessing.vadBackend},
|
||||||
|
onSelectionChanged: (s) {
|
||||||
|
setState(() => _audioProcessing.vadBackend = s.first);
|
||||||
|
_notifyAudioConfig();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Debug section (inside ExpansionTile) ─────────────────────────────
|
||||||
|
|
||||||
|
Widget _buildDebugSection(ThemeData theme) {
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
AudioProcessingToggleRow(
|
||||||
|
dense: true,
|
||||||
|
label: 'WAV dump',
|
||||||
|
subtitle: 'Record raw/processed mic to temp dir',
|
||||||
|
value: _audioProcessing.debugWavDump,
|
||||||
|
onChanged: (v) {
|
||||||
|
setState(() => _audioProcessing.debugWavDump = v);
|
||||||
|
_notifyAudioConfig();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Live TX/RX stats row ──────────────────────────────────────────────────
|
// ── Live TX/RX stats row ──────────────────────────────────────────────────
|
||||||
@@ -948,3 +998,424 @@ class _ModeRow extends StatelessWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Unified mobile voice bar ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// A unified bottom-anchored voice bar for compact / mobile layouts.
|
||||||
|
///
|
||||||
|
/// Combines the former [VoiceStatusChip] and [VoicePttButton] into one
|
||||||
|
/// visual zone with two rows:
|
||||||
|
///
|
||||||
|
/// ┌──────────────────────────────────────────────┐
|
||||||
|
/// │ 🟢 PTT · Connected [🔇] [🎧] [▲] │ ← control row (tap)
|
||||||
|
/// ├──────────────────────────────────────────────┤
|
||||||
|
/// │ ════ hold to talk ════ │ ← PTT row (hold)
|
||||||
|
/// └──────────────────────────────────────────────┘
|
||||||
|
///
|
||||||
|
/// The PTT row is shown only when [transmitMode] is PTT; for continuous
|
||||||
|
/// or voice-activity modes the bar shrinks to the control row alone.
|
||||||
|
///
|
||||||
|
/// State colour is applied to the entire container:
|
||||||
|
/// - normal: `surfaceContainerHigh`
|
||||||
|
/// - muted: `errorContainer` (35 % alpha)
|
||||||
|
/// - talk-power-block: amber (18 % alpha)
|
||||||
|
class CompactVoiceBar extends StatelessWidget {
|
||||||
|
/// Construct a compact voice bar.
|
||||||
|
const CompactVoiceBar({
|
||||||
|
super.key,
|
||||||
|
required this.inChannel,
|
||||||
|
required this.transmitMode,
|
||||||
|
required this.releaseTailMs,
|
||||||
|
required this.pttBoundKeyLabel,
|
||||||
|
required this.audioStats,
|
||||||
|
required this.isTouchOnly,
|
||||||
|
required this.inputMuted,
|
||||||
|
required this.outputMuted,
|
||||||
|
required this.onToggleInputMute,
|
||||||
|
required this.onToggleOutputMute,
|
||||||
|
required this.onOpenDetails,
|
||||||
|
required this.onPttHeldChanged,
|
||||||
|
this.talkPower,
|
||||||
|
this.neededTalkPower,
|
||||||
|
this.talkPowerGranted,
|
||||||
|
this.hardMuteByTalkPower = false,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// True when the client is inside a channel (gates PTT row visibility).
|
||||||
|
final bool inChannel;
|
||||||
|
|
||||||
|
/// Current transmit mode.
|
||||||
|
final rust.BridgeTransmitMode transmitMode;
|
||||||
|
|
||||||
|
/// Release-tail in milliseconds.
|
||||||
|
final int releaseTailMs;
|
||||||
|
|
||||||
|
/// Bound key label (empty on touch-only hosts).
|
||||||
|
final String pttBoundKeyLabel;
|
||||||
|
|
||||||
|
/// Current audio stats; null while audio engine not running.
|
||||||
|
final rust.BridgeAudioStats? audioStats;
|
||||||
|
|
||||||
|
/// True on iOS / iPadOS / Android.
|
||||||
|
final bool isTouchOnly;
|
||||||
|
|
||||||
|
/// True when local mic is muted.
|
||||||
|
final bool inputMuted;
|
||||||
|
|
||||||
|
/// True when local speaker is muted.
|
||||||
|
final bool outputMuted;
|
||||||
|
|
||||||
|
/// Toggle hard-mute on / off.
|
||||||
|
final VoidCallback onToggleInputMute;
|
||||||
|
|
||||||
|
/// Toggle output mute on / off.
|
||||||
|
final VoidCallback onToggleOutputMute;
|
||||||
|
|
||||||
|
/// Open the voice details modal sheet.
|
||||||
|
final VoidCallback onOpenDetails;
|
||||||
|
|
||||||
|
/// Called with `true` on finger-down, `false` on finger-up / cancel.
|
||||||
|
final ValueChanged<bool> onPttHeldChanged;
|
||||||
|
|
||||||
|
/// Own client's talk power.
|
||||||
|
final int? talkPower;
|
||||||
|
|
||||||
|
/// Talk power required to speak in current channel.
|
||||||
|
final int? neededTalkPower;
|
||||||
|
|
||||||
|
/// True when server granted talk power.
|
||||||
|
final bool? talkPowerGranted;
|
||||||
|
|
||||||
|
/// True when talk power prevents speaking.
|
||||||
|
final bool hardMuteByTalkPower;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
final l10n = AppL10n.of(context);
|
||||||
|
final isPtt = transmitMode == rust.BridgeTransmitMode.ptt;
|
||||||
|
final pttActive = audioStats?.pttActive ?? false;
|
||||||
|
|
||||||
|
final summary = voiceStatusSummary(
|
||||||
|
l10n: l10n,
|
||||||
|
transmitMode: transmitMode,
|
||||||
|
releaseTailMs: releaseTailMs,
|
||||||
|
pttBoundKeyLabel: pttBoundKeyLabel,
|
||||||
|
isTouchOnly: isTouchOnly,
|
||||||
|
inputMuted: inputMuted,
|
||||||
|
outputMuted: outputMuted,
|
||||||
|
pttActive: pttActive,
|
||||||
|
talkPower: talkPower,
|
||||||
|
neededTalkPower: neededTalkPower,
|
||||||
|
talkPowerGranted: talkPowerGranted,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Container colour based on voice state.
|
||||||
|
final containerColor = summary.talkPowerBlocked
|
||||||
|
? Colors.amber.withValues(alpha: 0.18)
|
||||||
|
: summary.muted
|
||||||
|
? theme.colorScheme.errorContainer.withValues(alpha: 0.35)
|
||||||
|
: theme.colorScheme.surfaceContainerHigh;
|
||||||
|
|
||||||
|
final borderColor = summary.talkPowerBlocked
|
||||||
|
? Colors.amber.shade700
|
||||||
|
: summary.muted
|
||||||
|
? theme.colorScheme.error
|
||||||
|
: theme.colorScheme.outlineVariant;
|
||||||
|
|
||||||
|
final borderWidth = 1.0;
|
||||||
|
|
||||||
|
return Semantics(
|
||||||
|
label: '${l10n.voiceSheetTitle}: ${summary.line1}, ${summary.line2}',
|
||||||
|
child: Material(
|
||||||
|
type: MaterialType.transparency,
|
||||||
|
child: Container(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: containerColor,
|
||||||
|
borderRadius: BorderRadius.circular(20),
|
||||||
|
border: Border.all(color: borderColor, width: borderWidth),
|
||||||
|
),
|
||||||
|
clipBehavior: Clip.antiAlias,
|
||||||
|
child: AnimatedSize(
|
||||||
|
duration: const Duration(milliseconds: 180),
|
||||||
|
curve: Curves.easeOutCubic,
|
||||||
|
alignment: Alignment.bottomCenter,
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
// ── Control row ──────────────────────────────────────
|
||||||
|
_ControlRow(
|
||||||
|
summary: summary,
|
||||||
|
inputMuted: inputMuted,
|
||||||
|
outputMuted: outputMuted,
|
||||||
|
hardMuteByTalkPower: hardMuteByTalkPower,
|
||||||
|
onToggleInputMute: onToggleInputMute,
|
||||||
|
onToggleOutputMute: onToggleOutputMute,
|
||||||
|
onOpenDetails: onOpenDetails,
|
||||||
|
),
|
||||||
|
// ── PTT row (in-channel + PTT mode only) ──────────────
|
||||||
|
if (isPtt && inChannel) ...[
|
||||||
|
Divider(
|
||||||
|
height: 1,
|
||||||
|
thickness: 0.5,
|
||||||
|
color: borderColor,
|
||||||
|
indent: 12,
|
||||||
|
endIndent: 12,
|
||||||
|
),
|
||||||
|
_PttRow(
|
||||||
|
active: pttActive,
|
||||||
|
enabled: !hardMuteByTalkPower,
|
||||||
|
onHeldChanged: onPttHeldChanged,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Control row (tap-only zone) ───────────────────────────────────────────
|
||||||
|
|
||||||
|
class _ControlRow extends StatelessWidget {
|
||||||
|
const _ControlRow({
|
||||||
|
required this.summary,
|
||||||
|
required this.inputMuted,
|
||||||
|
required this.outputMuted,
|
||||||
|
required this.hardMuteByTalkPower,
|
||||||
|
required this.onToggleInputMute,
|
||||||
|
required this.onToggleOutputMute,
|
||||||
|
required this.onOpenDetails,
|
||||||
|
});
|
||||||
|
|
||||||
|
final VoiceStatusSummary summary;
|
||||||
|
final bool inputMuted;
|
||||||
|
final bool outputMuted;
|
||||||
|
final bool hardMuteByTalkPower;
|
||||||
|
final VoidCallback onToggleInputMute;
|
||||||
|
final VoidCallback onToggleOutputMute;
|
||||||
|
final VoidCallback onOpenDetails;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
// Status dot.
|
||||||
|
Icon(
|
||||||
|
summary.micOn ? Icons.fiber_manual_record : Icons.fiber_manual_record_outlined,
|
||||||
|
size: 10,
|
||||||
|
color: summary.micOn ? theme.colorScheme.primary : theme.colorScheme.outline,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
|
||||||
|
// Status text (tappable → open details).
|
||||||
|
Expanded(
|
||||||
|
child: Semantics(
|
||||||
|
button: true,
|
||||||
|
label: summary.line1,
|
||||||
|
child: InkWell(
|
||||||
|
onTap: onOpenDetails,
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 2),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
summary.line1,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: theme.textTheme.bodyMedium?.copyWith(
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
summary.line2,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: theme.textTheme.bodySmall?.copyWith(
|
||||||
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
|
||||||
|
// Mute button.
|
||||||
|
_ToggleButton(
|
||||||
|
icon: inputMuted ? Icons.mic_off : Icons.mic,
|
||||||
|
isActive: inputMuted,
|
||||||
|
tooltip: 'Mute mic',
|
||||||
|
onPressed: hardMuteByTalkPower ? null : onToggleInputMute,
|
||||||
|
),
|
||||||
|
|
||||||
|
// Deafen button.
|
||||||
|
_ToggleButton(
|
||||||
|
icon: outputMuted ? Icons.headset_off : Icons.headset,
|
||||||
|
isActive: outputMuted,
|
||||||
|
tooltip: 'Deafen',
|
||||||
|
onPressed: onToggleOutputMute,
|
||||||
|
),
|
||||||
|
|
||||||
|
// Settings / expand chevron.
|
||||||
|
IconButton(
|
||||||
|
icon: Icon(Icons.expand_less, size: 20, color: theme.colorScheme.onSurfaceVariant),
|
||||||
|
tooltip: 'Voice settings',
|
||||||
|
onPressed: onOpenDetails,
|
||||||
|
visualDensity: VisualDensity.compact,
|
||||||
|
constraints: const BoxConstraints(minWidth: 40, minHeight: 40),
|
||||||
|
padding: EdgeInsets.zero,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Toggle button (mute / deafen) ─────────────────────────────────────────
|
||||||
|
|
||||||
|
class _ToggleButton extends StatelessWidget {
|
||||||
|
const _ToggleButton({
|
||||||
|
required this.icon,
|
||||||
|
required this.isActive,
|
||||||
|
required this.tooltip,
|
||||||
|
required this.onPressed,
|
||||||
|
});
|
||||||
|
|
||||||
|
final IconData icon;
|
||||||
|
final bool isActive;
|
||||||
|
final String tooltip;
|
||||||
|
final VoidCallback? onPressed;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
return IconButton(
|
||||||
|
icon: Icon(icon, size: 22),
|
||||||
|
tooltip: tooltip,
|
||||||
|
color: isActive ? theme.colorScheme.error : null,
|
||||||
|
onPressed: onPressed,
|
||||||
|
visualDensity: VisualDensity.compact,
|
||||||
|
constraints: const BoxConstraints(minWidth: 44, minHeight: 44),
|
||||||
|
padding: EdgeInsets.zero,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── PTT row (hold-only zone) ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
class _PttRow extends StatefulWidget {
|
||||||
|
const _PttRow({
|
||||||
|
required this.active,
|
||||||
|
required this.enabled,
|
||||||
|
required this.onHeldChanged,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// True while the engine reports the gate open.
|
||||||
|
final bool active;
|
||||||
|
|
||||||
|
/// Whether the PTT button can be engaged.
|
||||||
|
final bool enabled;
|
||||||
|
|
||||||
|
/// Called with `true` on pointer-down, `false` on pointer-up / cancel.
|
||||||
|
final ValueChanged<bool> onHeldChanged;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<_PttRow> createState() => _PttRowState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _PttRowState extends State<_PttRow> {
|
||||||
|
int? _activePointer;
|
||||||
|
bool _held = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
prepareVoiceHaptics();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _begin(PointerDownEvent event) {
|
||||||
|
if (!widget.enabled || _activePointer != null) return;
|
||||||
|
_activePointer = event.pointer;
|
||||||
|
_held = true;
|
||||||
|
widget.onHeldChanged(true);
|
||||||
|
playVoicePttHaptic(true);
|
||||||
|
setState(() {});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _end(int pointer) {
|
||||||
|
if (_activePointer != pointer) return;
|
||||||
|
_activePointer = null;
|
||||||
|
_held = false;
|
||||||
|
widget.onHeldChanged(false);
|
||||||
|
playVoicePttHaptic(false);
|
||||||
|
setState(() {});
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
final l10n = AppL10n.of(context);
|
||||||
|
final activeNow = _held || widget.active;
|
||||||
|
|
||||||
|
return Semantics(
|
||||||
|
button: true,
|
||||||
|
liveRegion: true,
|
||||||
|
label: activeNow ? l10n.pttTransmitting : l10n.pttHoldToTalk,
|
||||||
|
hint: l10n.pttHoldToTalkSemanticsHint,
|
||||||
|
child: Listener(
|
||||||
|
behavior: HitTestBehavior.opaque,
|
||||||
|
onPointerDown: _begin,
|
||||||
|
onPointerUp: (e) => _end(e.pointer),
|
||||||
|
onPointerCancel: (e) => _end(e.pointer),
|
||||||
|
child: ExcludeSemantics(
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: activeNow
|
||||||
|
? theme.colorScheme.primary
|
||||||
|
: Colors.transparent,
|
||||||
|
),
|
||||||
|
child: Center(
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
activeNow ? Icons.mic : Icons.mic_none_outlined,
|
||||||
|
size: 22,
|
||||||
|
color: activeNow
|
||||||
|
? theme.colorScheme.onPrimary
|
||||||
|
: theme.colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Text(
|
||||||
|
activeNow ? l10n.voiceMicOn : l10n.voiceModePtt,
|
||||||
|
style: theme.textTheme.bodyMedium?.copyWith(
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
letterSpacing: 0.3,
|
||||||
|
color: activeNow
|
||||||
|
? theme.colorScheme.onPrimary
|
||||||
|
: theme.colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user