feat: integrate speech-to-text functionality into chat screen with cross-platform permission configuration
This commit is contained in:
@@ -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,
|
||||
),
|
||||
|
||||
Reference in New Issue
Block a user