// Compact voice UI for narrow / mobile layouts (Plan E hybrid: // 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 // is now the **single** voice-controls surface on mobile. Mode + // release-tail are surfaced inline (radio buttons + slider) inside // the modal. import 'dart:async' show Timer, unawaited; import 'dart:io' show Platform; import 'package:flutter/foundation.dart' show kIsWeb; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:haptic_kit/haptic_kit.dart' show Haptics; import '../l10n/generated/app_localizations.dart'; import 'audio_output_tile.dart'; import 'audio_processing_config_state.dart'; import 'ptt_capability_badge.dart'; import 'talk_power_warning.dart'; import 'voice_haptics.dart'; import 'voice_level_meter.dart'; import 'voice_settings_controls.dart'; import 'voice_status_summary.dart'; import '../src/rust/api.dart' as rust; bool get _isIos { if (kIsWeb) return false; return Platform.isIOS; } bool get _isMacOS { if (kIsWeb) return false; return Platform.isMacOS; } bool get _isDesktopSileroVadHost { if (kIsWeb) return false; return Platform.isWindows || Platform.isLinux; } /// Two-line status chip that summarises the current voice state. /// Tap to open the voice details modal. class VoiceStatusChip extends StatelessWidget { /// Construct a status chip. const VoiceStatusChip({ super.key, required this.transmitMode, required this.releaseTailMs, required this.pttBoundKeyLabel, required this.audioStats, required this.isTouchOnly, required this.onTap, this.inputMuted = false, this.outputMuted = false, this.talkPower, this.neededTalkPower, this.talkPowerGranted, }); /// 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 (hard mute or permission mute). final bool inputMuted; /// True when local speaker is muted. final bool outputMuted; /// 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 regardless of numeric value. final bool? talkPowerGranted; /// Open the voice details modal. final VoidCallback onTap; @override Widget build(BuildContext context) { final theme = Theme.of(context); final l10n = AppL10n.of(context); final summary = voiceStatusSummary( l10n: l10n, transmitMode: transmitMode, releaseTailMs: releaseTailMs, pttBoundKeyLabel: pttBoundKeyLabel, isTouchOnly: isTouchOnly, inputMuted: inputMuted, outputMuted: outputMuted, pttActive: audioStats?.pttActive ?? false, talkPower: talkPower, neededTalkPower: neededTalkPower, talkPowerGranted: talkPowerGranted, ); return Semantics( button: true, label: '${l10n.voiceSheetTitle}: ${summary.line1}, ${summary.line2}', hint: l10n.voiceSettingsTitle, child: Material( type: MaterialType.transparency, child: InkWell( onTap: () { HapticFeedback.lightImpact(); onTap(); }, borderRadius: BorderRadius.circular(12), child: ExcludeSemantics( child: Container( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), decoration: BoxDecoration( color: summary.talkPowerBlocked ? Colors.amber.withValues(alpha: 0.18) : summary.muted ? theme.colorScheme.errorContainer.withValues(alpha: 0.35) : theme.colorScheme.surfaceContainerHigh, borderRadius: BorderRadius.circular(12), border: Border.all( color: summary.talkPowerBlocked ? Colors.amber.shade700 : summary.muted ? theme.colorScheme.error : theme.colorScheme.outlineVariant, width: summary.talkPowerBlocked || summary.muted ? 1.5 : 0.5, ), ), child: Row( children: [ Icon( summary.micOn ? Icons.fiber_manual_record : Icons.fiber_manual_record_outlined, size: 12, color: summary.micOn ? theme.colorScheme.primary : theme.colorScheme.outline, ), const SizedBox(width: 8), Expanded( 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: 8), Icon( Icons.expand_less, size: 18, color: theme.colorScheme.onSurfaceVariant, ), ], ), ), ), ), ), ); } } /// Wide, bottom-anchored push-to-talk button. class VoicePttButton extends StatefulWidget { /// Construct a PTT button. const VoicePttButton({ super.key, required this.active, required this.onHeldChanged, this.height = 56, this.borderRadius = 16, this.iconSize = 28, this.iconGap = 12, this.blurRadius = 16, this.spreadRadius = 2, this.listenForPan = false, this.labelLetterSpacing = 0.4, }); /// True while the engine reports the gate open. final bool active; /// Called with `true` on finger-down, `false` on finger-up or cancel. final ValueChanged onHeldChanged; /// Button height. final double height; /// Outer corner radius. final double borderRadius; /// Mic icon size. final double iconSize; /// Space between icon and label. final double iconGap; /// Active-state shadow blur radius. final double blurRadius; /// Active-state shadow spread radius. final double spreadRadius; /// Also react to pan start/end/cancel in addition to tap gestures. final bool listenForPan; /// Optional label letter spacing. final double? labelLetterSpacing; @override State createState() => _VoicePttButtonState(); } class _VoicePttButtonState extends State { bool _pressed = false; @override void initState() { super.initState(); prepareVoiceHaptics(); } void _setHeld(bool held) { if (_pressed == held) return; setState(() => _pressed = held); widget.onHeldChanged(held); playVoicePttHaptic(held); } @override Widget build(BuildContext context) { final theme = Theme.of(context); final l10n = AppL10n.of(context); final activeNow = _pressed || widget.active; return Semantics( button: true, liveRegion: true, label: activeNow ? l10n.pttTransmitting : l10n.pttHoldToTalk, hint: l10n.pttHoldToTalkSemanticsHint, child: GestureDetector( behavior: HitTestBehavior.opaque, onTapDown: (_) => _setHeld(true), onTapUp: (_) => _setHeld(false), onTapCancel: () => _setHeld(false), onPanDown: widget.listenForPan ? (_) => _setHeld(true) : null, onPanEnd: widget.listenForPan ? (_) => _setHeld(false) : null, onPanCancel: widget.listenForPan ? () => _setHeld(false) : null, child: ExcludeSemantics( child: AnimatedContainer( duration: const Duration(milliseconds: 80), height: widget.height, decoration: BoxDecoration( color: activeNow ? theme.colorScheme.primary : theme.colorScheme.primaryContainer, borderRadius: BorderRadius.circular(widget.borderRadius), boxShadow: activeNow ? [ BoxShadow( color: theme.colorScheme.primary.withAlpha(100), blurRadius: widget.blurRadius, spreadRadius: widget.spreadRadius, offset: const Offset(0, 2), ), ] : null, ), child: Center( child: Row( mainAxisSize: MainAxisSize.min, children: [ Icon( activeNow ? Icons.mic : Icons.mic_none, color: activeNow ? theme.colorScheme.onPrimary : theme.colorScheme.onPrimaryContainer, size: widget.iconSize, ), SizedBox(width: widget.iconGap), Text( activeNow ? l10n.voiceMicOn : l10n.voiceModePtt, style: theme.textTheme.titleMedium?.copyWith( fontWeight: FontWeight.w600, letterSpacing: widget.labelLetterSpacing, color: activeNow ? theme.colorScheme.onPrimary : theme.colorScheme.onPrimaryContainer, ), ), ], ), ), ), ), ), ); } } /// Show the voice controls modal sheet — the single voice-controls /// surface on mobile. Tiles: /// 1. Audio output route picker (iOS native AVRoutePickerView / /// Android Material 3 list). Mobile only. /// 2. Mode radio buttons (PTT / Continuous / Voice Activity). /// 3. Release-tail slider (PTT-only). /// 4. Mic level meter + frame counts. /// 5. Audio processing (NS · AEC · AGC · HPF · VAD) — all modes. /// 6. PTT capability badge. /// /// Mode + release-tail are inlined directly here instead of being /// hidden behind an "Adjust" button → nested dialog. Single-screen /// control panel, zero navigation depth. `onModeChanged` and /// `onReleaseTailChanged` are debounced by the caller so users can /// drag the slider freely. Future showVoiceDetailsSheet( BuildContext context, { required rust.BridgeTransmitMode transmitMode, required int releaseTailMs, required String pttBoundKeyLabel, required String pttLevel, required String pttBackendId, required String pttBoundInputClass, required bool isTouchOnly, required rust.BridgeAudioProcessingConfig initialAudioConfig, bool onnxRuntimeAvailable = true, required ValueChanged onModeChanged, required ValueChanged onReleaseTailChanged, required ValueChanged onAudioConfigChanged, int? talkPower, int? neededTalkPower, bool? talkPowerGranted, }) async { await showModalBottomSheet( context: context, showDragHandle: true, isScrollControlled: true, useSafeArea: true, builder: (ctx) { return DraggableScrollableSheet( initialChildSize: 0.6, minChildSize: 0.3, maxChildSize: 0.95, expand: false, snap: true, snapSizes: const [0.3, 0.6, 0.95], builder: (ctx, scrollController) => _VoiceSheetBody( scrollController: scrollController, initialMode: transmitMode, initialReleaseTailMs: releaseTailMs, pttBoundKeyLabel: pttBoundKeyLabel, pttLevel: pttLevel, pttBackendId: pttBackendId, pttBoundInputClass: pttBoundInputClass, isTouchOnly: isTouchOnly, initialAudioConfig: initialAudioConfig, onnxRuntimeAvailable: onnxRuntimeAvailable, onModeChanged: onModeChanged, onReleaseTailChanged: onReleaseTailChanged, onAudioConfigChanged: onAudioConfigChanged, talkPower: talkPower, neededTalkPower: neededTalkPower, talkPowerGranted: talkPowerGranted, ), ); }, ); } class _VoiceSheetBody extends StatefulWidget { const _VoiceSheetBody({ required this.scrollController, required this.initialMode, required this.initialReleaseTailMs, required this.pttBoundKeyLabel, required this.pttLevel, required this.pttBackendId, required this.pttBoundInputClass, required this.isTouchOnly, required this.initialAudioConfig, required this.onnxRuntimeAvailable, required this.onModeChanged, required this.onReleaseTailChanged, required this.onAudioConfigChanged, this.talkPower, this.neededTalkPower, this.talkPowerGranted, }); final ScrollController scrollController; final rust.BridgeTransmitMode initialMode; final int initialReleaseTailMs; final String pttBoundKeyLabel; final String pttLevel; final String pttBackendId; final String pttBoundInputClass; final bool isTouchOnly; final rust.BridgeAudioProcessingConfig initialAudioConfig; final bool onnxRuntimeAvailable; final ValueChanged onModeChanged; final ValueChanged onReleaseTailChanged; final ValueChanged onAudioConfigChanged; final int? talkPower; final int? neededTalkPower; final bool? talkPowerGranted; @override State<_VoiceSheetBody> createState() => _VoiceSheetBodyState(); } class _VoiceSheetBodyState extends State<_VoiceSheetBody> { late rust.BridgeTransmitMode _mode = widget.initialMode; late int _tail = widget.initialReleaseTailMs; // Live stats — polled by this widget's own timer so TX/RX update // in real time while the sheet is open, independent of the parent. rust.BridgeAudioStats? _stats; Timer? _statsTimer; // Previous snapshot for computing per-second rates. int _prevSent = 0; int _prevReceived = 0; int _txRate = 0; // frames/s int _rxRate = 0; // frames/s int _rateTickCount = 0; late final AudioProcessingConfigState _audioProcessing; @override void initState() { super.initState(); _audioProcessing = AudioProcessingConfigState.fromConfig( widget.initialAudioConfig, ); _audioProcessing.vadBackend = normalizedVadBackend( _audioProcessing.vadBackend, onnxRuntimeAvailable: widget.onnxRuntimeAvailable, ); // Poll audio stats at 250 ms so TX/RX counters and the level meter // update in real time while the sheet is open, independent of the parent. _statsTimer = Timer.periodic(const Duration(milliseconds: 250), (_) async { try { final s = await rust.audioStats(); if (!mounted) return; setState(() { _stats = s; _rateTickCount++; // Compute rates every ~1 s (4 × 250 ms). if (_rateTickCount >= 4) { _txRate = s.framesSent - _prevSent; _rxRate = s.framesReceived - _prevReceived; _prevSent = s.framesSent; _prevReceived = s.framesReceived; _rateTickCount = 0; } }); } catch (_) {} }); } @override void dispose() { _statsTimer?.cancel(); super.dispose(); } rust.BridgeAudioProcessingConfig _buildConfig() { return _audioProcessing.buildConfig(base: widget.initialAudioConfig); } void _notifyAudioConfig() { widget.onAudioConfigChanged(_buildConfig()); } void _setMode(rust.BridgeTransmitMode m) { if (m == _mode) return; setState(() => _mode = m); unawaited(Haptics.selection().catchError((_) {})); widget.onModeChanged(m); } void _setTail(double v) { final ms = v.round(); setState(() => _tail = ms); widget.onReleaseTailChanged(ms); } @override Widget build(BuildContext context) { final theme = Theme.of(context); final l10n = AppL10n.of(context); final stats = _stats; // Level meter active = transmitting (any mode). final levelActive = switch (_mode) { rust.BridgeTransmitMode.continuous => true, _ => stats?.pttActive ?? false, }; final isPtt = _mode == rust.BridgeTransmitMode.ptt; // Route picker is mobile-only. iOS uses AVAudioSession below; // Android uses AudioManager through a MethodChannel. final showRoutePicker = !kIsWeb && (Platform.isIOS || Platform.isAndroid); return SafeArea( child: SingleChildScrollView( controller: widget.scrollController, padding: const EdgeInsets.fromLTRB(20, 8, 20, 24), child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Text(l10n.voiceSheetTitle, style: theme.textTheme.titleLarge), const SizedBox(height: 12), // 1) Audio output route picker tile (mobile only). if (showRoutePicker) ...[ const AudioOutputTile(), const SizedBox(height: 8), Divider(height: 1, color: theme.colorScheme.outlineVariant), const SizedBox(height: 8), ], // Transmit mode. Text( l10n.voiceModeLabel, style: theme.textTheme.labelLarge?.copyWith( color: theme.colorScheme.onSurfaceVariant, ), ), const SizedBox(height: 4), _ModeRow( label: l10n.voiceModePtt, icon: Icons.radio_button_checked, selected: _mode == rust.BridgeTransmitMode.ptt, onTap: () => _setMode(rust.BridgeTransmitMode.ptt), ), _ModeRow( label: l10n.voiceModeContinuous, icon: Icons.podcasts, selected: _mode == rust.BridgeTransmitMode.continuous, onTap: () => _setMode(rust.BridgeTransmitMode.continuous), ), _ModeRow( label: l10n.voiceModeVoiceActivity, icon: Icons.graphic_eq, selected: _mode == rust.BridgeTransmitMode.voiceActivity, onTap: () => _setMode(rust.BridgeTransmitMode.voiceActivity), ), // 3) Release-tail slider (PTT only). if (isPtt) ...[ const SizedBox(height: 8), Row( children: [ Text( l10n.voiceReleaseTailLabel, style: theme.textTheme.labelLarge?.copyWith( color: theme.colorScheme.onSurfaceVariant, ), ), const Spacer(), Text( '$_tail${l10n.voiceReleaseTailHint}', style: theme.textTheme.bodyMedium?.copyWith( fontFeatures: const [FontFeature.tabularFigures()], ), ), ], ), Slider( value: _tail.toDouble().clamp(0, 500), min: 0, max: 500, divisions: 20, label: '$_tail ms', onChanged: _setTail, ), // Desktop-only: surface the bound key so the user // sees what hardware key is wired. On mobile this // row is suppressed (there is no hardware key; the // PTT button is the input). if (!widget.isTouchOnly && widget.pttBoundKeyLabel.isNotEmpty) ...[ Padding( padding: const EdgeInsets.only(left: 4), child: Text( '${l10n.voiceBoundKeyLabel}: ${widget.pttBoundKeyLabel}', style: theme.textTheme.bodySmall?.copyWith( color: theme.colorScheme.onSurfaceVariant, ), ), ), ], ], const SizedBox(height: 16), Divider(height: 1, color: theme.colorScheme.outlineVariant), const SizedBox(height: 12), // 4) Level meter + live TX/RX stats. VoiceLevelMeter(active: levelActive), const SizedBox(height: 6), _StatsRow( txRate: _txRate, rxRate: _rxRate, totalSent: stats?.framesSent ?? 0, totalReceived: stats?.framesReceived ?? 0, transmitting: levelActive, ), if (isTalkPowerBlocked( talkPower: widget.talkPower, neededTalkPower: widget.neededTalkPower, talkPowerGranted: widget.talkPowerGranted, )) ...[ const SizedBox(height: 8), TalkPowerWarning( talkPower: widget.talkPower, neededTalkPower: widget.neededTalkPower, talkPowerGranted: widget.talkPowerGranted, ), ], // Audio processing. const SizedBox(height: 12), Divider(height: 1, color: theme.colorScheme.outlineVariant), const SizedBox(height: 8), Text( 'Audio processing', style: theme.textTheme.labelLarge?.copyWith( color: theme.colorScheme.onSurfaceVariant, ), ), const SizedBox(height: 4), // Android HW/SW selector. if (Platform.isAndroid) ...[ const VoiceSubHeader('Processing backend'), SegmentedButton( 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( style: voiceSegmentedButtonStyle(theme), segments: vadBackendSegmentsForAvailability( desktop: _isDesktopSileroVadHost, onnxRuntimeAvailable: widget.onnxRuntimeAvailable, ), selected: {_audioProcessing.vadBackend}, onSelectionChanged: (s) { setState( () => _audioProcessing.vadBackend = normalizedVadBackend( s.first, onnxRuntimeAvailable: widget.onnxRuntimeAvailable, ), ); _notifyAudioConfig(); }, ), if (_isDesktopSileroVadHost) ...[ const SizedBox(height: 4), Text( widget.onnxRuntimeAvailable ? 'Silero needs ONNX Runtime. WebRTC works without it.' : 'Silero is unavailable because ONNX Runtime was not found. WebRTC is selected.', style: theme.textTheme.bodySmall?.copyWith( color: theme.colorScheme.onSurfaceVariant, ), ), ], // 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, ), ], ], ), ), ); } } // ── Live TX/RX stats row ────────────────────────────────────────────────── class _StatsRow extends StatelessWidget { const _StatsRow({ required this.txRate, required this.rxRate, required this.totalSent, required this.totalReceived, required this.transmitting, }); final int txRate; final int rxRate; final int totalSent; final int totalReceived; final bool transmitting; @override Widget build(BuildContext context) { final theme = Theme.of(context); final txColor = transmitting ? theme.colorScheme.primary : theme.colorScheme.onSurfaceVariant; return Row( children: [ // TX Icon(Icons.upload, size: 12, color: txColor), const SizedBox(width: 3), Text( 'TX $txRate/s · $totalSent', style: theme.textTheme.bodySmall?.copyWith(color: txColor), ), const SizedBox(width: 12), // RX Icon( Icons.download, size: 12, color: theme.colorScheme.onSurfaceVariant, ), const SizedBox(width: 3), Text( 'RX $rxRate/s · $totalReceived', style: theme.textTheme.bodySmall?.copyWith( color: theme.colorScheme.onSurfaceVariant, ), ), ], ); } } // ── Audio processing helper widgets ────────────────────────────────────── // ── Mode row ────────────────────────────────────────────────────────────── class _ModeRow extends StatelessWidget { const _ModeRow({ required this.label, required this.icon, required this.selected, required this.onTap, }); final String label; final IconData icon; final bool selected; final VoidCallback? onTap; @override Widget build(BuildContext context) { final theme = Theme.of(context); final disabled = onTap == null; final color = disabled ? theme.colorScheme.onSurfaceVariant.withAlpha(120) : selected ? theme.colorScheme.primary : theme.colorScheme.onSurface; return Semantics( button: true, selected: selected, enabled: !disabled, label: label, child: InkWell( onTap: onTap, borderRadius: BorderRadius.circular(8), child: ExcludeSemantics( child: Padding( padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 4), child: Row( children: [ Icon( selected ? Icons.radio_button_checked : Icons.radio_button_unchecked, size: 20, color: color, ), const SizedBox(width: 12), Icon(icon, size: 18, color: color), const SizedBox(width: 8), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( label, style: theme.textTheme.bodyLarge?.copyWith( color: color, ), ), ], ), ), ], ), ), ), ), ); } }