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_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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
|
||||
@@ -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"))
|
||||
|
||||
32
pubspec.lock
32
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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user