refactor(flutter): extract shared AudioProcessingPanel widget (TODO-013)
Deduplicate audio processing toggle UI from voice_compact.dart and voice_settings.dart into shared AudioProcessingPanel widget in voice_settings_controls.dart. Centralize platform detection getters in voice_platform.dart.
This commit is contained in:
@@ -26,21 +26,6 @@ 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 {
|
||||
@@ -737,130 +722,12 @@ class _VoiceSheetBodyState extends State<_VoiceSheetBody> {
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
|
||||
// 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);
|
||||
|
||||
AudioProcessingPanel(
|
||||
config: _audioProcessing,
|
||||
dense: true,
|
||||
update: (mutation) {
|
||||
setState(mutation);
|
||||
_notifyAudioConfig();
|
||||
},
|
||||
),
|
||||
|
||||
@@ -2,9 +2,17 @@ import 'dart:io' show Platform;
|
||||
|
||||
import 'package:flutter/foundation.dart' show kIsWeb;
|
||||
|
||||
/// True when the host is a touch-only mobile platform without a
|
||||
/// hardware keyboard the user would bind a PTT key on.
|
||||
bool get isTouchOnlyPttHost {
|
||||
if (kIsWeb) return false;
|
||||
return Platform.isIOS || Platform.isAndroid;
|
||||
}
|
||||
// TODO(refactor): Scattered Platform.isX checks exist across ~6 Dart files.
|
||||
// Centralize all platform checks here and update call sites to use these
|
||||
// getters instead of raw Platform.isAndroid/isIOS/etc.
|
||||
bool get _notWeb => !kIsWeb;
|
||||
|
||||
bool get isTouchOnlyPttHost => _notWeb && (Platform.isIOS || Platform.isAndroid);
|
||||
|
||||
bool get isAndroidHost => _notWeb && Platform.isAndroid;
|
||||
|
||||
bool get isIosHost => _notWeb && Platform.isIOS;
|
||||
|
||||
bool get isMacOsHost => _notWeb && Platform.isMacOS;
|
||||
|
||||
bool get isDesktopSileroVadHost => _notWeb && (Platform.isWindows || Platform.isLinux);
|
||||
|
||||
@@ -7,9 +7,6 @@
|
||||
// - VAD backend
|
||||
// - platform audio-processing mode selection where available
|
||||
|
||||
import 'dart:io' show Platform;
|
||||
|
||||
import 'package:flutter/foundation.dart' show kIsWeb;
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../l10n/generated/app_localizations.dart';
|
||||
@@ -22,26 +19,6 @@ import 'voice_platform.dart';
|
||||
import 'voice_settings_controls.dart';
|
||||
import '../src/rust/api.dart' as rust;
|
||||
|
||||
bool get _isAndroid {
|
||||
if (kIsWeb) return false;
|
||||
return Platform.isAndroid;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
/// Result returned by [VoiceSettingsDialog].
|
||||
class VoiceSettingsResult {
|
||||
const VoiceSettingsResult({
|
||||
@@ -106,7 +83,7 @@ class _VoiceSettingsDialogState extends State<VoiceSettingsDialog> {
|
||||
rust.BridgeAudioProcessingConfig _buildConfig() {
|
||||
return _audioProcessing.buildConfig(
|
||||
base: widget.initialAudioConfig,
|
||||
isAndroid: _isAndroid,
|
||||
isAndroid: isAndroidHost,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -189,92 +166,10 @@ class _VoiceSettingsDialogState extends State<VoiceSettingsDialog> {
|
||||
const Divider(height: 24),
|
||||
const VoiceSectionHeader('Audio processing'),
|
||||
|
||||
// Android HW/SW selector
|
||||
if (_isAndroid) ...[
|
||||
const VoiceSubHeader('Processing backend'),
|
||||
SegmentedButton<bool>(
|
||||
style: voiceSegmentedButtonStyle(theme),
|
||||
segments: androidProcessingSegments,
|
||||
selected: {_audioProcessing.preferHardware},
|
||||
onSelectionChanged: (s) =>
|
||||
setState(() => _audioProcessing.preferHardware = s.first),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
_audioProcessing.preferHardware
|
||||
? 'Android hardware mode still falls back to WebRTC APM per stage when device effects are missing, so these controls remain available.'
|
||||
: 'Android software mode applies the full WebRTC APM control set.',
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
],
|
||||
|
||||
// DSP toggles
|
||||
const VoiceSubHeader('DSP stages'),
|
||||
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: 8),
|
||||
],
|
||||
if (!_isIos &&
|
||||
(!_isAndroid || androidShowsNsControl(_audioProcessing)))
|
||||
AudioProcessingToggleRow(
|
||||
label: 'Noise suppression (NS)',
|
||||
subtitle: 'Wiener filter · stationary noise',
|
||||
value: _audioProcessing.nsEnabled,
|
||||
onChanged: (v) =>
|
||||
setState(() => _audioProcessing.nsEnabled = v),
|
||||
),
|
||||
if (!_isIos &&
|
||||
(!_isAndroid || androidShowsAecControl(_audioProcessing)))
|
||||
AudioProcessingToggleRow(
|
||||
label: 'Echo cancellation (AEC3)',
|
||||
subtitle: _isAndroid
|
||||
? (_audioProcessing.preferHardware
|
||||
? 'Prefers device/OS effect; WebRTC AEC3 fallback when binding is unavailable'
|
||||
: '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),
|
||||
),
|
||||
if (!_isIos &&
|
||||
(!_isAndroid || androidShowsAgcControl(_audioProcessing)))
|
||||
AudioProcessingToggleRow(
|
||||
label: 'Auto gain control (AGC2)',
|
||||
subtitle: 'RNN VAD-gated · -18 dBFS target',
|
||||
value: _audioProcessing.agcEnabled,
|
||||
onChanged: (v) =>
|
||||
setState(() => _audioProcessing.agcEnabled = v),
|
||||
),
|
||||
if (!_isAndroid || androidShowsHpfControl(_audioProcessing))
|
||||
AudioProcessingToggleRow(
|
||||
label: 'High-pass filter (HPF)',
|
||||
subtitle: '80 Hz Butterworth · DC removal',
|
||||
value: _audioProcessing.hpfEnabled,
|
||||
onChanged: (v) =>
|
||||
setState(() => _audioProcessing.hpfEnabled = v),
|
||||
),
|
||||
if (!_isIos &&
|
||||
(!_isAndroid || androidShowsLimiterControl(_audioProcessing)))
|
||||
AudioProcessingToggleRow(
|
||||
label: 'Peak limiter',
|
||||
subtitle: '-1 dBFS soft-knee · 2 ms look-ahead',
|
||||
value: _audioProcessing.limiterEnabled,
|
||||
onChanged: (v) =>
|
||||
setState(() => _audioProcessing.limiterEnabled = v),
|
||||
),
|
||||
AudioProcessingPanel(
|
||||
config: _audioProcessing,
|
||||
update: (mutation) => setState(mutation),
|
||||
),
|
||||
|
||||
if (isTalkPowerBlocked(
|
||||
talkPower: widget.talkPower,
|
||||
@@ -289,22 +184,6 @@ class _VoiceSettingsDialogState extends State<VoiceSettingsDialog> {
|
||||
),
|
||||
],
|
||||
|
||||
// ── VAD ────────────────────────────────────────────────
|
||||
const Divider(height: 24),
|
||||
const VoiceSectionHeader('Voice activity detection (VAD)'),
|
||||
|
||||
const VoiceSubHeader('Backend'),
|
||||
SegmentedButton<rust.BridgeVadBackend>(
|
||||
style: voiceSegmentedButtonStyle(theme),
|
||||
segments: _isDesktopSileroVadHost
|
||||
? desktopVadBackendSegments
|
||||
: vadBackendSegments,
|
||||
selected: {_audioProcessing.vadBackend},
|
||||
onSelectionChanged: (s) =>
|
||||
setState(() => _audioProcessing.vadBackend = s.first),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
|
||||
// ── PTT capability badge ────────────────────────────────
|
||||
if (_mode == rust.BridgeTransmitMode.ptt &&
|
||||
widget.pttLevel.isNotEmpty) ...[
|
||||
@@ -318,14 +197,14 @@ class _VoiceSettingsDialogState extends State<VoiceSettingsDialog> {
|
||||
],
|
||||
|
||||
// ── Audio output route picker (mobile only) ─────────────
|
||||
if (_isAndroid || _isIos) ...[
|
||||
if (isAndroidHost || isIosHost) ...[
|
||||
const Divider(height: 24),
|
||||
const VoiceSectionHeader('Audio output'),
|
||||
const AudioOutputTile(),
|
||||
],
|
||||
|
||||
// ── Audio devices (desktop only, SRS-026) ──────────────
|
||||
if (!_isAndroid && !_isIos) ...[
|
||||
if (!isAndroidHost && !isIosHost) ...[
|
||||
const Divider(height: 24),
|
||||
const VoiceSectionHeader('Audio devices'),
|
||||
const AudioDeviceListTile(
|
||||
|
||||
@@ -3,6 +3,8 @@ import 'dart:io' show Platform;
|
||||
import 'package:flutter/foundation.dart' show kIsWeb;
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'audio_processing_config_state.dart';
|
||||
import 'voice_platform.dart';
|
||||
import '../src/rust/api.dart' as rust;
|
||||
|
||||
/// Shared compact style for voice settings segmented buttons.
|
||||
@@ -230,3 +232,173 @@ class AudioProcessingToggleRow extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
typedef AudioFieldUpdater = void Function(VoidCallback mutation);
|
||||
|
||||
class AudioProcessingPanel extends StatelessWidget {
|
||||
const AudioProcessingPanel({
|
||||
super.key,
|
||||
required this.config,
|
||||
required this.update,
|
||||
this.dense = false,
|
||||
});
|
||||
|
||||
final AudioProcessingConfigState config;
|
||||
final AudioFieldUpdater update;
|
||||
final bool dense;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final isAndroid = isAndroidHost;
|
||||
final isIos = isIosHost;
|
||||
final isMacOS = isMacOsHost;
|
||||
final isDesktopSilero = isDesktopSileroVadHost;
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
if (isAndroid) ...[
|
||||
if (!dense) const VoiceSubHeader('Processing backend'),
|
||||
if (dense) _compactLabel(context, 'Processing backend'),
|
||||
SegmentedButton<bool>(
|
||||
style: voiceSegmentedButtonStyle(theme),
|
||||
segments: androidProcessingSegments,
|
||||
selected: {config.preferHardware},
|
||||
onSelectionChanged: (s) {
|
||||
update(() => config.preferHardware = s.first);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
config.preferHardware
|
||||
? (dense
|
||||
? 'Hardware mode still keeps per-stage WebRTC fallback, so these controls remain effective.'
|
||||
: 'Android hardware mode still falls back to WebRTC APM per stage when device effects are missing, so these controls remain available.')
|
||||
: (dense
|
||||
? 'Software mode applies the full WebRTC APM stage set.'
|
||||
: 'Android software mode applies the full WebRTC APM control set.'),
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
if (!dense) const SizedBox(height: 8),
|
||||
],
|
||||
|
||||
if (!dense) const VoiceSubHeader('DSP stages'),
|
||||
|
||||
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 &&
|
||||
(!isAndroid || androidShowsNsControl(config)))
|
||||
AudioProcessingToggleRow(
|
||||
dense: dense,
|
||||
label: dense ? 'Noise suppression' : 'Noise suppression (NS)',
|
||||
subtitle: dense
|
||||
? 'Wiener filter'
|
||||
: 'Wiener filter · stationary noise',
|
||||
value: config.nsEnabled,
|
||||
onChanged: (v) => update(() => config.nsEnabled = v),
|
||||
),
|
||||
if (!isIos &&
|
||||
(!isAndroid || androidShowsAecControl(config)))
|
||||
AudioProcessingToggleRow(
|
||||
dense: dense,
|
||||
label: dense ? 'Echo cancellation' : 'Echo cancellation (AEC3)',
|
||||
subtitle: _aecSubtitle(isAndroid, isMacOS, config),
|
||||
value: isMacOS ? true : config.aecEnabled,
|
||||
onChanged:
|
||||
isMacOS ? null : (v) => update(() => config.aecEnabled = v),
|
||||
),
|
||||
if (!isIos &&
|
||||
(!isAndroid || androidShowsAgcControl(config)))
|
||||
AudioProcessingToggleRow(
|
||||
dense: dense,
|
||||
label: dense ? 'Auto gain control' : 'Auto gain control (AGC2)',
|
||||
subtitle: dense
|
||||
? 'AGC2 · -18 dBFS target'
|
||||
: 'RNN VAD-gated · -18 dBFS target',
|
||||
value: config.agcEnabled,
|
||||
onChanged: (v) => update(() => config.agcEnabled = v),
|
||||
),
|
||||
if (!isAndroid || androidShowsHpfControl(config))
|
||||
AudioProcessingToggleRow(
|
||||
dense: dense,
|
||||
label: dense ? 'High-pass filter' : 'High-pass filter (HPF)',
|
||||
subtitle: dense
|
||||
? '80 Hz · DC removal'
|
||||
: '80 Hz Butterworth · DC removal',
|
||||
value: config.hpfEnabled,
|
||||
onChanged: (v) => update(() => config.hpfEnabled = v),
|
||||
),
|
||||
if (!isIos &&
|
||||
(!isAndroid || androidShowsLimiterControl(config)))
|
||||
AudioProcessingToggleRow(
|
||||
dense: dense,
|
||||
label: 'Peak limiter',
|
||||
subtitle: '-1 dBFS soft-knee · 2 ms look-ahead',
|
||||
value: config.limiterEnabled,
|
||||
onChanged: (v) => update(() => config.limiterEnabled = v),
|
||||
),
|
||||
|
||||
if (!dense) ...[
|
||||
const Divider(height: 24),
|
||||
const VoiceSectionHeader('Voice activity detection (VAD)'),
|
||||
const VoiceSubHeader('Backend'),
|
||||
] else ...[
|
||||
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:
|
||||
isDesktopSilero ? desktopVadBackendSegments : vadBackendSegments,
|
||||
selected: {config.vadBackend},
|
||||
onSelectionChanged: (s) {
|
||||
update(() => config.vadBackend = s.first);
|
||||
},
|
||||
),
|
||||
if (!dense) const SizedBox(height: 8),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
String _aecSubtitle(bool isAndroid, bool isMacOS, AudioProcessingConfigState c) {
|
||||
if (isAndroid) {
|
||||
return c.preferHardware
|
||||
? 'Prefers device/OS effect; falls back to WebRTC AEC3'
|
||||
: 'WebRTC AEC3 · adaptive filter';
|
||||
}
|
||||
if (isMacOS) return 'Managed by platform VPIO';
|
||||
return 'WebRTC AEC3 · adaptive filter';
|
||||
}
|
||||
|
||||
Widget _compactLabel(BuildContext context, String text) {
|
||||
final theme = Theme.of(context);
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 2),
|
||||
child: Text(
|
||||
text,
|
||||
style: theme.textTheme.labelLarge?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user