feat(voice): add iOS VAD runtime support
This commit is contained in:
@@ -0,0 +1,209 @@
|
||||
// P1 debug stats overlay widget.
|
||||
//
|
||||
// Shows a compact, auto-refreshing panel with the key audio processing
|
||||
// metrics from [BridgeAudioProcessingStats]. Intended for internal
|
||||
// debug builds only — wrap with a kDebugMode guard at the call site.
|
||||
//
|
||||
// Usage:
|
||||
// if (kDebugMode) const AudioDebugStatsPanel(),
|
||||
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../src/rust/api.dart';
|
||||
|
||||
/// Compact debug panel that polls [audioProcessingStats] every 500 ms
|
||||
/// and renders the key metrics in a monospace overlay.
|
||||
///
|
||||
/// Designed to be placed in a [Stack] over the main UI during
|
||||
/// development. It is transparent to hit-testing so it does not
|
||||
/// interfere with taps.
|
||||
class AudioDebugStatsPanel extends StatefulWidget {
|
||||
const AudioDebugStatsPanel({super.key});
|
||||
|
||||
@override
|
||||
State<AudioDebugStatsPanel> createState() => _AudioDebugStatsPanelState();
|
||||
}
|
||||
|
||||
class _AudioDebugStatsPanelState extends State<AudioDebugStatsPanel> {
|
||||
BridgeAudioProcessingStats? _stats;
|
||||
Timer? _timer;
|
||||
String? _error;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_poll();
|
||||
_timer = Timer.periodic(const Duration(milliseconds: 500), (_) => _poll());
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_timer?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _poll() async {
|
||||
try {
|
||||
final stats = await audioProcessingStats();
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_stats = stats;
|
||||
_error = null;
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
setState(() => _error = e.toString());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return IgnorePointer(
|
||||
child: Align(
|
||||
alignment: Alignment.topRight,
|
||||
child: SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
child: _buildPanel(),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPanel() {
|
||||
if (_error != null) {
|
||||
return _PanelBox(
|
||||
child: Text(
|
||||
'audio stats error:\n$_error',
|
||||
style: _monoStyle(Colors.red),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final s = _stats;
|
||||
if (s == null) {
|
||||
return _PanelBox(
|
||||
child: Text('audio stats: loading…', style: _monoStyle(Colors.grey)),
|
||||
);
|
||||
}
|
||||
|
||||
final vadColor = s.vadActive ? Colors.greenAccent : Colors.grey;
|
||||
final txColor = s.transmitting ? Colors.redAccent : Colors.grey;
|
||||
final xruns = s.callbackXruns + s.inputOverruns + s.outputUnderruns;
|
||||
|
||||
return _PanelBox(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
_row('route', _routeLabel(s.audioRoute), Colors.white),
|
||||
_row('backend', _backendLabel(s.processingBackend), Colors.white),
|
||||
_row(
|
||||
'vpio',
|
||||
s.platformVoiceProcessingEnabled ? 'on' : 'off',
|
||||
Colors.white,
|
||||
),
|
||||
_row('sonora', s.sonoraEnabled ? 'on' : 'off', Colors.white),
|
||||
const SizedBox(height: 4),
|
||||
_row(
|
||||
'mic in',
|
||||
'${s.inputDbfs.toStringAsFixed(1)} dBFS',
|
||||
Colors.white,
|
||||
),
|
||||
_row(
|
||||
'mic out',
|
||||
'${s.processedDbfs.toStringAsFixed(1)} dBFS',
|
||||
Colors.white,
|
||||
),
|
||||
_row(
|
||||
'render',
|
||||
'${s.renderDbfs.toStringAsFixed(1)} dBFS',
|
||||
Colors.white,
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
_row(
|
||||
'vad',
|
||||
'${(s.vadProbability * 100).toStringAsFixed(0)}% '
|
||||
'${s.vadActive ? "OPEN" : "closed"}',
|
||||
vadColor,
|
||||
),
|
||||
_row('vad backend', _vadBackendLabel(s.vadBackend), Colors.white),
|
||||
if (s.vadFallbackActive)
|
||||
_row('vad fallback', 'ACTIVE', Colors.orange),
|
||||
const SizedBox(height: 4),
|
||||
_row('tx', s.transmitting ? 'TRANSMITTING' : 'idle', txColor),
|
||||
_row('sr', '${s.actualSampleRateHz} Hz', Colors.white),
|
||||
_row('buf', '${s.actualIoBufferFrames} frames', Colors.white),
|
||||
if (xruns > BigInt.zero)
|
||||
_row('xruns', xruns.toString(), Colors.orange),
|
||||
if (s.clippedSamples > BigInt.zero)
|
||||
_row('clipped', s.clippedSamples.toString(), Colors.orange),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _row(String label, String value, Color valueColor) {
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text('$label: ', style: _monoStyle(Colors.grey.shade400)),
|
||||
Text(value, style: _monoStyle(valueColor)),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
TextStyle _monoStyle(Color color) => TextStyle(
|
||||
fontFamily: 'monospace',
|
||||
fontSize: 10,
|
||||
color: color,
|
||||
height: 1.4,
|
||||
);
|
||||
|
||||
String _routeLabel(BridgeAudioRoute route) => switch (route) {
|
||||
BridgeAudioRoute.speaker => 'speaker',
|
||||
BridgeAudioRoute.earpiece => 'earpiece',
|
||||
BridgeAudioRoute.wiredHeadset => 'wired',
|
||||
BridgeAudioRoute.bluetoothHfp => 'bt-hfp',
|
||||
BridgeAudioRoute.bluetoothA2Dp => 'bt-a2dp',
|
||||
BridgeAudioRoute.unknown => 'unknown',
|
||||
};
|
||||
|
||||
String _backendLabel(BridgeAudioBackend backend) => switch (backend) {
|
||||
BridgeAudioBackend.platformVoiceProcessing => 'vpio',
|
||||
BridgeAudioBackend.sonora => 'sonora',
|
||||
BridgeAudioBackend.noop => 'noop',
|
||||
BridgeAudioBackend.webrtcApm => 'webrtc-apm',
|
||||
};
|
||||
|
||||
String _vadBackendLabel(BridgeVadBackend backend) => switch (backend) {
|
||||
BridgeVadBackend.webrtcVad => 'webrtc',
|
||||
BridgeVadBackend.sileroOnnx => 'silero',
|
||||
BridgeVadBackend.tenVad => 'ten',
|
||||
BridgeVadBackend.energyDebug => 'energy',
|
||||
BridgeVadBackend.disabled => 'off',
|
||||
};
|
||||
}
|
||||
|
||||
/// Semi-transparent dark box for the debug panel.
|
||||
class _PanelBox extends StatelessWidget {
|
||||
const _PanelBox({required this.child});
|
||||
final Widget child;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black.withValues(alpha: 0.72),
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
/// SRS-209 listen-only banner for Android RECORD_AUDIO permission.
|
||||
/// SRS-209 listen-only banner for mobile microphone permission.
|
||||
///
|
||||
/// Trace:
|
||||
/// - SDD-106 §2 (denial UX — non-blocking affordance "Enable
|
||||
@@ -6,17 +6,19 @@
|
||||
/// - SRS-209 (path to grant; listen-only fallback).
|
||||
///
|
||||
/// Behaviour:
|
||||
/// * Watches [AndroidPermissionsService.recordAudioState].
|
||||
/// * Watches the injected microphone permission state listenable.
|
||||
/// * On `denied`: renders a non-modal banner with a "Grant" action.
|
||||
/// * On `permanentlyDenied`: action text becomes "Open Settings" and
|
||||
/// invokes [AndroidPermissionsService.openAppSettings].
|
||||
/// * On `granted` / `unknown`: builds an empty [SizedBox.shrink].
|
||||
/// * On non-Android hosts the service stays at `granted`, so this
|
||||
/// widget is effectively invisible without any extra branching.
|
||||
/// * On platforms whose service stays at `granted`, this widget is
|
||||
/// effectively invisible without any extra branching.
|
||||
library;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import '../l10n/generated/app_localizations.dart';
|
||||
import '../services/android_permissions_service.dart';
|
||||
|
||||
/// Listen-only banner widget. Drop this above the `VoiceBar` in the
|
||||
@@ -24,43 +26,69 @@ import '../services/android_permissions_service.dart';
|
||||
///
|
||||
/// Trace: SDD-106 §2, §3; SRS-209.
|
||||
class PermissionStateBanner extends StatelessWidget {
|
||||
const PermissionStateBanner({super.key, required this.service});
|
||||
PermissionStateBanner({super.key, required AndroidPermissionsService service})
|
||||
: recordAudioState = service.recordAudioState,
|
||||
ensureRecordAudio = service.ensureRecordAudio,
|
||||
openAppSettings = service.openAppSettings;
|
||||
|
||||
/// Permissions service whose [AndroidPermissionsService.recordAudioState]
|
||||
/// drives the banner.
|
||||
final AndroidPermissionsService service;
|
||||
const PermissionStateBanner.fromCallbacks({
|
||||
super.key,
|
||||
required this.recordAudioState,
|
||||
required this.ensureRecordAudio,
|
||||
required this.openAppSettings,
|
||||
});
|
||||
|
||||
final ValueListenable<AndroidRecordAudioPermissionState> recordAudioState;
|
||||
final Future<AndroidRecordAudioPermissionState> Function() ensureRecordAudio;
|
||||
final Future<void> Function() openAppSettings;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ValueListenableBuilder<AndroidRecordAudioPermissionState>(
|
||||
valueListenable: service.recordAudioState,
|
||||
valueListenable: recordAudioState,
|
||||
builder: (ctx, state, _) {
|
||||
switch (state) {
|
||||
case AndroidRecordAudioPermissionState.granted:
|
||||
case AndroidRecordAudioPermissionState.unknown:
|
||||
return const SizedBox.shrink();
|
||||
case AndroidRecordAudioPermissionState.denied:
|
||||
return _BannerBody(
|
||||
// TODO(localization): route through AppL10n once an arb
|
||||
// entry exists. SRS-209 requires the message; the
|
||||
// English literal is a placeholder.
|
||||
message:
|
||||
'Microphone permission required for voice transmission.',
|
||||
actionLabel: 'Grant',
|
||||
onPressed: () => service.ensureRecordAudio(),
|
||||
);
|
||||
case AndroidRecordAudioPermissionState.permanentlyDenied:
|
||||
return _BannerBody(
|
||||
// TODO(localization): see above.
|
||||
message:
|
||||
'Microphone permission required for voice transmission.',
|
||||
actionLabel: 'Open Settings',
|
||||
onPressed: () => service.openAppSettings(),
|
||||
);
|
||||
final l10n = AppL10n.of(ctx);
|
||||
final action = _actionFor(state, l10n);
|
||||
if (action == null) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
return _BannerBody(
|
||||
message: l10n.microphonePermissionRequiredForVoice,
|
||||
actionLabel: action.label,
|
||||
onPressed: action.onPressed,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
_BannerAction? _actionFor(
|
||||
AndroidRecordAudioPermissionState state,
|
||||
AppL10n l10n,
|
||||
) {
|
||||
switch (state) {
|
||||
case AndroidRecordAudioPermissionState.granted:
|
||||
case AndroidRecordAudioPermissionState.unknown:
|
||||
return null;
|
||||
case AndroidRecordAudioPermissionState.denied:
|
||||
return _BannerAction(
|
||||
label: l10n.permissionGrantAction,
|
||||
onPressed: () => ensureRecordAudio(),
|
||||
);
|
||||
case AndroidRecordAudioPermissionState.permanentlyDenied:
|
||||
return _BannerAction(
|
||||
label: l10n.networkPermissionOpenSettings,
|
||||
onPressed: () => openAppSettings(),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class _BannerAction {
|
||||
const _BannerAction({required this.label, required this.onPressed});
|
||||
|
||||
final String label;
|
||||
final VoidCallback onPressed;
|
||||
}
|
||||
|
||||
class _BannerBody extends StatelessWidget {
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
import 'package:flutter/foundation.dart'
|
||||
show TargetPlatform, defaultTargetPlatform;
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../l10n/generated/app_localizations.dart';
|
||||
|
||||
/// PTT capability badge (gen2 v0.9.3 / SDD-091).
|
||||
///
|
||||
/// Renders the active PTT level + backend in the Voice Bar so the
|
||||
/// user understands which input path is in effect. When the
|
||||
/// resolved capability is `L0Focused` an info icon appears that
|
||||
/// opens a per-platform explanation sheet describing why Global
|
||||
/// PTT is not active and what the user can do to engage it.
|
||||
class PttCapabilityBadge extends StatelessWidget {
|
||||
/// Construct a badge.
|
||||
const PttCapabilityBadge({
|
||||
super.key,
|
||||
required this.level,
|
||||
required this.backendId,
|
||||
required this.boundInputClass,
|
||||
});
|
||||
|
||||
/// Resolved capability level as the bridge emits it
|
||||
/// (`L0Focused` / `L1WindowsHook` / `L2WindowsRawInput` /
|
||||
/// `L1MacOSEventTap` / `L1LinuxGnomeWaylandPortal`).
|
||||
final String level;
|
||||
|
||||
/// Stable backend identifier (`focused`, `windows-raw-input`, …).
|
||||
final String backendId;
|
||||
|
||||
/// Privacy-safe input class (`keyboard`, `mouse-side-button`,
|
||||
/// or empty when no binding is set).
|
||||
final String boundInputClass;
|
||||
|
||||
bool get _isFocused => level == 'L0Focused';
|
||||
|
||||
String _explainBodyForPlatform(AppL10n l10n) {
|
||||
switch (defaultTargetPlatform) {
|
||||
case TargetPlatform.windows:
|
||||
return l10n.pttCapabilityExplainGoGlobalWindows;
|
||||
case TargetPlatform.macOS:
|
||||
return l10n.pttCapabilityExplainGoGlobalMacos;
|
||||
case TargetPlatform.linux:
|
||||
return l10n.pttCapabilityExplainGoGlobalLinux;
|
||||
case TargetPlatform.iOS:
|
||||
return l10n.pttCapabilityExplainGoGlobalIos;
|
||||
default:
|
||||
return l10n.pttCapabilityExplainGoGlobalGeneric;
|
||||
}
|
||||
}
|
||||
|
||||
void _openExplanationSheet(BuildContext context) {
|
||||
final l10n = AppL10n.of(context);
|
||||
showModalBottomSheet<void>(
|
||||
context: context,
|
||||
showDragHandle: true,
|
||||
builder: (sheetContext) {
|
||||
final theme = Theme.of(sheetContext);
|
||||
return SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 4, 20, 24),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
l10n.pttCapabilityExplainTitle,
|
||||
style: theme.textTheme.titleMedium,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
l10n.pttCapabilityExplainFocusedHeading,
|
||||
style: theme.textTheme.titleSmall,
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
l10n.pttCapabilityExplainFocusedBody,
|
||||
style: theme.textTheme.bodyMedium,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
_explainBodyForPlatform(l10n),
|
||||
style: theme.textTheme.bodyMedium,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Align(
|
||||
alignment: AlignmentDirectional.centerEnd,
|
||||
child: TextButton(
|
||||
onPressed: () => Navigator.of(sheetContext).pop(),
|
||||
child: Text(l10n.closeAction),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppL10n.of(context);
|
||||
final theme = Theme.of(context);
|
||||
final badgeLabel = l10n.pttCapabilityBadge(level, backendId);
|
||||
final tooltipMessage = boundInputClass.isEmpty
|
||||
? badgeLabel
|
||||
: '$badgeLabel\n($boundInputClass)';
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 6),
|
||||
child: Tooltip(
|
||||
message: tooltipMessage,
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
_isFocused ? Icons.crop_free : Icons.public,
|
||||
size: 14,
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Expanded(
|
||||
child: Text(
|
||||
badgeLabel,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (_isFocused)
|
||||
IconButton(
|
||||
icon: const Icon(Icons.info_outline, size: 16),
|
||||
tooltip: l10n.pttCapabilityExplainTitle,
|
||||
visualDensity: VisualDensity.compact,
|
||||
onPressed: () => _openExplanationSheet(context),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -3,24 +3,16 @@
|
||||
// `BridgeEvent::VoiceState` stream the bridge publishes from the
|
||||
// core's transmit-mode selector + release-tail timer.
|
||||
|
||||
import 'dart:io' show Platform;
|
||||
import 'dart:async' show unawaited;
|
||||
|
||||
import 'package:flutter/foundation.dart' show kIsWeb;
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:haptic_kit/haptic_kit.dart';
|
||||
|
||||
import '../l10n/generated/app_localizations.dart';
|
||||
import '../main.dart' show PttCapabilityBadge;
|
||||
import 'ptt_capability_badge.dart';
|
||||
import 'voice_platform.dart';
|
||||
import '../src/rust/api.dart' as rust;
|
||||
|
||||
/// True when the host is a mobile platform without a hardware
|
||||
/// keyboard the user would bind a PTT key on. iOS / iPadOS /
|
||||
/// Android fall here. macOS / Linux / Windows / Web fall on the
|
||||
/// hardware-key path.
|
||||
bool get _isTouchOnlyPttHost {
|
||||
if (kIsWeb) return false;
|
||||
return Platform.isIOS || Platform.isAndroid;
|
||||
}
|
||||
|
||||
/// Voice bar — surfaces the live voice state, mode badge, hard-mute
|
||||
/// toggle, level meter, and a leave-channel affordance.
|
||||
class VoiceBar extends StatelessWidget {
|
||||
@@ -113,7 +105,7 @@ class VoiceBar extends StatelessWidget {
|
||||
case rust.BridgeTransmitMode.continuous:
|
||||
return l10n.voiceModeContinuous;
|
||||
case rust.BridgeTransmitMode.voiceActivity:
|
||||
return '${l10n.voiceModeVoiceActivity} (${l10n.voiceModeComingSoon})';
|
||||
return l10n.voiceModeVoiceActivity;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -237,7 +229,7 @@ class VoiceBar extends StatelessWidget {
|
||||
// pinned to the bottom of a narrow-layout screen. The
|
||||
// release-tail value is folded into the small print
|
||||
// under the button rather than shown here.
|
||||
if (isPtt && !_isTouchOnlyPttHost)
|
||||
if (isPtt && !isTouchOnlyPttHost)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 22, top: 2),
|
||||
child: Text(
|
||||
@@ -288,7 +280,7 @@ class VoiceBar extends StatelessWidget {
|
||||
// the bottom of a narrow-layout screen. The release-
|
||||
// tail value sits above the button so the user sees
|
||||
// how long their voice continues after they let go.
|
||||
if (isPtt && _isTouchOnlyPttHost) ...[
|
||||
if (isPtt && isTouchOnlyPttHost) ...[
|
||||
const SizedBox(height: 4),
|
||||
Center(
|
||||
child: Text(
|
||||
@@ -380,10 +372,24 @@ class _PttHoldButton extends StatefulWidget {
|
||||
class _PttHoldButtonState extends State<_PttHoldButton> {
|
||||
bool _pressed = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
unawaited(Haptics.prepare().catchError((_) => false));
|
||||
}
|
||||
|
||||
void _setHeld(bool held) {
|
||||
if (_pressed == held) return;
|
||||
setState(() => _pressed = held);
|
||||
widget.onHeldChanged(held);
|
||||
_playPressHaptic(held);
|
||||
}
|
||||
|
||||
void _playPressHaptic(bool held) {
|
||||
final haptic = held
|
||||
? Haptics.impact(HapticImpactStyle.medium)
|
||||
: Haptics.selection();
|
||||
unawaited(haptic.catchError((_) {}));
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -392,54 +398,62 @@ class _PttHoldButtonState extends State<_PttHoldButton> {
|
||||
final activeNow = _pressed || widget.active;
|
||||
final l10n = AppL10n.of(context);
|
||||
|
||||
return GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTapDown: (_) => _setHeld(true),
|
||||
onTapUp: (_) => _setHeld(false),
|
||||
onTapCancel: () => _setHeld(false),
|
||||
onPanDown: (_) => _setHeld(true),
|
||||
onPanEnd: (_) => _setHeld(false),
|
||||
onPanCancel: () => _setHeld(false),
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 80),
|
||||
height: 64,
|
||||
decoration: BoxDecoration(
|
||||
color: activeNow
|
||||
? theme.colorScheme.primary
|
||||
: theme.colorScheme.primaryContainer,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
boxShadow: activeNow
|
||||
? [
|
||||
BoxShadow(
|
||||
color: theme.colorScheme.primary.withAlpha(100),
|
||||
blurRadius: 12,
|
||||
offset: const Offset(0, 2),
|
||||
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: (_) => _setHeld(true),
|
||||
onPanEnd: (_) => _setHeld(false),
|
||||
onPanCancel: () => _setHeld(false),
|
||||
child: ExcludeSemantics(
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 80),
|
||||
height: 64,
|
||||
decoration: BoxDecoration(
|
||||
color: activeNow
|
||||
? theme.colorScheme.primary
|
||||
: theme.colorScheme.primaryContainer,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
boxShadow: activeNow
|
||||
? [
|
||||
BoxShadow(
|
||||
color: theme.colorScheme.primary.withAlpha(100),
|
||||
blurRadius: 12,
|
||||
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: 24,
|
||||
),
|
||||
]
|
||||
: 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: 24,
|
||||
const SizedBox(width: 10),
|
||||
Text(
|
||||
activeNow ? l10n.voiceMicOn : l10n.voiceModePtt,
|
||||
style: theme.textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
color: activeNow
|
||||
? theme.colorScheme.onPrimary
|
||||
: theme.colorScheme.onPrimaryContainer,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Text(
|
||||
activeNow ? l10n.voiceMicOn : l10n.voiceModePtt,
|
||||
style: theme.textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
color: activeNow
|
||||
? theme.colorScheme.onPrimary
|
||||
: theme.colorScheme.onPrimaryContainer,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,10 @@
|
||||
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;
|
||||
}
|
||||
@@ -1,5 +1,13 @@
|
||||
// Voice settings dialog (SDD-097). Surfaces a TransmitMode radio
|
||||
// group, a bind-key button, and a release-tail slider.
|
||||
// Voice settings dialog (SDD-097). Surfaces transmit mode, release
|
||||
// tail, and the full P1 audio processing configuration:
|
||||
// - Noise suppression (NS)
|
||||
// - Echo cancellation (AEC3)
|
||||
// - Automatic gain control (AGC2)
|
||||
// - High-pass filter (HPF)
|
||||
// - VAD backend
|
||||
// - iOS voice processing mode
|
||||
|
||||
// ignore_for_file: deprecated_member_use
|
||||
|
||||
import 'dart:io' show Platform;
|
||||
|
||||
@@ -7,52 +15,41 @@ import 'package:flutter/foundation.dart' show kIsWeb;
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../l10n/generated/app_localizations.dart';
|
||||
import 'voice_platform.dart';
|
||||
import '../src/rust/api.dart' as rust;
|
||||
|
||||
/// True when the host is a touch-only mobile platform without a
|
||||
/// hardware keyboard the user would bind a PTT key on. Mirrors the
|
||||
/// helper in `voice_bar.dart`.
|
||||
bool get _isTouchOnlyPttHost {
|
||||
bool get _isIos {
|
||||
if (kIsWeb) return false;
|
||||
return Platform.isIOS || Platform.isAndroid;
|
||||
return Platform.isIOS;
|
||||
}
|
||||
|
||||
/// Result returned by [`VoiceSettingsDialog`]. `null` indicates a
|
||||
/// cancelled dialog.
|
||||
/// Result returned by [VoiceSettingsDialog].
|
||||
class VoiceSettingsResult {
|
||||
/// Construct a result snapshot.
|
||||
const VoiceSettingsResult({
|
||||
required this.mode,
|
||||
required this.releaseTailMs,
|
||||
required this.bindKeyRequested,
|
||||
required this.audioConfig,
|
||||
});
|
||||
|
||||
/// Selected transmit mode.
|
||||
final rust.BridgeTransmitMode mode;
|
||||
|
||||
/// Chosen release-tail in milliseconds (0..=500, step 25).
|
||||
final int releaseTailMs;
|
||||
|
||||
/// True when the user tapped the "bind key" button. The caller
|
||||
/// is expected to open the focus-scoped capture dialog
|
||||
/// afterwards.
|
||||
final bool bindKeyRequested;
|
||||
final rust.BridgeAudioProcessingConfig audioConfig;
|
||||
}
|
||||
|
||||
/// Voice settings dialog widget.
|
||||
/// Voice + audio processing settings dialog.
|
||||
class VoiceSettingsDialog extends StatefulWidget {
|
||||
/// Construct a dialog seeded with the current settings.
|
||||
const VoiceSettingsDialog({
|
||||
super.key,
|
||||
required this.initialMode,
|
||||
required this.initialReleaseTailMs,
|
||||
required this.initialAudioConfig,
|
||||
});
|
||||
|
||||
/// Currently active transmit mode.
|
||||
final rust.BridgeTransmitMode initialMode;
|
||||
|
||||
/// Currently configured release tail in milliseconds.
|
||||
final int initialReleaseTailMs;
|
||||
final rust.BridgeAudioProcessingConfig initialAudioConfig;
|
||||
|
||||
@override
|
||||
State<VoiceSettingsDialog> createState() => _VoiceSettingsDialogState();
|
||||
@@ -62,115 +59,273 @@ class _VoiceSettingsDialogState extends State<VoiceSettingsDialog> {
|
||||
late rust.BridgeTransmitMode _mode;
|
||||
late double _releaseTail;
|
||||
|
||||
// Audio processing state — mirrors BridgeAudioProcessingConfig fields.
|
||||
late bool _nsEnabled;
|
||||
late bool _aecEnabled;
|
||||
late bool _agcEnabled;
|
||||
late bool _hpfEnabled;
|
||||
late bool _limiterEnabled;
|
||||
late rust.BridgeVadBackend _vadBackend;
|
||||
late rust.BridgeIosVoiceProcessingMode _iosMode;
|
||||
late bool _debugWavDump;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_mode = widget.initialMode;
|
||||
_releaseTail = widget.initialReleaseTailMs.clamp(0, 500).toDouble();
|
||||
|
||||
final c = widget.initialAudioConfig;
|
||||
_nsEnabled = c.ns != rust.BridgeEffectOwner.off;
|
||||
_aecEnabled = c.aec != rust.BridgeEffectOwner.off;
|
||||
_agcEnabled = c.agc != rust.BridgeEffectOwner.off;
|
||||
_hpfEnabled = c.hpfEnabled;
|
||||
_limiterEnabled = c.limiterEnabled;
|
||||
_vadBackend = c.vadBackend == rust.BridgeVadBackend.disabled
|
||||
? rust.BridgeVadBackend.webrtcVad
|
||||
: c.vadBackend;
|
||||
_iosMode = c.iosMode;
|
||||
_debugWavDump = c.debugWavDumpEnabled;
|
||||
}
|
||||
|
||||
rust.BridgeAudioProcessingConfig _buildConfig() {
|
||||
final c = widget.initialAudioConfig;
|
||||
final isSonora =
|
||||
_iosMode == rust.BridgeIosVoiceProcessingMode.sonoraExperimental;
|
||||
// In VPIO mode, enabled effects are platform-owned. Sonora ownership is
|
||||
// reserved for the experimental raw path so config validation stays honest.
|
||||
final aecOwner = isSonora
|
||||
? (_aecEnabled
|
||||
? rust.BridgeEffectOwner.sonora
|
||||
: rust.BridgeEffectOwner.off)
|
||||
: rust.BridgeEffectOwner.platform; // VPIO always owns AEC
|
||||
final nsOwner = isSonora
|
||||
? (_nsEnabled
|
||||
? rust.BridgeEffectOwner.sonora
|
||||
: rust.BridgeEffectOwner.off)
|
||||
: (_nsEnabled
|
||||
? rust.BridgeEffectOwner.platform
|
||||
: rust.BridgeEffectOwner.off);
|
||||
final agcOwner = isSonora
|
||||
? (_agcEnabled
|
||||
? rust.BridgeEffectOwner.sonora
|
||||
: rust.BridgeEffectOwner.off)
|
||||
: (_agcEnabled
|
||||
? rust.BridgeEffectOwner.platform
|
||||
: rust.BridgeEffectOwner.off);
|
||||
final vadBackend = _vadBackend == rust.BridgeVadBackend.disabled
|
||||
? rust.BridgeVadBackend.webrtcVad
|
||||
: _vadBackend;
|
||||
return rust.BridgeAudioProcessingConfig(
|
||||
route: c.route,
|
||||
iosMode: _iosMode,
|
||||
processingBackend: isSonora
|
||||
? rust.BridgeAudioBackend.sonora
|
||||
: rust.BridgeAudioBackend.platformVoiceProcessing,
|
||||
vadBackend: vadBackend,
|
||||
aec: aecOwner,
|
||||
ns: nsOwner,
|
||||
agc: agcOwner,
|
||||
hpfEnabled: _hpfEnabled,
|
||||
limiterEnabled: _limiterEnabled,
|
||||
vadHangoverMs: c.vadHangoverMs,
|
||||
vadPreRollMs: c.vadPreRollMs,
|
||||
vadMinTxMs: c.vadMinTxMs,
|
||||
debugWavDumpEnabled: _debugWavDump,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppL10n.of(context);
|
||||
final theme = Theme.of(context);
|
||||
final platformVpio =
|
||||
_iosMode == rust.BridgeIosVoiceProcessingMode.platformVoiceProcessing;
|
||||
return AlertDialog(
|
||||
title: Text(l10n.voiceSettingsTitle),
|
||||
contentPadding: const EdgeInsets.fromLTRB(24, 16, 24, 0),
|
||||
content: SizedBox(
|
||||
width: 360,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
l10n.voiceModeLabel,
|
||||
style: theme.textTheme.titleSmall,
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
RadioListTile<rust.BridgeTransmitMode>(
|
||||
dense: true,
|
||||
value: rust.BridgeTransmitMode.ptt,
|
||||
groupValue: _mode,
|
||||
title: Text(l10n.voiceModePtt),
|
||||
onChanged: (v) => setState(() => _mode = v!),
|
||||
),
|
||||
RadioListTile<rust.BridgeTransmitMode>(
|
||||
dense: true,
|
||||
value: rust.BridgeTransmitMode.continuous,
|
||||
groupValue: _mode,
|
||||
title: Text(l10n.voiceModeContinuous),
|
||||
onChanged: (v) => setState(() => _mode = v!),
|
||||
),
|
||||
RadioListTile<rust.BridgeTransmitMode>(
|
||||
dense: true,
|
||||
value: rust.BridgeTransmitMode.voiceActivity,
|
||||
groupValue: _mode,
|
||||
title: Text(l10n.voiceModeVoiceActivity),
|
||||
secondary: Text(
|
||||
l10n.voiceModeComingSoon,
|
||||
style: theme.textTheme.bodySmall,
|
||||
width: 400,
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// ── Transmit mode ──────────────────────────────────────
|
||||
_sectionHeader(theme, l10n.voiceModeLabel),
|
||||
_radioTile<rust.BridgeTransmitMode>(
|
||||
value: rust.BridgeTransmitMode.ptt,
|
||||
groupValue: _mode,
|
||||
title: Text(l10n.voiceModePtt),
|
||||
onSelected: (v) => _mode = v,
|
||||
),
|
||||
// VoiceActivity is reserved per DEC-030 — keep the
|
||||
// tile visible but disabled per SDD-095.
|
||||
onChanged: null,
|
||||
),
|
||||
const Divider(),
|
||||
// Bind-key + release-tail are PTT-only concepts. Hide
|
||||
// them entirely when the user has switched to a
|
||||
// non-PTT mode so the dialog stays focused on what's
|
||||
// actually configurable for that mode.
|
||||
//
|
||||
// Additionally on touch-only mobile hosts (iOS / iPadOS
|
||||
// / Android) there is no hardware keyboard to bind a
|
||||
// key on — the VoiceBar renders an on-screen Push to
|
||||
// Talk button instead. Hide the Bind Key affordance
|
||||
// there but keep the release-tail slider since it
|
||||
// still applies to the on-screen button's behaviour.
|
||||
if (_mode == rust.BridgeTransmitMode.ptt) ...[
|
||||
if (!_isTouchOnlyPttHost) ...[
|
||||
OutlinedButton.icon(
|
||||
icon: const Icon(Icons.keyboard),
|
||||
label: Text(l10n.voiceBindKeyAction),
|
||||
onPressed: () {
|
||||
Navigator.of(context).pop(
|
||||
_radioTile<rust.BridgeTransmitMode>(
|
||||
value: rust.BridgeTransmitMode.continuous,
|
||||
groupValue: _mode,
|
||||
title: Text(l10n.voiceModeContinuous),
|
||||
onSelected: (v) => _mode = v,
|
||||
),
|
||||
_radioTile<rust.BridgeTransmitMode>(
|
||||
value: rust.BridgeTransmitMode.voiceActivity,
|
||||
groupValue: _mode,
|
||||
title: Text(l10n.voiceModeVoiceActivity),
|
||||
onSelected: (v) => _mode = v,
|
||||
),
|
||||
|
||||
// ── PTT options ────────────────────────────────────────
|
||||
if (_mode == rust.BridgeTransmitMode.ptt) ...[
|
||||
const Divider(height: 24),
|
||||
if (!isTouchOnlyPttHost) ...[
|
||||
OutlinedButton.icon(
|
||||
icon: const Icon(Icons.keyboard),
|
||||
label: Text(l10n.voiceBindKeyAction),
|
||||
onPressed: () => Navigator.of(context).pop(
|
||||
VoiceSettingsResult(
|
||||
mode: _mode,
|
||||
releaseTailMs: _releaseTail.round(),
|
||||
bindKeyRequested: true,
|
||||
audioConfig: _buildConfig(),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
],
|
||||
Text(
|
||||
l10n.voiceReleaseTailLabel,
|
||||
style: theme.textTheme.titleSmall,
|
||||
),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Slider(
|
||||
value: _releaseTail,
|
||||
min: 0,
|
||||
max: 500,
|
||||
divisions: 20, // step 25 ms
|
||||
label:
|
||||
'${_releaseTail.round()}${l10n.voiceReleaseTailHint}',
|
||||
onChanged: (v) => setState(() => _releaseTail = v),
|
||||
),
|
||||
),
|
||||
SizedBox(
|
||||
width: 64,
|
||||
child: Text(
|
||||
'${_releaseTail.round()}${l10n.voiceReleaseTailHint}',
|
||||
style: theme.textTheme.bodySmall,
|
||||
textAlign: TextAlign.end,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
],
|
||||
Text(
|
||||
l10n.voiceReleaseTailLabel,
|
||||
style: theme.textTheme.titleSmall,
|
||||
),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Slider(
|
||||
value: _releaseTail,
|
||||
min: 0,
|
||||
max: 500,
|
||||
divisions: 20,
|
||||
label:
|
||||
'${_releaseTail.round()}${l10n.voiceReleaseTailHint}',
|
||||
onChanged: (v) => setState(() => _releaseTail = v),
|
||||
),
|
||||
),
|
||||
SizedBox(
|
||||
width: 64,
|
||||
child: Text(
|
||||
'${_releaseTail.round()}${l10n.voiceReleaseTailHint}',
|
||||
style: theme.textTheme.bodySmall,
|
||||
textAlign: TextAlign.end,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
|
||||
// ── Audio processing ───────────────────────────────────
|
||||
const Divider(height: 24),
|
||||
_sectionHeader(theme, 'Audio processing'),
|
||||
|
||||
// iOS mode selector (iOS only)
|
||||
if (_isIos) ...[
|
||||
_subHeader(theme, 'Processing backend'),
|
||||
_radioTile<rust.BridgeIosVoiceProcessingMode>(
|
||||
value:
|
||||
rust.BridgeIosVoiceProcessingMode.platformVoiceProcessing,
|
||||
groupValue: _iosMode,
|
||||
title: const Text('Platform (VPIO)'),
|
||||
subtitle: _tileSubtitle('Apple AEC · NS · AGC'),
|
||||
onSelected: (v) => _iosMode = v,
|
||||
),
|
||||
_radioTile<rust.BridgeIosVoiceProcessingMode>(
|
||||
value: rust.BridgeIosVoiceProcessingMode.sonoraExperimental,
|
||||
groupValue: _iosMode,
|
||||
title: const Text('Sonora (experimental)'),
|
||||
subtitle: _tileSubtitle('Rust AEC3 · NS · AGC2'),
|
||||
onSelected: (v) => _iosMode = v,
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
],
|
||||
|
||||
// DSP toggles
|
||||
_subHeader(theme, 'DSP stages'),
|
||||
_switchTile(
|
||||
title: 'Noise suppression (NS)',
|
||||
subtitle: 'Wiener filter · stationary noise',
|
||||
value: _nsEnabled,
|
||||
onSelected: (v) => _nsEnabled = v,
|
||||
),
|
||||
_switchTile(
|
||||
title: 'Echo cancellation (AEC3)',
|
||||
subtitle: platformVpio
|
||||
? 'Managed by platform VPIO'
|
||||
: 'Adaptive NLMS · 80 ms tail',
|
||||
value: _aecEnabled,
|
||||
// AEC is always on in VPIO mode — disable the toggle.
|
||||
onSelected: platformVpio ? null : (v) => _aecEnabled = v,
|
||||
),
|
||||
_switchTile(
|
||||
title: 'Auto gain control (AGC2)',
|
||||
subtitle: 'RNN VAD-gated · −18 dBFS target',
|
||||
value: _agcEnabled,
|
||||
onSelected: (v) => _agcEnabled = v,
|
||||
),
|
||||
_switchTile(
|
||||
title: 'High-pass filter (HPF)',
|
||||
subtitle: '80 Hz Butterworth · DC removal',
|
||||
value: _hpfEnabled,
|
||||
onSelected: (v) => _hpfEnabled = v,
|
||||
),
|
||||
_switchTile(
|
||||
title: 'Peak limiter',
|
||||
subtitle: '−1 dBFS soft-knee · 2 ms look-ahead',
|
||||
value: _limiterEnabled,
|
||||
onSelected: (v) => _limiterEnabled = v,
|
||||
),
|
||||
|
||||
// ── VAD ────────────────────────────────────────────────
|
||||
const Divider(height: 24),
|
||||
_sectionHeader(theme, 'Voice activity detection (VAD)'),
|
||||
|
||||
_subHeader(theme, 'Backend'),
|
||||
_radioTile<rust.BridgeVadBackend>(
|
||||
value: rust.BridgeVadBackend.webrtcVad,
|
||||
groupValue: _vadBackend,
|
||||
title: const Text('WebRTC VAD'),
|
||||
subtitle: _tileSubtitle(
|
||||
'Fast · energy-based · always available',
|
||||
),
|
||||
onSelected: (v) => _vadBackend = v,
|
||||
),
|
||||
_radioTile<rust.BridgeVadBackend>(
|
||||
value: rust.BridgeVadBackend.sileroOnnx,
|
||||
groupValue: _vadBackend,
|
||||
title: const Text('Silero v6 (ONNX)'),
|
||||
subtitle: _tileSubtitle(
|
||||
'Neural · 32 ms frames · requires model file',
|
||||
),
|
||||
onSelected: (v) => _vadBackend = v,
|
||||
),
|
||||
_radioTile<rust.BridgeVadBackend>(
|
||||
value: rust.BridgeVadBackend.tenVad,
|
||||
groupValue: _vadBackend,
|
||||
title: const Text('TEN VAD'),
|
||||
subtitle: _tileSubtitle(
|
||||
'Neural · 16 kHz · native runtime optional',
|
||||
),
|
||||
onSelected: (v) => _vadBackend = v,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
|
||||
// ── Debug ──────────────────────────────────────────────
|
||||
const Divider(height: 24),
|
||||
_sectionHeader(theme, 'Debug'),
|
||||
_switchTile(
|
||||
title: 'WAV dump',
|
||||
subtitle: 'Record raw/processed mic to temp dir',
|
||||
value: _debugWavDump,
|
||||
onSelected: (v) => _debugWavDump = v,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
@@ -184,6 +339,7 @@ class _VoiceSettingsDialogState extends State<VoiceSettingsDialog> {
|
||||
mode: _mode,
|
||||
releaseTailMs: _releaseTail.round(),
|
||||
bindKeyRequested: false,
|
||||
audioConfig: _buildConfig(),
|
||||
),
|
||||
),
|
||||
child: Text(l10n.pttConfigureSaveAction),
|
||||
@@ -191,4 +347,54 @@ class _VoiceSettingsDialogState extends State<VoiceSettingsDialog> {
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _radioTile<T>({
|
||||
required T value,
|
||||
required T groupValue,
|
||||
required Widget title,
|
||||
Widget? subtitle,
|
||||
required ValueChanged<T> onSelected,
|
||||
}) => RadioListTile<T>(
|
||||
dense: true,
|
||||
value: value,
|
||||
groupValue: groupValue,
|
||||
title: title,
|
||||
subtitle: subtitle,
|
||||
onChanged: (v) {
|
||||
if (v == null) return;
|
||||
setState(() => onSelected(v));
|
||||
},
|
||||
);
|
||||
|
||||
Widget _switchTile({
|
||||
required String title,
|
||||
required String subtitle,
|
||||
required bool value,
|
||||
required ValueChanged<bool>? onSelected,
|
||||
}) => SwitchListTile(
|
||||
dense: true,
|
||||
title: Text(title),
|
||||
subtitle: _tileSubtitle(subtitle),
|
||||
value: value,
|
||||
onChanged: onSelected == null ? null : (v) => setState(() => onSelected(v)),
|
||||
);
|
||||
|
||||
Widget _tileSubtitle(String text) =>
|
||||
Text(text, style: const TextStyle(fontSize: 11));
|
||||
|
||||
Widget _sectionHeader(ThemeData theme, String text) => Padding(
|
||||
padding: const EdgeInsets.only(bottom: 4),
|
||||
child: Text(text, style: theme.textTheme.titleSmall),
|
||||
);
|
||||
|
||||
Widget _subHeader(ThemeData theme, String text) => Padding(
|
||||
padding: const EdgeInsets.only(top: 8, bottom: 2),
|
||||
child: Text(
|
||||
text,
|
||||
style: theme.textTheme.labelSmall?.copyWith(
|
||||
color: theme.colorScheme.primary,
|
||||
letterSpacing: 0.5,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user