feat: integrate speech-to-text functionality into chat screen with cross-platform permission configuration
This commit is contained in:
@@ -5,6 +5,10 @@
|
|||||||
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" android:maxSdkVersion="32"/>
|
<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.READ_MEDIA_IMAGES"/>
|
||||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
|
<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
|
<application
|
||||||
android:label="@string/app_name"
|
android:label="@string/app_name"
|
||||||
android:name="${applicationName}"
|
android:name="${applicationName}"
|
||||||
@@ -92,5 +96,9 @@
|
|||||||
<action android:name="android.intent.action.VIEW"/>
|
<action android:name="android.intent.action.VIEW"/>
|
||||||
<data android:scheme="geo"/>
|
<data android:scheme="geo"/>
|
||||||
</intent>
|
</intent>
|
||||||
|
<!-- Speech-to-text recognition service -->
|
||||||
|
<intent>
|
||||||
|
<action android:name="android.speech.RecognitionService" />
|
||||||
|
</intent>
|
||||||
</queries>
|
</queries>
|
||||||
</manifest>
|
</manifest>
|
||||||
|
|||||||
@@ -72,6 +72,10 @@
|
|||||||
<string>We need access to your photo library to set your profile photo.</string>
|
<string>We need access to your photo library to set your profile photo.</string>
|
||||||
<key>NSCameraUsageDescription</key>
|
<key>NSCameraUsageDescription</key>
|
||||||
<string>We need access to your camera to take a profile photo.</string>
|
<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>
|
<key>UIApplicationSupportsIndirectInputEvents</key>
|
||||||
<true/>
|
<true/>
|
||||||
<key>UILaunchStoryboardName</key>
|
<key>UILaunchStoryboardName</key>
|
||||||
|
|||||||
@@ -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/core/services/push_notification_service.dart';
|
||||||
import 'package:real_estate_mobile/features/messaging/data/socket_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: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';
|
import 'package:url_launcher/url_launcher.dart';
|
||||||
|
|
||||||
class ChatScreen extends ConsumerStatefulWidget {
|
class ChatScreen extends ConsumerStatefulWidget {
|
||||||
@@ -36,6 +37,12 @@ class _ChatScreenState extends ConsumerState<ChatScreen>
|
|||||||
final SocketService _socket = SocketService();
|
final SocketService _socket = SocketService();
|
||||||
StreamSubscription<bool>? _connectionSub;
|
StreamSubscription<bool>? _connectionSub;
|
||||||
|
|
||||||
|
// Speech-to-text
|
||||||
|
final stt.SpeechToText _speech = stt.SpeechToText();
|
||||||
|
bool _speechAvailable = false;
|
||||||
|
bool _isListening = false;
|
||||||
|
String _lastPartial = '';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
@@ -82,6 +89,8 @@ class _ChatScreenState extends ConsumerState<ChatScreen>
|
|||||||
_socket.joinConversation(widget.conversationId);
|
_socket.joinConversation(widget.conversationId);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
// Initialize speech-to-text (requests mic permission lazily on first use)
|
||||||
|
_initSpeech();
|
||||||
});
|
});
|
||||||
_scrollController.addListener(_onScroll);
|
_scrollController.addListener(_onScroll);
|
||||||
_messageController.addListener(_onTextChanged);
|
_messageController.addListener(_onTextChanged);
|
||||||
@@ -116,6 +125,9 @@ class _ChatScreenState extends ConsumerState<ChatScreen>
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
|
if (_isListening) {
|
||||||
|
_speech.cancel();
|
||||||
|
}
|
||||||
WidgetsBinding.instance.removeObserver(this);
|
WidgetsBinding.instance.removeObserver(this);
|
||||||
_connectionSub?.cancel();
|
_connectionSub?.cancel();
|
||||||
// Stop typing and leave room before disposing
|
// 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}) {
|
void _scrollToBottom({bool animated = true}) {
|
||||||
if (!_scrollController.hasClients) return;
|
if (!_scrollController.hasClients) return;
|
||||||
if (animated) {
|
if (animated) {
|
||||||
@@ -1545,19 +1635,21 @@ class _ChatScreenState extends ConsumerState<ChatScreen>
|
|||||||
),
|
),
|
||||||
const SizedBox(width: 10),
|
const SizedBox(width: 10),
|
||||||
|
|
||||||
// Mic icon in filled dark circle
|
// Mic icon in filled circle — turns orange while listening
|
||||||
GestureDetector(
|
GestureDetector(
|
||||||
onTap: () => _showComingSoon('Speech to text'),
|
onTap: _toggleSpeech,
|
||||||
child: Container(
|
child: Container(
|
||||||
width: 36,
|
width: 36,
|
||||||
height: 36,
|
height: 36,
|
||||||
decoration: const BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: AppColors.primaryDark,
|
color: _isListening
|
||||||
|
? AppColors.accentOrange
|
||||||
|
: AppColors.primaryDark,
|
||||||
shape: BoxShape.circle,
|
shape: BoxShape.circle,
|
||||||
),
|
),
|
||||||
child: const Center(
|
child: Center(
|
||||||
child: Icon(
|
child: Icon(
|
||||||
Icons.mic_none,
|
_isListening ? Icons.mic : Icons.mic_none,
|
||||||
size: 20,
|
size: 20,
|
||||||
color: Colors.white,
|
color: Colors.white,
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import google_sign_in_ios
|
|||||||
import package_info_plus
|
import package_info_plus
|
||||||
import path_provider_foundation
|
import path_provider_foundation
|
||||||
import shared_preferences_foundation
|
import shared_preferences_foundation
|
||||||
|
import speech_to_text
|
||||||
import sqflite_darwin
|
import sqflite_darwin
|
||||||
import url_launcher_macos
|
import url_launcher_macos
|
||||||
import webview_flutter_wkwebview
|
import webview_flutter_wkwebview
|
||||||
@@ -32,6 +33,7 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
|||||||
FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin"))
|
FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin"))
|
||||||
PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin"))
|
PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin"))
|
||||||
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
|
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
|
||||||
|
SpeechToTextPlugin.register(with: registry.registrar(forPlugin: "SpeechToTextPlugin"))
|
||||||
SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin"))
|
SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin"))
|
||||||
UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin"))
|
UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin"))
|
||||||
WebViewFlutterPlugin.register(with: registry.registrar(forPlugin: "WebViewFlutterPlugin"))
|
WebViewFlutterPlugin.register(with: registry.registrar(forPlugin: "WebViewFlutterPlugin"))
|
||||||
|
|||||||
32
pubspec.lock
32
pubspec.lock
@@ -1000,6 +1000,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:
|
||||||
@@ -1221,6 +1229,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:
|
||||||
|
|||||||
@@ -63,6 +63,7 @@ dependencies:
|
|||||||
image_picker: ^1.2.1
|
image_picker: ^1.2.1
|
||||||
webview_flutter: ^4.13.1
|
webview_flutter: ^4.13.1
|
||||||
package_info_plus: ^9.0.0
|
package_info_plus: ^9.0.0
|
||||||
|
speech_to_text: ^7.3.0
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
flutter_test:
|
flutter_test:
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
#include <file_selector_windows/file_selector_windows.h>
|
#include <file_selector_windows/file_selector_windows.h>
|
||||||
#include <firebase_core/firebase_core_plugin_c_api.h>
|
#include <firebase_core/firebase_core_plugin_c_api.h>
|
||||||
#include <flutter_secure_storage_windows/flutter_secure_storage_windows_plugin.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>
|
#include <url_launcher_windows/url_launcher_windows.h>
|
||||||
|
|
||||||
void RegisterPlugins(flutter::PluginRegistry* registry) {
|
void RegisterPlugins(flutter::PluginRegistry* registry) {
|
||||||
@@ -21,6 +22,8 @@ void RegisterPlugins(flutter::PluginRegistry* registry) {
|
|||||||
registry->GetRegistrarForPlugin("FirebaseCorePluginCApi"));
|
registry->GetRegistrarForPlugin("FirebaseCorePluginCApi"));
|
||||||
FlutterSecureStorageWindowsPluginRegisterWithRegistrar(
|
FlutterSecureStorageWindowsPluginRegisterWithRegistrar(
|
||||||
registry->GetRegistrarForPlugin("FlutterSecureStorageWindowsPlugin"));
|
registry->GetRegistrarForPlugin("FlutterSecureStorageWindowsPlugin"));
|
||||||
|
SpeechToTextWindowsRegisterWithRegistrar(
|
||||||
|
registry->GetRegistrarForPlugin("SpeechToTextWindows"));
|
||||||
UrlLauncherWindowsRegisterWithRegistrar(
|
UrlLauncherWindowsRegisterWithRegistrar(
|
||||||
registry->GetRegistrarForPlugin("UrlLauncherWindows"));
|
registry->GetRegistrarForPlugin("UrlLauncherWindows"));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ list(APPEND FLUTTER_PLUGIN_LIST
|
|||||||
file_selector_windows
|
file_selector_windows
|
||||||
firebase_core
|
firebase_core
|
||||||
flutter_secure_storage_windows
|
flutter_secure_storage_windows
|
||||||
|
speech_to_text_windows
|
||||||
url_launcher_windows
|
url_launcher_windows
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user