From ea79a193be9feec503d2d66649b0fac936834da5 Mon Sep 17 00:00:00 2001 From: cogwheel0 <172976095+cogwheel0@users.noreply.github.com> Date: Wed, 8 Oct 2025 19:09:57 +0530 Subject: [PATCH] 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. --- .../chat/services/voice_call_service.dart | 26 +++- lib/features/chat/views/chat_page.dart | 11 +- .../chat/widgets/modern_chat_input.dart | 146 ++++++++++++++---- 3 files changed, 138 insertions(+), 45 deletions(-) diff --git a/lib/features/chat/services/voice_call_service.dart b/lib/features/chat/services/voice_call_service.dart index 5992591..33fb027 100644 --- a/lib/features/chat/services/voice_call_service.dart +++ b/lib/features/chat/services/voice_call_service.dart @@ -190,6 +190,7 @@ class VoiceCallService { } String _accumulatedResponse = ''; + bool _isSpeaking = false; void _handleSocketEvent( Map event, @@ -204,7 +205,7 @@ class VoiceCallService { final innerData = outerData['data']; if (eventType == 'chat:completion' && innerData is Map) { - // Handle streaming content chunks + // Handle full content replacement (used by some models/backends) if (innerData.containsKey('content')) { final content = innerData['content']?.toString() ?? ''; 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')) { final choices = innerData['choices'] as List?; if (choices != null && choices.isNotEmpty) { final firstChoice = choices[0] as Map?; + final delta = firstChoice?['delta']; final finishReason = firstChoice?['finish_reason']; + // Extract incremental content from delta + if (delta is Map) { + final deltaContent = delta['content']?.toString() ?? ''; + if (deltaContent.isNotEmpty) { + _accumulatedResponse += deltaContent; + _responseController.add(_accumulatedResponse); + } + } + + // Check for completion if (finishReason == 'stop') { - if (_accumulatedResponse.isNotEmpty) { + if (_accumulatedResponse.isNotEmpty && !_isSpeaking) { _speakResponse(_accumulatedResponse); _accumulatedResponse = ''; - } else { + } else if (_accumulatedResponse.isEmpty) { // No response, restart listening _startListening(); } @@ -236,9 +248,11 @@ class VoiceCallService { } Future _speakResponse(String response) async { - if (_isDisposed) return; + if (_isDisposed || _isSpeaking) return; try { + _isSpeaking = true; + // Stop listening before speaking await _voiceInput.stopListening(); await _transcriptSubscription?.cancel(); @@ -248,6 +262,7 @@ class VoiceCallService { await _tts.speak(response); // After speaking completes, _handleTtsComplete will restart listening } catch (e) { + _isSpeaking = false; _updateState(VoiceCallState.error); // Restart listening even if TTS fails await _startListening(); @@ -261,6 +276,7 @@ class VoiceCallService { void _handleTtsComplete() { if (_isDisposed) return; + _isSpeaking = false; // After assistant finishes speaking, start listening for user again _startListening(); } diff --git a/lib/features/chat/views/chat_page.dart b/lib/features/chat/views/chat_page.dart index b83321f..2c5dbc7 100644 --- a/lib/features/chat/views/chat_page.dart +++ b/lib/features/chat/views/chat_page.dart @@ -1445,16 +1445,6 @@ class _ChatPageState extends ConsumerState { ), actions: [ if (!_isSelectionMode) ...[ - // Voice call button - IconButton( - icon: Icon( - CupertinoIcons.phone, - color: context.conduitTheme.textPrimary, - size: IconSize.appBar, - ), - onPressed: _handleVoiceCall, - tooltip: 'Voice Call', - ), Padding( padding: const EdgeInsets.only(right: Spacing.inputPadding), child: IconButton( @@ -1566,6 +1556,7 @@ class _ChatPageState extends ConsumerState { onSendMessage: (text) => _handleMessageSend(text, selectedModel), onVoiceInput: null, + onVoiceCall: _handleVoiceCall, onFileAttachment: _handleFileAttachment, onImageAttachment: _handleImageAttachment, onCameraCapture: () => diff --git a/lib/features/chat/widgets/modern_chat_input.dart b/lib/features/chat/widgets/modern_chat_input.dart index db2a8c0..30b8e4b 100644 --- a/lib/features/chat/widgets/modern_chat_input.dart +++ b/lib/features/chat/widgets/modern_chat_input.dart @@ -60,6 +60,7 @@ class ModernChatInput extends ConsumerStatefulWidget { final Function(String) onSendMessage; final bool enabled; final Function()? onVoiceInput; + final Function()? onVoiceCall; final Function()? onFileAttachment; final Function()? onImageAttachment; final Function()? onCameraCapture; @@ -69,6 +70,7 @@ class ModernChatInput extends ConsumerStatefulWidget { required this.onSendMessage, this.enabled = true, this.onVoiceInput, + this.onVoiceCall, this.onFileAttachment, this.onImageAttachment, this.onCameraCapture, @@ -831,6 +833,10 @@ class _ModernChatInputState extends ConsumerState ), ), const SizedBox(width: Spacing.sm), + if (!_hasText && voiceAvailable && !isGenerating) + _buildMicButton(voiceAvailable), + if (!_hasText && voiceAvailable && !isGenerating) + const SizedBox(width: Spacing.sm), _buildPrimaryButton( _hasText, isGenerating, @@ -911,6 +917,10 @@ class _ModernChatInputState extends ConsumerState Row( mainAxisSize: MainAxisSize.min, children: [ + if (!_hasText && voiceAvailable && !isGenerating) ...[ + _buildMicButton(voiceAvailable), + const SizedBox(width: Spacing.sm), + ], _buildPrimaryButton( _hasText, isGenerating, @@ -1242,6 +1252,45 @@ class _ModernChatInputState extends ConsumerState ); } + 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( bool hasText, bool isGenerating, @@ -1296,7 +1345,7 @@ class _ModernChatInputState extends ConsumerState ); } - // 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) { return Tooltip( message: enabled @@ -1374,37 +1423,74 @@ class _ModernChatInputState extends ConsumerState ); } - // MIC variant when no text is present - final bool enabledMic = widget.enabled && voiceAvailable; + // VOICE CALL variant when no text is present + final bool enabledVoiceCall = widget.enabled && widget.onVoiceCall != null; return Tooltip( - message: AppLocalizations.of(context)!.voiceInput, + message: 'Voice Call', 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, - )), + opacity: enabledVoiceCall ? Alpha.primary : Alpha.disabled, + child: IgnorePointer( + ignoring: !enabledVoiceCall, + child: Material( + color: Colors.transparent, + child: InkWell( + borderRadius: BorderRadius.circular(radius), + onTap: enabledVoiceCall + ? () { + PlatformUtils.lightHaptic(); + widget.onVoiceCall!(); + } + : null, + child: AnimatedContainer( + duration: const Duration(milliseconds: 160), + curve: Curves.easeOutCubic, + width: buttonSize, + height: buttonSize, + decoration: BoxDecoration( + color: enabledVoiceCall + ? context.conduitTheme.buttonPrimary + : context.conduitTheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(radius), + 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( + 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( + alpha: Alpha.disabled, + ), + ), + ), ), ), ),