feat: on device speech to text

This commit is contained in:
cogwheel0
2025-08-22 13:54:58 +05:30
parent 2cdbbbc1d3
commit 7f30b728ab
7 changed files with 697 additions and 236 deletions

View File

@@ -1,4 +1,7 @@
PODS: PODS:
- CwlCatchException (2.2.1):
- CwlCatchExceptionSupport (~> 2.2.1)
- CwlCatchExceptionSupport (2.2.1)
- DKImagePickerController/Core (4.3.9): - DKImagePickerController/Core (4.3.9):
- DKImagePickerController/ImageDataManager - DKImagePickerController/ImageDataManager
- DKImagePickerController/Resource - DKImagePickerController/Resource
@@ -55,6 +58,10 @@ PODS:
- shared_preferences_foundation (0.0.1): - shared_preferences_foundation (0.0.1):
- Flutter - Flutter
- FlutterMacOS - FlutterMacOS
- speech_to_text (7.2.0):
- CwlCatchException
- Flutter
- FlutterMacOS
- sqflite_darwin (0.0.4): - sqflite_darwin (0.0.4):
- Flutter - Flutter
- FlutterMacOS - FlutterMacOS
@@ -75,12 +82,15 @@ DEPENDENCIES:
- record_ios (from `.symlinks/plugins/record_ios/ios`) - record_ios (from `.symlinks/plugins/record_ios/ios`)
- share_plus (from `.symlinks/plugins/share_plus/ios`) - share_plus (from `.symlinks/plugins/share_plus/ios`)
- shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`) - shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`)
- speech_to_text (from `.symlinks/plugins/speech_to_text/darwin`)
- sqflite_darwin (from `.symlinks/plugins/sqflite_darwin/darwin`) - sqflite_darwin (from `.symlinks/plugins/sqflite_darwin/darwin`)
- url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`) - url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`)
- wakelock_plus (from `.symlinks/plugins/wakelock_plus/ios`) - wakelock_plus (from `.symlinks/plugins/wakelock_plus/ios`)
SPEC REPOS: SPEC REPOS:
trunk: trunk:
- CwlCatchException
- CwlCatchExceptionSupport
- DKImagePickerController - DKImagePickerController
- DKPhotoGallery - DKPhotoGallery
- SDWebImage - SDWebImage
@@ -107,6 +117,8 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/share_plus/ios" :path: ".symlinks/plugins/share_plus/ios"
shared_preferences_foundation: shared_preferences_foundation:
:path: ".symlinks/plugins/shared_preferences_foundation/darwin" :path: ".symlinks/plugins/shared_preferences_foundation/darwin"
speech_to_text:
:path: ".symlinks/plugins/speech_to_text/darwin"
sqflite_darwin: sqflite_darwin:
:path: ".symlinks/plugins/sqflite_darwin/darwin" :path: ".symlinks/plugins/sqflite_darwin/darwin"
url_launcher_ios: url_launcher_ios:
@@ -115,6 +127,8 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/wakelock_plus/ios" :path: ".symlinks/plugins/wakelock_plus/ios"
SPEC CHECKSUMS: SPEC CHECKSUMS:
CwlCatchException: 7acc161b299a6de7f0a46a6ed741eae2c8b4d75a
CwlCatchExceptionSupport: 54ccab8d8c78907b57f99717fb19d4cc3bce02dc
DKImagePickerController: 946cec48c7873164274ecc4624d19e3da4c1ef3c DKImagePickerController: 946cec48c7873164274ecc4624d19e3da4c1ef3c
DKPhotoGallery: b3834fecb755ee09a593d7c9e389d8b5d6deed60 DKPhotoGallery: b3834fecb755ee09a593d7c9e389d8b5d6deed60
file_picker: a0560bc09d61de87f12d246fc47d2119e6ef37be file_picker: a0560bc09d61de87f12d246fc47d2119e6ef37be
@@ -128,6 +142,7 @@ SPEC CHECKSUMS:
SDWebImage: f29024626962457f3470184232766516dee8dfea SDWebImage: f29024626962457f3470184232766516dee8dfea
share_plus: 50da8cb520a8f0f65671c6c6a99b3617ed10a58a share_plus: 50da8cb520a8f0f65671c6c6a99b3617ed10a58a
shared_preferences_foundation: 9e1978ff2562383bd5676f64ec4e9aa8fa06a6f7 shared_preferences_foundation: 9e1978ff2562383bd5676f64ec4e9aa8fa06a6f7
speech_to_text: 3b313d98516d3d0406cea424782ec25470c59d19
sqflite_darwin: 20b2a3a3b70e43edae938624ce550a3cbf66a3d0 sqflite_darwin: 20b2a3a3b70e43edae938624ce550a3cbf66a3d0
SwiftyGif: 706c60cf65fa2bc5ee0313beece843c8eb8194d4 SwiftyGif: 706c60cf65fa2bc5ee0313beece843c8eb8194d4
url_launcher_ios: 694010445543906933d732453a59da0a173ae33d url_launcher_ios: 694010445543906933d732453a59da0a173ae33d

View File

@@ -54,6 +54,8 @@
<!-- Permissions --> <!-- Permissions -->
<key>NSMicrophoneUsageDescription</key> <key>NSMicrophoneUsageDescription</key>
<string>Conduit uses the microphone to record voice messages and enable voice-to-text in chats. For example, when you hold the mic button in a conversation, we capture your speech to send as an audio message or transcript.</string> <string>Conduit uses the microphone to record voice messages and enable voice-to-text in chats. For example, when you hold the mic button in a conversation, we capture your speech to send as an audio message or transcript.</string>
<key>NSSpeechRecognitionUsageDescription</key>
<string>Conduit uses on-device speech recognition so you can dictate messages handsfree. Your speech is converted to text on your device when available.</string>
<key>NSCameraUsageDescription</key> <key>NSCameraUsageDescription</key>
<string>Conduit uses the camera to take photos or videos you choose to share in chats. For example, you can snap a photo of a document and attach it to a message.</string> <string>Conduit uses the camera to take photos or videos you choose to share in chats. For example, you can snap a photo of a document and attach it to a message.</string>
<key>NSPhotoLibraryUsageDescription</key> <key>NSPhotoLibraryUsageDescription</key>

View File

@@ -12,6 +12,10 @@ class SettingsService {
static const String _largeTextKey = 'large_text'; static const String _largeTextKey = 'large_text';
static const String _darkModeKey = 'dark_mode'; static const String _darkModeKey = 'dark_mode';
static const String _defaultModelKey = 'default_model'; static const String _defaultModelKey = 'default_model';
// Voice input settings
static const String _voiceLocaleKey = 'voice_locale_id';
static const String _voiceHoldToTalkKey = 'voice_hold_to_talk';
static const String _voiceAutoSendKey = 'voice_auto_send_final';
/// Get reduced motion preference /// Get reduced motion preference
static Future<bool> getReduceMotion() async { static Future<bool> getReduceMotion() async {
@@ -111,6 +115,9 @@ class SettingsService {
largeText: await getLargeText(), largeText: await getLargeText(),
darkMode: await getDarkMode(), darkMode: await getDarkMode(),
defaultModel: await getDefaultModel(), defaultModel: await getDefaultModel(),
voiceLocaleId: await getVoiceLocaleId(),
voiceHoldToTalk: await getVoiceHoldToTalk(),
voiceAutoSendFinal: await getVoiceAutoSendFinal(),
); );
} }
@@ -124,9 +131,47 @@ class SettingsService {
setLargeText(settings.largeText), setLargeText(settings.largeText),
setDarkMode(settings.darkMode), setDarkMode(settings.darkMode),
setDefaultModel(settings.defaultModel), setDefaultModel(settings.defaultModel),
setVoiceLocaleId(settings.voiceLocaleId),
setVoiceHoldToTalk(settings.voiceHoldToTalk),
setVoiceAutoSendFinal(settings.voiceAutoSendFinal),
]); ]);
} }
// Voice input specific settings
static Future<String?> getVoiceLocaleId() async {
final prefs = await SharedPreferences.getInstance();
return prefs.getString(_voiceLocaleKey);
}
static Future<void> setVoiceLocaleId(String? localeId) async {
final prefs = await SharedPreferences.getInstance();
if (localeId == null || localeId.isEmpty) {
await prefs.remove(_voiceLocaleKey);
} else {
await prefs.setString(_voiceLocaleKey, localeId);
}
}
static Future<bool> getVoiceHoldToTalk() async {
final prefs = await SharedPreferences.getInstance();
return prefs.getBool(_voiceHoldToTalkKey) ?? false;
}
static Future<void> setVoiceHoldToTalk(bool value) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setBool(_voiceHoldToTalkKey, value);
}
static Future<bool> getVoiceAutoSendFinal() async {
final prefs = await SharedPreferences.getInstance();
return prefs.getBool(_voiceAutoSendKey) ?? false;
}
static Future<void> setVoiceAutoSendFinal(bool value) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setBool(_voiceAutoSendKey, value);
}
/// Get effective animation duration considering all settings /// Get effective animation duration considering all settings
static Duration getEffectiveAnimationDuration( static Duration getEffectiveAnimationDuration(
BuildContext context, BuildContext context,
@@ -176,6 +221,9 @@ class AppSettings {
final bool largeText; final bool largeText;
final bool darkMode; final bool darkMode;
final String? defaultModel; final String? defaultModel;
final String? voiceLocaleId;
final bool voiceHoldToTalk;
final bool voiceAutoSendFinal;
const AppSettings({ const AppSettings({
this.reduceMotion = false, this.reduceMotion = false,
@@ -185,6 +233,9 @@ class AppSettings {
this.largeText = false, this.largeText = false,
this.darkMode = true, this.darkMode = true,
this.defaultModel, this.defaultModel,
this.voiceLocaleId,
this.voiceHoldToTalk = false,
this.voiceAutoSendFinal = false,
}); });
AppSettings copyWith({ AppSettings copyWith({
@@ -195,6 +246,9 @@ class AppSettings {
bool? largeText, bool? largeText,
bool? darkMode, bool? darkMode,
Object? defaultModel = const _DefaultValue(), Object? defaultModel = const _DefaultValue(),
Object? voiceLocaleId = const _DefaultValue(),
bool? voiceHoldToTalk,
bool? voiceAutoSendFinal,
}) { }) {
return AppSettings( return AppSettings(
reduceMotion: reduceMotion ?? this.reduceMotion, reduceMotion: reduceMotion ?? this.reduceMotion,
@@ -204,6 +258,9 @@ class AppSettings {
largeText: largeText ?? this.largeText, largeText: largeText ?? this.largeText,
darkMode: darkMode ?? this.darkMode, darkMode: darkMode ?? this.darkMode,
defaultModel: defaultModel is _DefaultValue ? this.defaultModel : defaultModel as String?, defaultModel: defaultModel is _DefaultValue ? this.defaultModel : defaultModel as String?,
voiceLocaleId: voiceLocaleId is _DefaultValue ? this.voiceLocaleId : voiceLocaleId as String?,
voiceHoldToTalk: voiceHoldToTalk ?? this.voiceHoldToTalk,
voiceAutoSendFinal: voiceAutoSendFinal ?? this.voiceAutoSendFinal,
); );
} }
@@ -217,7 +274,10 @@ class AppSettings {
other.highContrast == highContrast && other.highContrast == highContrast &&
other.largeText == largeText && other.largeText == largeText &&
other.darkMode == darkMode && other.darkMode == darkMode &&
other.defaultModel == defaultModel; other.defaultModel == defaultModel &&
other.voiceLocaleId == voiceLocaleId &&
other.voiceHoldToTalk == voiceHoldToTalk &&
other.voiceAutoSendFinal == voiceAutoSendFinal;
} }
@override @override
@@ -230,6 +290,9 @@ class AppSettings {
largeText, largeText,
darkMode, darkMode,
defaultModel, defaultModel,
voiceLocaleId,
voiceHoldToTalk,
voiceAutoSendFinal,
); );
} }
} }
@@ -285,6 +348,21 @@ class AppSettingsNotifier extends StateNotifier<AppSettings> {
await SettingsService.setDefaultModel(modelId); await SettingsService.setDefaultModel(modelId);
} }
Future<void> setVoiceLocaleId(String? localeId) async {
state = state.copyWith(voiceLocaleId: localeId);
await SettingsService.setVoiceLocaleId(localeId);
}
Future<void> setVoiceHoldToTalk(bool value) async {
state = state.copyWith(voiceHoldToTalk: value);
await SettingsService.setVoiceHoldToTalk(value);
}
Future<void> setVoiceAutoSendFinal(bool value) async {
state = state.copyWith(voiceAutoSendFinal: value);
await SettingsService.setVoiceAutoSendFinal(value);
}
Future<void> resetToDefaults() async { Future<void> resetToDefaults() async {
const defaultSettings = AppSettings(); const defaultSettings = AppSettings();
await SettingsService.saveSettings(defaultSettings); await SettingsService.saveSettings(defaultSettings);

View File

@@ -1,14 +1,22 @@
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:record/record.dart'; import 'package:record/record.dart';
import 'package:flutter/widgets.dart';
import 'dart:async'; import 'dart:async';
import 'dart:io' show Platform; import 'dart:io' show Platform;
import 'package:path_provider/path_provider.dart'; import 'package:path_provider/path_provider.dart';
import 'package:path/path.dart' as p; import 'package:path/path.dart' as p;
import 'package:speech_to_text/speech_recognition_error.dart';
import 'package:speech_to_text/speech_recognition_result.dart';
import 'package:speech_to_text/speech_to_text.dart' as stt;
class VoiceInputService { class VoiceInputService {
final AudioRecorder _recorder = AudioRecorder(); final AudioRecorder _recorder = AudioRecorder();
stt.SpeechToText? _speech;
bool _isInitialized = false; bool _isInitialized = false;
bool _isListening = false; bool _isListening = false;
bool _localSttAvailable = false;
String? _selectedLocaleId;
List<stt.LocaleName> _locales = const [];
StreamController<String>? _textStreamController; StreamController<String>? _textStreamController;
String _currentText = ''; String _currentText = '';
// Public stream for UI waveform visualization (emits partial text length as proxy) // Public stream for UI waveform visualization (emits partial text length as proxy)
@@ -23,16 +31,46 @@ class VoiceInputService {
Future<bool> initialize() async { Future<bool> initialize() async {
if (_isInitialized) return true; if (_isInitialized) return true;
if (!isSupportedPlatform) return false; if (!isSupportedPlatform) return false;
// Log platform for diagnostics // Prepare local speech recognizer
// ignore: avoid_print try {
print( _speech = stt.SpeechToText();
'DEBUG: VoiceInputService initialize on platform: ' _localSttAvailable = await _speech!.initialize(
'${Platform.isAndroid onStatus: (status) {
? 'Android' // When platform end-of-speech triggers, ensure we stop timer/streams
: Platform.isIOS if (status.toLowerCase().contains('notListening') ||
? 'iOS' status.toLowerCase().contains('done')) {
: 'Other'}', // No-op: UI manages stopping; SpeechToText emits final result
); }
},
onError: (SpeechRecognitionError error) {
// If any error, we keep fallback available; no throws here.
},
);
if (_localSttAvailable) {
try {
_locales = await _speech!.locales();
final deviceTag = WidgetsBinding.instance.platformDispatcher.locale
.toLanguageTag();
final match = _locales.firstWhere(
(l) => l.localeId.toLowerCase() == deviceTag.toLowerCase(),
orElse: () {
final primary = deviceTag.split(RegExp('[-_]')).first.toLowerCase();
return _locales.firstWhere(
(l) => l.localeId.toLowerCase().startsWith('$primary-'),
orElse: () => _locales.isNotEmpty
? _locales.first
: stt.LocaleName('en_US', 'English (US)'),
);
},
);
_selectedLocaleId = match.localeId;
} catch (_) {
_selectedLocaleId = null;
}
}
} catch (_) {
_localSttAvailable = false;
}
_isInitialized = true; _isInitialized = true;
return true; return true;
} }
@@ -46,10 +84,16 @@ class VoiceInputService {
} }
bool get isListening => _isListening; bool get isListening => _isListening;
bool get isAvailable => _isInitialized; bool get isAvailable => _isInitialized; // service usable (local or fallback)
bool get hasLocalStt => _localSttAvailable;
String? get selectedLocaleId => _selectedLocaleId;
List<stt.LocaleName> get locales => _locales;
void setLocale(String? localeId) {
_selectedLocaleId = localeId;
}
Stream<String> startListening() { Stream<String> startListening() {
// Ensure initialized; we allow initialize to pass even if native STT unavailable
if (!_isInitialized) { if (!_isInitialized) {
throw Exception('Voice input not initialized'); throw Exception('Voice input not initialized');
} }
@@ -61,21 +105,52 @@ class VoiceInputService {
_textStreamController = StreamController<String>.broadcast(); _textStreamController = StreamController<String>.broadcast();
_currentText = ''; _currentText = '';
_isListening = true; _isListening = true;
_intensityController = StreamController<int>.broadcast(); _intensityController = StreamController<int>.broadcast();
// Start recording raw audio; UI or auto-timer will stop and trigger transcription via API if (_localSttAvailable && _speech != null) {
// ignore: avoid_print // Local on-device STT path
print('DEBUG: VoiceInputService startListening'); _autoStopTimer?.cancel();
_startRecordingProxyIntensity(); // SpeechToText has its own end-of-speech handling; we still cap at 60s
_autoStopTimer = Timer(const Duration(seconds: 60), () {
if (_isListening) {
_stopListening();
}
});
// Auto-stop after 30 seconds similar to native STT behavior _speech!.listen(
_autoStopTimer?.cancel(); localeId: _selectedLocaleId,
_autoStopTimer = Timer(const Duration(seconds: 30), () { listenFor: const Duration(seconds: 60),
if (_isListening) { pauseFor: const Duration(seconds: 5),
_stopListening(); onResult: (SpeechRecognitionResult result) {
} if (!_isListening) return;
}); _currentText = result.recognizedWords;
_textStreamController?.add(_currentText);
if (result.finalResult) {
// Will be followed by notListening status; we proactively close
_stopListening();
}
},
onSoundLevelChange: (level) {
// level is roughly 0..1+; map to 0..10
final scaled = (level * 10).clamp(0, 10).round();
_intensityController?.add(scaled);
},
listenOptions: stt.SpeechListenOptions(
partialResults: true,
cancelOnError: true,
listenMode: stt.ListenMode.confirmation,
),
);
} else {
// Fallback: record audio and signal file path for server transcription
_startRecordingProxyIntensity();
_autoStopTimer?.cancel();
_autoStopTimer = Timer(const Duration(seconds: 30), () {
if (_isListening) {
_stopListening();
}
});
}
return _textStreamController!.stream; return _textStreamController!.stream;
} }
@@ -88,10 +163,14 @@ class VoiceInputService {
if (!_isListening) return; if (!_isListening) return;
_isListening = false; _isListening = false;
// Also stop recorder if active if (_localSttAvailable && _speech != null) {
await _stopRecording(); try {
// ignore: avoid_print await _speech!.stop();
print('DEBUG: VoiceInputService stopped listening'); } catch (_) {}
} else {
// Also stop recorder if active
await _stopRecording();
}
_autoStopTimer?.cancel(); _autoStopTimer?.cancel();
_autoStopTimer = null; _autoStopTimer = null;
@@ -111,6 +190,9 @@ class VoiceInputService {
void dispose() { void dispose() {
stopListening(); stopListening();
_stopRecording(force: true); _stopRecording(force: true);
try {
_speech?.cancel();
} catch (_) {}
} }
// --- Recording and intensity proxy for server transcription path --- // --- Recording and intensity proxy for server transcription path ---
@@ -138,8 +220,7 @@ class VoiceInputService {
), ),
path: filePath, path: filePath,
); );
// ignore: avoid_print // recording started at filePath
print('DEBUG: VoiceInputService recording started at: $filePath');
// Drive intensity from amplitude stream and detect silence // Drive intensity from amplitude stream and detect silence
// Consider amplitude less than threshold as silence; stop after ~3s of continuous silence // Consider amplitude less than threshold as silence; stop after ~3s of continuous silence
@@ -167,8 +248,6 @@ class VoiceInputService {
} }
}); });
} catch (e) { } catch (e) {
// ignore: avoid_print
print('DEBUG: VoiceInputService recording failed: $e');
_textStreamController?.addError('Audio recording failed: $e'); _textStreamController?.addError('Audio recording failed: $e');
_stopListening(); _stopListening();
} }
@@ -182,8 +261,6 @@ class VoiceInputService {
_textStreamController?.addError('Recording failed: no file path'); _textStreamController?.addError('Recording failed: no file path');
return; return;
} }
// ignore: avoid_print
print('DEBUG: VoiceInputService recording saved: $path');
// Hand off recorded file path to listeners as a special token; UI layer will upload for transcription // Hand off recorded file path to listeners as a special token; UI layer will upload for transcription
_textStreamController?.add('[[AUDIO_FILE_PATH]]:$path'); _textStreamController?.add('[[AUDIO_FILE_PATH]]:$path');
} catch (e) { } catch (e) {
@@ -203,6 +280,8 @@ final voiceInputAvailableProvider = FutureProvider<bool>((ref) async {
if (!service.isSupportedPlatform) return false; if (!service.isSupportedPlatform) return false;
final initialized = await service.initialize(); final initialized = await service.initialize();
if (!initialized) return false; if (!initialized) return false;
// If local STT exists, we consider it available; otherwise ensure mic permission for fallback
if (service.hasLocalStt) return true;
final hasPermission = await service.checkPermissions(); final hasPermission = await service.checkPermissions();
if (!hasPermission) return false; if (!hasPermission) return false;
return service.isAvailable; return service.isAvailable;

View File

@@ -29,6 +29,10 @@ import 'chat_page_helpers.dart';
import '../../../shared/widgets/themed_dialogs.dart'; import '../../../shared/widgets/themed_dialogs.dart';
import '../../onboarding/views/onboarding_sheet.dart'; import '../../onboarding/views/onboarding_sheet.dart';
import '../../../shared/widgets/sheet_handle.dart'; import '../../../shared/widgets/sheet_handle.dart';
import '../../../shared/widgets/conduit_components.dart';
import '../../../core/services/settings_service.dart';
// Removed unused PlatformUtils import
import '../../../core/services/platform_service.dart' as ps;
class ChatPage extends ConsumerStatefulWidget { class ChatPage extends ConsumerStatefulWidget {
const ChatPage({super.key}); const ChatPage({super.key});
@@ -1791,20 +1795,35 @@ class _VoiceInputSheetState extends ConsumerState<_VoiceInputSheet> {
Timer? _elapsedTimer; Timer? _elapsedTimer;
bool _isTranscribing = false; bool _isTranscribing = false;
String _languageTag = 'en'; String _languageTag = 'en';
bool _holdToTalk = false;
bool _autoSendFinal = false;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_voiceService = ref.read(voiceInputServiceProvider); _voiceService = ref.read(voiceInputServiceProvider);
try { try {
_languageTag = WidgetsBinding.instance.platformDispatcher.locale final preset = _voiceService.selectedLocaleId;
.toLanguageTag() if (preset != null && preset.isNotEmpty) {
.split(RegExp('[-_]')) _languageTag = preset.split(RegExp('[-_]')).first.toLowerCase();
.first } else {
.toLowerCase(); _languageTag = WidgetsBinding.instance.platformDispatcher.locale
.toLanguageTag()
.split(RegExp('[-_]'))
.first
.toLowerCase();
}
} catch (_) { } catch (_) {
_languageTag = 'en'; _languageTag = 'en';
} }
// Load voice settings from app settings
final settings = ref.read(appSettingsProvider);
_holdToTalk = settings.voiceHoldToTalk;
_autoSendFinal = settings.voiceAutoSendFinal;
if (settings.voiceLocaleId != null && settings.voiceLocaleId!.isNotEmpty) {
_voiceService.setLocale(settings.voiceLocaleId);
_languageTag = settings.voiceLocaleId!.split(RegExp('[-_]')).first.toLowerCase();
}
} }
void _startListening() async { void _startListening() async {
@@ -1813,12 +1832,23 @@ class _VoiceInputSheetState extends ConsumerState<_VoiceInputSheet> {
_recognizedText = ''; _recognizedText = '';
_elapsedSeconds = 0; _elapsedSeconds = 0;
}); });
// Haptic: indicate start listening
final hapticEnabled = ref.read(hapticEnabledProvider);
ps.PlatformService.hapticFeedbackWithSettings(
type: ps.HapticType.medium,
hapticEnabled: hapticEnabled,
);
try { try {
// Ensure service is initialized and has permission // Ensure service is initialized (local STT will request permissions itself)
final ok = await _voiceService.initialize(); final ok = await _voiceService.initialize();
if (!ok || !await _voiceService.checkPermissions()) { if (!ok) {
throw Exception('Microphone permission not granted'); throw Exception('Voice service unavailable');
}
// Only check mic permission when falling back to recording
if (!_voiceService.hasLocalStt) {
final mic = await _voiceService.checkPermissions();
if (!mic) throw Exception('Microphone permission not granted');
} }
// Start elapsed timer for UX // Start elapsed timer for UX
@@ -1838,7 +1868,7 @@ class _VoiceInputSheetState extends ConsumerState<_VoiceInputSheet> {
}); });
_textSub = stream.listen( _textSub = stream.listen(
(text) { (text) {
// If we receive a special token with recorded audio path, transcribe it via API // If we receive a special token with recorded audio path, transcribe it via API (fallback)
if (text.startsWith('[[AUDIO_FILE_PATH]]:')) { if (text.startsWith('[[AUDIO_FILE_PATH]]:')) {
final filePath = text.split(':').skip(1).join(':'); final filePath = text.split(':').skip(1).join(':');
debugPrint( debugPrint(
@@ -1857,6 +1887,10 @@ class _VoiceInputSheetState extends ConsumerState<_VoiceInputSheet> {
_isListening = false; _isListening = false;
}); });
_elapsedTimer?.cancel(); _elapsedTimer?.cancel();
// Auto-send on final local result if enabled
if (_autoSendFinal && _recognizedText.trim().isNotEmpty) {
_sendText();
}
}, },
onError: (error) { onError: (error) {
debugPrint('DEBUG: VoiceInputSheet stream error: $error'); debugPrint('DEBUG: VoiceInputSheet stream error: $error');
@@ -1864,7 +1898,13 @@ class _VoiceInputSheetState extends ConsumerState<_VoiceInputSheet> {
_isListening = false; _isListening = false;
}); });
_elapsedTimer?.cancel(); _elapsedTimer?.cancel();
if (mounted) {} if (mounted) {
final hapticEnabled = ref.read(hapticEnabledProvider);
ps.PlatformService.hapticFeedbackWithSettings(
type: ps.HapticType.warning,
hapticEnabled: hapticEnabled,
);
}
}, },
); );
} catch (e) { } catch (e) {
@@ -1903,6 +1943,9 @@ class _VoiceInputSheetState extends ConsumerState<_VoiceInputSheet> {
}); });
// Stop listening state if we have a result // Stop listening state if we have a result
setState(() => _isListening = false); setState(() => _isListening = false);
if (_autoSendFinal && _recognizedText.trim().isNotEmpty) {
_sendText();
}
} catch (e) { } catch (e) {
if (!mounted) return; if (!mounted) return;
setState(() => _isListening = false); setState(() => _isListening = false);
@@ -1922,10 +1965,22 @@ class _VoiceInputSheetState extends ConsumerState<_VoiceInputSheet> {
_isListening = false; _isListening = false;
}); });
} }
// Haptic: subtle stop confirmation
final hapticEnabled = ref.read(hapticEnabledProvider);
ps.PlatformService.hapticFeedbackWithSettings(
type: ps.HapticType.selection,
hapticEnabled: hapticEnabled,
);
} }
void _sendText() { void _sendText() {
if (_recognizedText.isNotEmpty) { if (_recognizedText.isNotEmpty) {
// Haptic: success send
final hapticEnabled = ref.read(hapticEnabledProvider);
ps.PlatformService.hapticFeedbackWithSettings(
type: ps.HapticType.success,
hapticEnabled: hapticEnabled,
);
widget.onTextReceived(_recognizedText); widget.onTextReceived(_recognizedText);
Navigator.pop(context); Navigator.pop(context);
} }
@@ -1937,9 +1992,103 @@ class _VoiceInputSheetState extends ConsumerState<_VoiceInputSheet> {
return '$m:$s'; return '$m:$s';
} }
void _cancel() { void _pickLanguage() async {
_stopListening(); // Only for local STT
Navigator.pop(context); if (!_voiceService.hasLocalStt) return;
final locales = _voiceService.locales;
if (locales.isEmpty) return;
if (!mounted) return;
final selected = await showModalBottomSheet<String>(
context: context,
backgroundColor: Colors.transparent,
builder: (context) {
return Container(
decoration: BoxDecoration(
color: context.conduitTheme.surfaceBackground,
borderRadius: const BorderRadius.vertical(
top: Radius.circular(AppBorderRadius.bottomSheet),
),
border: Border.all(
color: context.conduitTheme.dividerColor,
width: BorderWidth.regular,
),
boxShadow: ConduitShadows.modal,
),
padding: const EdgeInsets.all(Spacing.bottomSheetPadding),
child: SafeArea(
top: false,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const SheetHandle(),
const SizedBox(height: Spacing.md),
Text('Select Language',
style: TextStyle(
fontSize: AppTypography.headlineSmall,
color: context.conduitTheme.textPrimary,
fontWeight: FontWeight.w600,
)),
const SizedBox(height: Spacing.sm),
Flexible(
child: ListView.separated(
shrinkWrap: true,
itemCount: locales.length,
separatorBuilder: (_, sep) => Divider(
height: 1,
color: context.conduitTheme.dividerColor,
),
itemBuilder: (ctx, i) {
final l = locales[i];
final isSelected = l.localeId == _voiceService.selectedLocaleId;
return ListTile(
title: Text(
l.name,
style: TextStyle(color: context.conduitTheme.textPrimary),
),
subtitle: Text(
l.localeId,
style: TextStyle(color: context.conduitTheme.textSecondary),
),
trailing: isSelected
? Icon(Icons.check, color: context.conduitTheme.buttonPrimary)
: null,
onTap: () => Navigator.pop(ctx, l.localeId),
);
},
),
),
],
),
),
);
},
);
if (selected != null && mounted) {
setState(() {
_voiceService.setLocale(selected);
_languageTag = selected.split(RegExp('[-_]')).first.toLowerCase();
});
// Persist preferred locale
await ref.read(appSettingsProvider.notifier).setVoiceLocaleId(selected);
if (_isListening) {
// Restart listening to apply new language
await _voiceService.stopListening();
_startListening();
}
}
}
Widget _buildThemedSwitch({
required bool value,
required ValueChanged<bool> onChanged,
}) {
final theme = context.conduitTheme;
return ps.PlatformService.getPlatformSwitch(
value: value,
onChanged: onChanged,
activeColor: theme.buttonPrimary,
);
} }
@override @override
@@ -1951,40 +2100,40 @@ class _VoiceInputSheetState extends ConsumerState<_VoiceInputSheet> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final media = MediaQuery.of(context);
final isCompact = media.size.height < 680;
return Container( return Container(
height: MediaQuery.of(context).size.height * 0.6, height: media.size.height * (isCompact ? 0.45 : 0.6),
decoration: BoxDecoration( decoration: BoxDecoration(
color: context.conduitTheme.surfaceBackground, color: context.conduitTheme.surfaceBackground,
borderRadius: const BorderRadius.vertical( borderRadius: const BorderRadius.vertical(
top: Radius.circular(AppBorderRadius.lg), top: Radius.circular(AppBorderRadius.bottomSheet),
), ),
border: Border.all(color: context.conduitTheme.dividerColor, width: 1), border: Border.all(color: context.conduitTheme.dividerColor, width: 1),
boxShadow: ConduitShadows.modal,
), ),
child: Column( child: SafeArea(
top: false,
bottom: true,
child: Padding(
padding: const EdgeInsets.all(Spacing.bottomSheetPadding),
child: Column(
children: [ children: [
// Handle bar // Handle bar
Container( const SheetHandle(),
margin: const EdgeInsets.only(top: Spacing.sm),
width: 40,
height: 4,
decoration: BoxDecoration(
color: context.conduitTheme.dividerColor,
borderRadius: BorderRadius.circular(AppBorderRadius.xs),
),
),
// Header: Title + timer + language chip // Header: Title + timer + language chip
Padding( Padding(
padding: const EdgeInsets.all(Spacing.lg), padding: const EdgeInsets.only(top: Spacing.md, bottom: Spacing.md),
child: Row( child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween, mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [ children: [
Text( Text(
_isListening _isTranscribing
? 'Listening\u2026' ? 'Transcribing…'
: _isTranscribing : _isListening
? 'Transcribing\u2026' ? (_voiceService.hasLocalStt ? 'Listening…' : 'Recording…')
: 'Voice', : 'Voice',
style: TextStyle( style: TextStyle(
fontSize: AppTypography.headlineMedium, fontSize: AppTypography.headlineMedium,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
@@ -1994,28 +2143,43 @@ class _VoiceInputSheetState extends ConsumerState<_VoiceInputSheet> {
Row( Row(
children: [ children: [
// Language chip // Language chip
Container( GestureDetector(
padding: const EdgeInsets.symmetric( onTap: _voiceService.hasLocalStt ? _pickLanguage : null,
horizontal: Spacing.xs, child: Container(
vertical: 4, padding: const EdgeInsets.symmetric(
), horizontal: Spacing.xs,
decoration: BoxDecoration( vertical: 4,
color: context.conduitTheme.surfaceBackground
.withValues(alpha: 0.4),
borderRadius: BorderRadius.circular(
AppBorderRadius.badge,
), ),
border: Border.all( decoration: BoxDecoration(
color: context.conduitTheme.dividerColor, color: context.conduitTheme.surfaceBackground
width: BorderWidth.thin, .withValues(alpha: 0.4),
borderRadius: BorderRadius.circular(
AppBorderRadius.badge,
),
border: Border.all(
color: context.conduitTheme.dividerColor,
width: BorderWidth.thin,
),
), ),
), child: Row(
child: Text( children: [
_languageTag.toUpperCase(), Text(
style: TextStyle( _languageTag.toUpperCase(),
fontSize: AppTypography.labelSmall, style: TextStyle(
color: context.conduitTheme.textSecondary, fontSize: AppTypography.labelSmall,
fontWeight: FontWeight.w600, color: context.conduitTheme.textSecondary,
fontWeight: FontWeight.w600,
),
),
if (_voiceService.hasLocalStt) ...[
const SizedBox(width: 4),
Icon(
Icons.arrow_drop_down,
size: 16,
color: context.conduitTheme.iconSecondary,
),
],
],
), ),
), ),
), ),
@@ -2032,25 +2196,108 @@ class _VoiceInputSheetState extends ConsumerState<_VoiceInputSheet> {
), ),
), ),
), ),
const SizedBox(width: Spacing.sm),
// Close sheet
ConduitIconButton(
icon: Platform.isIOS ? CupertinoIcons.xmark : Icons.close,
tooltip: 'Close',
isCompact: true,
onPressed: () => Navigator.of(context).pop(),
),
], ],
), ),
], ],
), ),
), ),
// Microphone animation and waveform // Toggles row: Hold to talk, Auto-send
Padding(
padding: const EdgeInsets.only(bottom: Spacing.sm),
child: Row(
children: [
Expanded(
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
_buildThemedSwitch(
value: _holdToTalk,
onChanged: (v) async {
setState(() => _holdToTalk = v);
await ref.read(appSettingsProvider.notifier).setVoiceHoldToTalk(v);
},
),
const SizedBox(width: Spacing.xs),
Text(
'Hold to talk',
style: TextStyle(color: context.conduitTheme.textSecondary),
),
],
),
),
Expanded(
child: Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
_buildThemedSwitch(
value: _autoSendFinal,
onChanged: (v) async {
setState(() => _autoSendFinal = v);
await ref.read(appSettingsProvider.notifier).setVoiceAutoSendFinal(v);
},
),
const SizedBox(width: Spacing.xs),
Text(
'Auto-send',
style: TextStyle(color: context.conduitTheme.textSecondary),
),
],
),
),
],
),
),
// Microphone + waveform
Expanded( Expanded(
child: Center( child: LayoutBuilder(
child: Column( builder: (context, viewport) {
mainAxisAlignment: MainAxisAlignment.center, final isUltra = media.size.height < 560;
children: [ final double micSize = isUltra ? 64 : (isCompact ? 80 : 100);
// Microphone icon with animation (tap to toggle) final double micIconSize = isUltra ? 26 : (isCompact ? 32 : 40);
// Extra top padding so scale animation (up to 1.2x) never clips
final double topPaddingForScale = ((micSize * 1.2) - micSize) / 2 + 8;
final content = Center(
child: Column(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.center,
children: [
// Top spacer (baseline); additional padding handled by scroll view
SizedBox(height: isUltra ? Spacing.sm : Spacing.md),
// Microphone control
GestureDetector( GestureDetector(
onTapDown: _holdToTalk
? (_) {
if (!_isListening) _startListening();
}
: null,
onTapUp: _holdToTalk
? (_) {
if (_isListening) _stopListening();
}
: null,
onTapCancel: _holdToTalk
? () {
if (_isListening) _stopListening();
}
: null,
onTap: () => onTap: () =>
_isListening ? _stopListening() : _startListening(), _holdToTalk
? null
: (_isListening ? _stopListening() : _startListening()),
child: Container( child: Container(
width: 100, width: micSize,
height: 100, height: micSize,
decoration: BoxDecoration( decoration: BoxDecoration(
color: _isListening color: _isListening
? context.conduitTheme.error.withValues( ? context.conduitTheme.error.withValues(
@@ -2076,7 +2323,7 @@ class _VoiceInputSheetState extends ConsumerState<_VoiceInputSheet> {
: (Platform.isIOS : (Platform.isIOS
? CupertinoIcons.mic_off ? CupertinoIcons.mic_off
: Icons.mic_off), : Icons.mic_off),
size: 40, size: micIconSize,
color: _isListening color: _isListening
? context.conduitTheme.error ? context.conduitTheme.error
: context.conduitTheme.iconSecondary, : context.conduitTheme.iconSecondary,
@@ -2099,22 +2346,24 @@ class _VoiceInputSheetState extends ConsumerState<_VoiceInputSheet> {
end: const Offset(1, 1), end: const Offset(1, 1),
), ),
const SizedBox(height: Spacing.md), SizedBox(height: isUltra ? Spacing.xs : (isCompact ? Spacing.sm : Spacing.md)),
// Simple animated bars waveform based on intensity proxy // Simple animated bars waveform based on intensity proxy
SizedBox( SizedBox(
height: 32, height: isUltra ? 18 : (isCompact ? 24 : 32),
child: AnimatedSwitcher( child: AnimatedSwitcher(
duration: const Duration(milliseconds: 150), duration: const Duration(milliseconds: 150),
child: Row( child: Row(
key: ValueKey<int>(_intensity), key: ValueKey<int>(_intensity),
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: List.generate(12, (i) { children: List.generate(isUltra ? 10 : 12, (i) {
final normalized = ((_intensity + i) % 10) / 10.0; final normalized = ((_intensity + i) % 10) / 10.0;
final barHeight = 8 + (normalized * 24); final base = isUltra ? 4 : (isCompact ? 6 : 8);
final range = isUltra ? 14 : (isCompact ? 18 : 24);
final barHeight = base + (normalized * range);
return Container( return Container(
width: 4, width: isUltra ? 2.5 : (isCompact ? 3 : 4),
height: barHeight, height: barHeight,
margin: const EdgeInsets.symmetric(horizontal: 2), margin: EdgeInsets.symmetric(horizontal: isUltra ? 1 : (isCompact ? 1.5 : 2)),
decoration: BoxDecoration( decoration: BoxDecoration(
color: context.conduitTheme.buttonPrimary color: context.conduitTheme.buttonPrimary
.withValues(alpha: 0.7), .withValues(alpha: 0.7),
@@ -2125,151 +2374,156 @@ class _VoiceInputSheetState extends ConsumerState<_VoiceInputSheet> {
), ),
), ),
), ),
const SizedBox(height: Spacing.xl), SizedBox(height: isUltra ? Spacing.sm : (isCompact ? Spacing.md : Spacing.xl)),
// Recognized text / Transcribing state // Recognized text / Transcribing state with Clear action
Container( ConstrainedBox(
margin: const EdgeInsets.symmetric(horizontal: 20),
padding: const EdgeInsets.all(Spacing.md),
constraints: BoxConstraints( constraints: BoxConstraints(
maxHeight: MediaQuery.of(context).size.height * 0.2, maxHeight: media.size.height * (isUltra ? 0.13 : (isCompact ? 0.16 : 0.2)),
minHeight: 80, minHeight: isUltra ? 56 : (isCompact ? 64 : 80),
), ),
decoration: BoxDecoration( child: ConduitCard(
color: context.conduitTheme.inputBackground, isCompact: isCompact,
borderRadius: BorderRadius.circular(AppBorderRadius.md), padding: EdgeInsets.all(isCompact ? Spacing.md : Spacing.md),
border: Border.all( child: Column(
color: context.conduitTheme.inputBorder, mainAxisSize: MainAxisSize.min,
width: 1, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Inline clear action aligned to the end
Row(
children: [
Text(
'Transcript',
style: TextStyle(
fontSize: AppTypography.labelSmall,
fontWeight: FontWeight.w600,
color: context.conduitTheme.textSecondary,
),
),
const Spacer(),
ConduitIconButton(
icon: Icons.close,
isCompact: true,
tooltip: 'Clear',
onPressed: _recognizedText.isNotEmpty && !_isTranscribing
? () {
setState(() => _recognizedText = '');
}
: null,
),
],
),
const SizedBox(height: Spacing.xs),
if (_isTranscribing)
Center(
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
ConduitLoadingIndicator(
size: isUltra ? 14 : (isCompact ? 16 : 18),
isCompact: true,
),
const SizedBox(width: Spacing.xs),
Text(
'Transcribing…',
style: TextStyle(
fontSize: isUltra
? AppTypography.bodySmall
: (isCompact
? AppTypography.bodyMedium
: AppTypography.bodyLarge),
color: context.conduitTheme.textSecondary,
),
),
],
),
)
else
Flexible(
child: SingleChildScrollView(
child: Text(
_recognizedText.isEmpty
? (_isListening
? (_voiceService.hasLocalStt
? 'Speak now…'
: 'Recording…')
: 'Tap Start to begin')
: _recognizedText,
style: TextStyle(
fontSize: isUltra
? AppTypography.bodySmall
: (isCompact
? AppTypography.bodyMedium
: AppTypography.bodyLarge),
color: _recognizedText.isEmpty
? context.conduitTheme.inputPlaceholder
: context.conduitTheme.textPrimary,
height: 1.4,
),
textAlign: TextAlign.center,
),
),
),
],
), ),
), ),
child: _isTranscribing
? Center(
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(
strokeWidth: 2,
color: context.conduitTheme.buttonPrimary,
),
),
const SizedBox(width: Spacing.xs),
Text(
'Transcribing…',
style: TextStyle(
fontSize: AppTypography.bodyLarge,
color: context.conduitTheme.textSecondary,
),
),
],
),
)
: SingleChildScrollView(
child: Text(
_recognizedText.isEmpty
? (_isListening
? 'Speak now…'
: 'Tap Start to begin')
: _recognizedText,
style: TextStyle(
fontSize: AppTypography.bodyLarge,
color: _recognizedText.isEmpty
? context.conduitTheme.inputPlaceholder
: context.conduitTheme.textPrimary,
height: 1.5,
),
textAlign: TextAlign.center,
),
),
), ),
], ],
), ),
);
// Make scrollable if content exceeds available height
return SingleChildScrollView(
physics: const ClampingScrollPhysics(),
padding: EdgeInsets.only(top: topPaddingForScale),
child: ConstrainedBox(
constraints: BoxConstraints(minHeight: viewport.maxHeight),
child: content,
),
);
},
), ),
), ),
// Action buttons // Action buttons
Padding( Builder(builder: (context) {
padding: const EdgeInsets.all(Spacing.lg), final showStartStop = !_holdToTalk;
child: Row( final showSend = !_autoSendFinal;
children: [ if (!showStartStop && !showSend) {
// Start/Stop toggle button return const SizedBox.shrink();
Expanded( }
child: FilledButton.tonal( return Padding(
onPressed: _isListening ? _stopListening : _startListening, padding: EdgeInsets.only(top: isCompact ? Spacing.sm : Spacing.md),
style: FilledButton.styleFrom( child: Row(
padding: const EdgeInsets.symmetric(vertical: Spacing.md), children: [
shape: RoundedRectangleBorder( if (showStartStop) ...[
borderRadius: BorderRadius.circular(AppBorderRadius.md), Expanded(
child: ConduitButton(
text: _isListening ? 'Stop' : 'Start',
isSecondary: true,
isCompact: isCompact,
onPressed: _isListening ? _stopListening : _startListening,
), ),
), ),
child: Text( ],
_isListening ? 'Stop' : 'Start', if (showStartStop && showSend) const SizedBox(width: Spacing.xs),
style: TextStyle( if (showSend) ...[
fontSize: AppTypography.bodyLarge, Expanded(
fontWeight: FontWeight.w600, child: ConduitButton(
color: context.conduitTheme.textPrimary, text: 'Send',
isCompact: isCompact,
onPressed: _recognizedText.isNotEmpty ? _sendText : null,
), ),
), ),
), ],
), ],
),
const SizedBox(width: Spacing.xs), );
// Cancel button }),
Expanded(
child: TextButton(
onPressed: _cancel,
style: TextButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: Spacing.md),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(AppBorderRadius.md),
side: BorderSide(
color: context.conduitTheme.dividerColor,
width: 1,
),
),
),
child: Text(
'Cancel',
style: TextStyle(
color: context.conduitTheme.textPrimary,
fontSize: AppTypography.bodyLarge,
fontWeight: FontWeight.w500,
),
),
),
),
const SizedBox(width: Spacing.xs),
// Send button
Expanded(
child: FilledButton(
onPressed: _recognizedText.isNotEmpty ? _sendText : null,
style: FilledButton.styleFrom(
backgroundColor: context.conduitTheme.buttonPrimary,
foregroundColor: context.conduitTheme.buttonPrimaryText,
padding: const EdgeInsets.symmetric(vertical: Spacing.md),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(AppBorderRadius.md),
),
),
child: Text(
'Send',
style: TextStyle(
fontSize: AppTypography.bodyLarge,
fontWeight: FontWeight.w600,
),
),
),
),
],
),
),
], ],
), ),
),
),
); );
} }
} }

View File

@@ -824,6 +824,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.3.0" version: "2.3.0"
pedantic:
dependency: transitive
description:
name: pedantic
sha256: "67fc27ed9639506c856c840ccce7594d0bdcd91bc8d53d6e52359449a1d50602"
url: "https://pub.dev"
source: hosted
version: "1.11.1"
petitparser: petitparser:
dependency: transitive dependency: transitive
description: description:
@@ -1085,6 +1093,30 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.10.1" version: "1.10.1"
speech_to_text:
dependency: "direct main"
description:
name: speech_to_text
sha256: c07557664974afa061f221d0d4186935bea4220728ea9446702825e8b988db04
url: "https://pub.dev"
source: hosted
version: "7.3.0"
speech_to_text_platform_interface:
dependency: transitive
description:
name: speech_to_text_platform_interface
sha256: a1935847704e41ee468aad83181ddd2423d0833abe55d769c59afca07adb5114
url: "https://pub.dev"
source: hosted
version: "2.3.0"
speech_to_text_windows:
dependency: transitive
description:
name: speech_to_text_windows
sha256: "2c9846d18253c7bbe059a276297ef9f27e8a2745dead32192525beb208195072"
url: "https://pub.dev"
source: hosted
version: "1.0.0+beta.8"
sprintf: sprintf:
dependency: transitive dependency: transitive
description: description:

View File

@@ -35,6 +35,7 @@ dependencies:
# Platform Features # Platform Features
record: ^6.0.0 record: ^6.0.0
speech_to_text: ^7.3.0
image_picker: ^1.1.2 image_picker: ^1.1.2
file_picker: ^10.2.1 file_picker: ^10.2.1
path_provider: ^2.1.4 path_provider: ^2.1.4