diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 12021a5..be0c581 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -5,6 +5,10 @@ + + + + + + + + diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist index f128e20..2f1905c 100644 --- a/ios/Runner/Info.plist +++ b/ios/Runner/Info.plist @@ -72,6 +72,10 @@ We need access to your photo library to set your profile photo. NSCameraUsageDescription We need access to your camera to take a profile photo. + NSMicrophoneUsageDescription + We need access to your microphone to enable voice input in chat. + NSSpeechRecognitionUsageDescription + We use speech recognition to convert your voice to text in chat messages. UIApplicationSupportsIndirectInputEvents UILaunchStoryboardName diff --git a/lib/features/messaging/presentation/screens/chat_screen.dart b/lib/features/messaging/presentation/screens/chat_screen.dart index 98f95fa..ea13e1a 100644 --- a/lib/features/messaging/presentation/screens/chat_screen.dart +++ b/lib/features/messaging/presentation/screens/chat_screen.dart @@ -12,6 +12,7 @@ import 'package:real_estate_mobile/features/messaging/data/models/messaging_mode import 'package:real_estate_mobile/core/services/push_notification_service.dart'; import 'package:real_estate_mobile/features/messaging/data/socket_service.dart'; import 'package:real_estate_mobile/features/messaging/presentation/providers/messaging_provider.dart'; +import 'package:speech_to_text/speech_to_text.dart' as stt; import 'package:url_launcher/url_launcher.dart'; class ChatScreen extends ConsumerStatefulWidget { @@ -36,6 +37,12 @@ class _ChatScreenState extends ConsumerState final SocketService _socket = SocketService(); StreamSubscription? _connectionSub; + // Speech-to-text + final stt.SpeechToText _speech = stt.SpeechToText(); + bool _speechAvailable = false; + bool _isListening = false; + String _lastPartial = ''; + @override void initState() { super.initState(); @@ -82,6 +89,8 @@ class _ChatScreenState extends ConsumerState _socket.joinConversation(widget.conversationId); } }); + // Initialize speech-to-text (requests mic permission lazily on first use) + _initSpeech(); }); _scrollController.addListener(_onScroll); _messageController.addListener(_onTextChanged); @@ -116,6 +125,9 @@ class _ChatScreenState extends ConsumerState @override void dispose() { + if (_isListening) { + _speech.cancel(); + } WidgetsBinding.instance.removeObserver(this); _connectionSub?.cancel(); // Stop typing and leave room before disposing @@ -152,6 +164,84 @@ class _ChatScreenState extends ConsumerState } } + Future _initSpeech() async { + try { + _speechAvailable = await _speech.initialize( + onStatus: (status) { + // Stop UI listening indicator when engine stops on its own + if (status == 'done' || status == 'notListening') { + if (mounted && _isListening) { + setState(() => _isListening = false); + } + } + }, + onError: (error) { + if (mounted) { + setState(() => _isListening = false); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Speech error: ${error.errorMsg}'), + duration: const Duration(seconds: 2), + ), + ); + } + }, + ); + } catch (_) { + _speechAvailable = false; + } + } + + Future _toggleSpeech() async { + if (_isListening) { + await _speech.stop(); + if (mounted) setState(() => _isListening = false); + return; + } + + if (!_speechAvailable) { + await _initSpeech(); + if (!_speechAvailable) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text( + 'Microphone permission is required for speech input', + ), + duration: Duration(seconds: 2), + ), + ); + } + return; + } + } + + // Remember what was already typed so partials don't wipe it + _lastPartial = ''; + final baseText = _messageController.text; + + setState(() => _isListening = true); + await _speech.listen( + onResult: (result) { + final recognized = result.recognizedWords; + final separator = + baseText.isNotEmpty && !baseText.endsWith(' ') ? ' ' : ''; + final newText = '$baseText$separator$recognized'; + _messageController.value = TextEditingValue( + text: newText, + selection: TextSelection.collapsed(offset: newText.length), + ); + _lastPartial = recognized; + }, + listenFor: const Duration(seconds: 30), + pauseFor: const Duration(seconds: 3), + listenOptions: stt.SpeechListenOptions( + cancelOnError: true, + partialResults: true, + ), + ); + } + void _scrollToBottom({bool animated = true}) { if (!_scrollController.hasClients) return; if (animated) { @@ -1545,19 +1635,21 @@ class _ChatScreenState extends ConsumerState ), const SizedBox(width: 10), - // Mic icon in filled dark circle + // Mic icon in filled circle — turns orange while listening GestureDetector( - onTap: () => _showComingSoon('Speech to text'), + onTap: _toggleSpeech, child: Container( width: 36, height: 36, - decoration: const BoxDecoration( - color: AppColors.primaryDark, + decoration: BoxDecoration( + color: _isListening + ? AppColors.accentOrange + : AppColors.primaryDark, shape: BoxShape.circle, ), - child: const Center( + child: Center( child: Icon( - Icons.mic_none, + _isListening ? Icons.mic : Icons.mic_none, size: 20, color: Colors.white, ), diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index c75bcbb..d660086 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -16,6 +16,7 @@ import google_sign_in_ios import package_info_plus import path_provider_foundation import shared_preferences_foundation +import speech_to_text import sqflite_darwin import url_launcher_macos import webview_flutter_wkwebview @@ -32,6 +33,7 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin")) PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin")) SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) + SpeechToTextPlugin.register(with: registry.registrar(forPlugin: "SpeechToTextPlugin")) SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin")) UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin")) WebViewFlutterPlugin.register(with: registry.registrar(forPlugin: "WebViewFlutterPlugin")) diff --git a/pubspec.lock b/pubspec.lock index 0700d2b..93bec46 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1000,6 +1000,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.3.0" + pedantic: + dependency: transitive + description: + name: pedantic + sha256: "67fc27ed9639506c856c840ccce7594d0bdcd91bc8d53d6e52359449a1d50602" + url: "https://pub.dev" + source: hosted + version: "1.11.1" petitparser: dependency: transitive description: @@ -1221,6 +1229,30 @@ packages: url: "https://pub.dev" source: hosted 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: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 3aaa877..08fa28f 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -63,6 +63,7 @@ dependencies: image_picker: ^1.2.1 webview_flutter: ^4.13.1 package_info_plus: ^9.0.0 + speech_to_text: ^7.3.0 dev_dependencies: flutter_test: diff --git a/windows/flutter/generated_plugin_registrant.cc b/windows/flutter/generated_plugin_registrant.cc index a19633f..06c4707 100644 --- a/windows/flutter/generated_plugin_registrant.cc +++ b/windows/flutter/generated_plugin_registrant.cc @@ -10,6 +10,7 @@ #include #include #include +#include #include void RegisterPlugins(flutter::PluginRegistry* registry) { @@ -21,6 +22,8 @@ void RegisterPlugins(flutter::PluginRegistry* registry) { registry->GetRegistrarForPlugin("FirebaseCorePluginCApi")); FlutterSecureStorageWindowsPluginRegisterWithRegistrar( registry->GetRegistrarForPlugin("FlutterSecureStorageWindowsPlugin")); + SpeechToTextWindowsRegisterWithRegistrar( + registry->GetRegistrarForPlugin("SpeechToTextWindows")); UrlLauncherWindowsRegisterWithRegistrar( registry->GetRegistrarForPlugin("UrlLauncherWindows")); } diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake index fe60621..8bb250d 100644 --- a/windows/flutter/generated_plugins.cmake +++ b/windows/flutter/generated_plugins.cmake @@ -7,6 +7,7 @@ list(APPEND FLUTTER_PLUGIN_LIST file_selector_windows firebase_core flutter_secure_storage_windows + speech_to_text_windows url_launcher_windows )