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

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