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
)