feat: enhance voice call functionality and response handling
- Introduced a new boolean flag `_isSpeaking` in VoiceCallService to manage speaking state during voice interactions. - Improved response handling by extracting incremental content from socket events and updating the accumulated response accordingly. - Updated the chat page to include a voice call button, allowing users to initiate voice calls directly from the chat interface. - Enhanced the modern chat input widget to support voice call functionality, providing a seamless user experience for initiating calls.
This commit is contained in:
@@ -190,6 +190,7 @@ class VoiceCallService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
String _accumulatedResponse = '';
|
String _accumulatedResponse = '';
|
||||||
|
bool _isSpeaking = false;
|
||||||
|
|
||||||
void _handleSocketEvent(
|
void _handleSocketEvent(
|
||||||
Map<String, dynamic> event,
|
Map<String, dynamic> event,
|
||||||
@@ -204,7 +205,7 @@ class VoiceCallService {
|
|||||||
final innerData = outerData['data'];
|
final innerData = outerData['data'];
|
||||||
|
|
||||||
if (eventType == 'chat:completion' && innerData is Map<String, dynamic>) {
|
if (eventType == 'chat:completion' && innerData is Map<String, dynamic>) {
|
||||||
// Handle streaming content chunks
|
// Handle full content replacement (used by some models/backends)
|
||||||
if (innerData.containsKey('content')) {
|
if (innerData.containsKey('content')) {
|
||||||
final content = innerData['content']?.toString() ?? '';
|
final content = innerData['content']?.toString() ?? '';
|
||||||
if (content.isNotEmpty) {
|
if (content.isNotEmpty) {
|
||||||
@@ -213,18 +214,29 @@ class VoiceCallService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for completion using choices[0].finish_reason
|
// Handle streaming delta chunks (incremental updates)
|
||||||
if (innerData.containsKey('choices')) {
|
if (innerData.containsKey('choices')) {
|
||||||
final choices = innerData['choices'] as List?;
|
final choices = innerData['choices'] as List?;
|
||||||
if (choices != null && choices.isNotEmpty) {
|
if (choices != null && choices.isNotEmpty) {
|
||||||
final firstChoice = choices[0] as Map<String, dynamic>?;
|
final firstChoice = choices[0] as Map<String, dynamic>?;
|
||||||
|
final delta = firstChoice?['delta'];
|
||||||
final finishReason = firstChoice?['finish_reason'];
|
final finishReason = firstChoice?['finish_reason'];
|
||||||
|
|
||||||
|
// Extract incremental content from delta
|
||||||
|
if (delta is Map<String, dynamic>) {
|
||||||
|
final deltaContent = delta['content']?.toString() ?? '';
|
||||||
|
if (deltaContent.isNotEmpty) {
|
||||||
|
_accumulatedResponse += deltaContent;
|
||||||
|
_responseController.add(_accumulatedResponse);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for completion
|
||||||
if (finishReason == 'stop') {
|
if (finishReason == 'stop') {
|
||||||
if (_accumulatedResponse.isNotEmpty) {
|
if (_accumulatedResponse.isNotEmpty && !_isSpeaking) {
|
||||||
_speakResponse(_accumulatedResponse);
|
_speakResponse(_accumulatedResponse);
|
||||||
_accumulatedResponse = '';
|
_accumulatedResponse = '';
|
||||||
} else {
|
} else if (_accumulatedResponse.isEmpty) {
|
||||||
// No response, restart listening
|
// No response, restart listening
|
||||||
_startListening();
|
_startListening();
|
||||||
}
|
}
|
||||||
@@ -236,9 +248,11 @@ class VoiceCallService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _speakResponse(String response) async {
|
Future<void> _speakResponse(String response) async {
|
||||||
if (_isDisposed) return;
|
if (_isDisposed || _isSpeaking) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
_isSpeaking = true;
|
||||||
|
|
||||||
// Stop listening before speaking
|
// Stop listening before speaking
|
||||||
await _voiceInput.stopListening();
|
await _voiceInput.stopListening();
|
||||||
await _transcriptSubscription?.cancel();
|
await _transcriptSubscription?.cancel();
|
||||||
@@ -248,6 +262,7 @@ class VoiceCallService {
|
|||||||
await _tts.speak(response);
|
await _tts.speak(response);
|
||||||
// After speaking completes, _handleTtsComplete will restart listening
|
// After speaking completes, _handleTtsComplete will restart listening
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
_isSpeaking = false;
|
||||||
_updateState(VoiceCallState.error);
|
_updateState(VoiceCallState.error);
|
||||||
// Restart listening even if TTS fails
|
// Restart listening even if TTS fails
|
||||||
await _startListening();
|
await _startListening();
|
||||||
@@ -261,6 +276,7 @@ class VoiceCallService {
|
|||||||
|
|
||||||
void _handleTtsComplete() {
|
void _handleTtsComplete() {
|
||||||
if (_isDisposed) return;
|
if (_isDisposed) return;
|
||||||
|
_isSpeaking = false;
|
||||||
// After assistant finishes speaking, start listening for user again
|
// After assistant finishes speaking, start listening for user again
|
||||||
_startListening();
|
_startListening();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1445,16 +1445,6 @@ class _ChatPageState extends ConsumerState<ChatPage> {
|
|||||||
),
|
),
|
||||||
actions: [
|
actions: [
|
||||||
if (!_isSelectionMode) ...[
|
if (!_isSelectionMode) ...[
|
||||||
// Voice call button
|
|
||||||
IconButton(
|
|
||||||
icon: Icon(
|
|
||||||
CupertinoIcons.phone,
|
|
||||||
color: context.conduitTheme.textPrimary,
|
|
||||||
size: IconSize.appBar,
|
|
||||||
),
|
|
||||||
onPressed: _handleVoiceCall,
|
|
||||||
tooltip: 'Voice Call',
|
|
||||||
),
|
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.only(right: Spacing.inputPadding),
|
padding: const EdgeInsets.only(right: Spacing.inputPadding),
|
||||||
child: IconButton(
|
child: IconButton(
|
||||||
@@ -1566,6 +1556,7 @@ class _ChatPageState extends ConsumerState<ChatPage> {
|
|||||||
onSendMessage: (text) =>
|
onSendMessage: (text) =>
|
||||||
_handleMessageSend(text, selectedModel),
|
_handleMessageSend(text, selectedModel),
|
||||||
onVoiceInput: null,
|
onVoiceInput: null,
|
||||||
|
onVoiceCall: _handleVoiceCall,
|
||||||
onFileAttachment: _handleFileAttachment,
|
onFileAttachment: _handleFileAttachment,
|
||||||
onImageAttachment: _handleImageAttachment,
|
onImageAttachment: _handleImageAttachment,
|
||||||
onCameraCapture: () =>
|
onCameraCapture: () =>
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ class ModernChatInput extends ConsumerStatefulWidget {
|
|||||||
final Function(String) onSendMessage;
|
final Function(String) onSendMessage;
|
||||||
final bool enabled;
|
final bool enabled;
|
||||||
final Function()? onVoiceInput;
|
final Function()? onVoiceInput;
|
||||||
|
final Function()? onVoiceCall;
|
||||||
final Function()? onFileAttachment;
|
final Function()? onFileAttachment;
|
||||||
final Function()? onImageAttachment;
|
final Function()? onImageAttachment;
|
||||||
final Function()? onCameraCapture;
|
final Function()? onCameraCapture;
|
||||||
@@ -69,6 +70,7 @@ class ModernChatInput extends ConsumerStatefulWidget {
|
|||||||
required this.onSendMessage,
|
required this.onSendMessage,
|
||||||
this.enabled = true,
|
this.enabled = true,
|
||||||
this.onVoiceInput,
|
this.onVoiceInput,
|
||||||
|
this.onVoiceCall,
|
||||||
this.onFileAttachment,
|
this.onFileAttachment,
|
||||||
this.onImageAttachment,
|
this.onImageAttachment,
|
||||||
this.onCameraCapture,
|
this.onCameraCapture,
|
||||||
@@ -831,6 +833,10 @@ class _ModernChatInputState extends ConsumerState<ModernChatInput>
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: Spacing.sm),
|
const SizedBox(width: Spacing.sm),
|
||||||
|
if (!_hasText && voiceAvailable && !isGenerating)
|
||||||
|
_buildMicButton(voiceAvailable),
|
||||||
|
if (!_hasText && voiceAvailable && !isGenerating)
|
||||||
|
const SizedBox(width: Spacing.sm),
|
||||||
_buildPrimaryButton(
|
_buildPrimaryButton(
|
||||||
_hasText,
|
_hasText,
|
||||||
isGenerating,
|
isGenerating,
|
||||||
@@ -911,6 +917,10 @@ class _ModernChatInputState extends ConsumerState<ModernChatInput>
|
|||||||
Row(
|
Row(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
|
if (!_hasText && voiceAvailable && !isGenerating) ...[
|
||||||
|
_buildMicButton(voiceAvailable),
|
||||||
|
const SizedBox(width: Spacing.sm),
|
||||||
|
],
|
||||||
_buildPrimaryButton(
|
_buildPrimaryButton(
|
||||||
_hasText,
|
_hasText,
|
||||||
isGenerating,
|
isGenerating,
|
||||||
@@ -1242,6 +1252,45 @@ class _ModernChatInputState extends ConsumerState<ModernChatInput>
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Widget _buildMicButton(bool voiceAvailable) {
|
||||||
|
final bool enabledMic = widget.enabled && voiceAvailable;
|
||||||
|
return Tooltip(
|
||||||
|
message: AppLocalizations.of(context)!.voiceInput,
|
||||||
|
child: Opacity(
|
||||||
|
opacity: enabledMic ? Alpha.primary : Alpha.disabled,
|
||||||
|
child: Material(
|
||||||
|
color: Colors.transparent,
|
||||||
|
child: InkWell(
|
||||||
|
borderRadius: BorderRadius.circular(AppBorderRadius.circular),
|
||||||
|
onTap: enabledMic
|
||||||
|
? () {
|
||||||
|
HapticFeedback.selectionClick();
|
||||||
|
_toggleVoice();
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
child: SizedBox(
|
||||||
|
width: TouchTarget.minimum,
|
||||||
|
height: TouchTarget.minimum,
|
||||||
|
child: Icon(
|
||||||
|
Platform.isIOS ? CupertinoIcons.mic : Icons.mic,
|
||||||
|
size: IconSize.large,
|
||||||
|
color: _isRecording
|
||||||
|
? context.conduitTheme.buttonPrimary
|
||||||
|
: (enabledMic
|
||||||
|
? context.conduitTheme.textPrimary.withValues(
|
||||||
|
alpha: Alpha.strong,
|
||||||
|
)
|
||||||
|
: context.conduitTheme.textPrimary.withValues(
|
||||||
|
alpha: Alpha.disabled,
|
||||||
|
)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
Widget _buildPrimaryButton(
|
Widget _buildPrimaryButton(
|
||||||
bool hasText,
|
bool hasText,
|
||||||
bool isGenerating,
|
bool isGenerating,
|
||||||
@@ -1296,7 +1345,7 @@ class _ModernChatInputState extends ConsumerState<ModernChatInput>
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// If there's text, render SEND variant; otherwise render MIC variant
|
// If there's text, render SEND variant; otherwise render VOICE CALL variant
|
||||||
if (hasText) {
|
if (hasText) {
|
||||||
return Tooltip(
|
return Tooltip(
|
||||||
message: enabled
|
message: enabled
|
||||||
@@ -1374,37 +1423,74 @@ class _ModernChatInputState extends ConsumerState<ModernChatInput>
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// MIC variant when no text is present
|
// VOICE CALL variant when no text is present
|
||||||
final bool enabledMic = widget.enabled && voiceAvailable;
|
final bool enabledVoiceCall = widget.enabled && widget.onVoiceCall != null;
|
||||||
return Tooltip(
|
return Tooltip(
|
||||||
message: AppLocalizations.of(context)!.voiceInput,
|
message: 'Voice Call',
|
||||||
child: Opacity(
|
child: Opacity(
|
||||||
opacity: enabledMic ? Alpha.primary : Alpha.disabled,
|
opacity: enabledVoiceCall ? Alpha.primary : Alpha.disabled,
|
||||||
|
child: IgnorePointer(
|
||||||
|
ignoring: !enabledVoiceCall,
|
||||||
child: Material(
|
child: Material(
|
||||||
color: Colors.transparent,
|
color: Colors.transparent,
|
||||||
child: InkWell(
|
child: InkWell(
|
||||||
borderRadius: BorderRadius.circular(AppBorderRadius.circular),
|
borderRadius: BorderRadius.circular(radius),
|
||||||
onTap: enabledMic
|
onTap: enabledVoiceCall
|
||||||
? () {
|
? () {
|
||||||
HapticFeedback.selectionClick();
|
PlatformUtils.lightHaptic();
|
||||||
_toggleVoice();
|
widget.onVoiceCall!();
|
||||||
}
|
}
|
||||||
: null,
|
: null,
|
||||||
child: SizedBox(
|
child: AnimatedContainer(
|
||||||
width: TouchTarget.minimum,
|
duration: const Duration(milliseconds: 160),
|
||||||
height: TouchTarget.minimum,
|
curve: Curves.easeOutCubic,
|
||||||
child: Icon(
|
width: buttonSize,
|
||||||
Platform.isIOS ? CupertinoIcons.mic : Icons.mic,
|
height: buttonSize,
|
||||||
size: IconSize.large,
|
decoration: BoxDecoration(
|
||||||
color: _isRecording
|
color: enabledVoiceCall
|
||||||
? context.conduitTheme.buttonPrimary
|
? context.conduitTheme.buttonPrimary
|
||||||
: (enabledMic
|
: context.conduitTheme.surfaceContainerHighest,
|
||||||
? context.conduitTheme.textPrimary.withValues(
|
borderRadius: BorderRadius.circular(radius),
|
||||||
alpha: Alpha.strong,
|
border: Border.all(
|
||||||
|
color: enabledVoiceCall
|
||||||
|
? context.conduitTheme.buttonPrimary.withValues(
|
||||||
|
alpha: 0.8,
|
||||||
)
|
)
|
||||||
|
: context.conduitTheme.cardBorder.withValues(
|
||||||
|
alpha: 0.45,
|
||||||
|
),
|
||||||
|
width: BorderWidth.thin,
|
||||||
|
),
|
||||||
|
boxShadow: enabledVoiceCall
|
||||||
|
? <BoxShadow>[
|
||||||
|
BoxShadow(
|
||||||
|
color: context.conduitTheme.cardShadow.withValues(
|
||||||
|
alpha:
|
||||||
|
Theme.of(context).brightness ==
|
||||||
|
Brightness.dark
|
||||||
|
? 0.36
|
||||||
|
: 0.18,
|
||||||
|
),
|
||||||
|
blurRadius: 18,
|
||||||
|
spreadRadius: -6,
|
||||||
|
offset: const Offset(0, 8),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
: const [],
|
||||||
|
),
|
||||||
|
child: Center(
|
||||||
|
child: Icon(
|
||||||
|
Platform.isIOS
|
||||||
|
? CupertinoIcons.waveform
|
||||||
|
: Icons.graphic_eq,
|
||||||
|
size: IconSize.large,
|
||||||
|
color: enabledVoiceCall
|
||||||
|
? context.conduitTheme.buttonPrimaryText
|
||||||
: context.conduitTheme.textPrimary.withValues(
|
: context.conduitTheme.textPrimary.withValues(
|
||||||
alpha: Alpha.disabled,
|
alpha: Alpha.disabled,
|
||||||
)),
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
Reference in New Issue
Block a user