feat: integrate speech-to-text functionality into chat screen with cross-platform permission configuration

This commit is contained in:
pradeepkumar
2026-04-08 20:21:29 +05:30
parent 8474d41002
commit ca81a73e49
8 changed files with 149 additions and 6 deletions

View File

@@ -5,6 +5,10 @@
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" android:maxSdkVersion="32"/>
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES"/>
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
<uses-permission android:name="android.permission.RECORD_AUDIO"/>
<uses-permission android:name="android.permission.BLUETOOTH"/>
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN"/>
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT"/>
<application
android:label="@string/app_name"
android:name="${applicationName}"
@@ -92,5 +96,9 @@
<action android:name="android.intent.action.VIEW"/>
<data android:scheme="geo"/>
</intent>
<!-- Speech-to-text recognition service -->
<intent>
<action android:name="android.speech.RecognitionService" />
</intent>
</queries>
</manifest>

View File

@@ -72,6 +72,10 @@
<string>We need access to your photo library to set your profile photo.</string>
<key>NSCameraUsageDescription</key>
<string>We need access to your camera to take a profile photo.</string>
<key>NSMicrophoneUsageDescription</key>
<string>We need access to your microphone to enable voice input in chat.</string>
<key>NSSpeechRecognitionUsageDescription</key>
<string>We use speech recognition to convert your voice to text in chat messages.</string>
<key>UIApplicationSupportsIndirectInputEvents</key>
<true/>
<key>UILaunchStoryboardName</key>

View File

@@ -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<ChatScreen>
final SocketService _socket = SocketService();
StreamSubscription<bool>? _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<ChatScreen>
_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<ChatScreen>
@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<ChatScreen>
}
}
Future<void> _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<void> _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<ChatScreen>
),
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,
),

View File

@@ -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"))

View File

@@ -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:

View File

@@ -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:

View File

@@ -10,6 +10,7 @@
#include <file_selector_windows/file_selector_windows.h>
#include <firebase_core/firebase_core_plugin_c_api.h>
#include <flutter_secure_storage_windows/flutter_secure_storage_windows_plugin.h>
#include <speech_to_text_windows/speech_to_text_windows.h>
#include <url_launcher_windows/url_launcher_windows.h>
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"));
}

View File

@@ -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
)