feat: add voice call functionality to chat page
- Introduced a new button in the chat page's app bar to initiate voice calls. - Implemented the _handleVoiceCall method to navigate to the VoiceCallPage. - Enhanced user experience by providing a direct way to start voice calls from the chat interface.
This commit is contained in:
390
lib/features/chat/services/voice_call_service.dart
Normal file
390
lib/features/chat/services/voice_call_service.dart
Normal file
@@ -0,0 +1,390 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
|
||||
import '../../../core/providers/app_providers.dart';
|
||||
import '../../../core/services/socket_service.dart';
|
||||
import '../providers/chat_providers.dart';
|
||||
import 'text_to_speech_service.dart';
|
||||
import 'voice_input_service.dart';
|
||||
|
||||
part 'voice_call_service.g.dart';
|
||||
|
||||
enum VoiceCallState {
|
||||
idle,
|
||||
connecting,
|
||||
listening,
|
||||
processing,
|
||||
speaking,
|
||||
error,
|
||||
disconnected,
|
||||
}
|
||||
|
||||
class VoiceCallService {
|
||||
final VoiceInputService _voiceInput;
|
||||
final TextToSpeechService _tts;
|
||||
final SocketService _socketService;
|
||||
final Ref _ref;
|
||||
|
||||
VoiceCallState _state = VoiceCallState.idle;
|
||||
String? _sessionId;
|
||||
StreamSubscription<String>? _transcriptSubscription;
|
||||
StreamSubscription<int>? _intensitySubscription;
|
||||
String _accumulatedTranscript = '';
|
||||
bool _isDisposed = false;
|
||||
SocketEventSubscription? _socketSubscription;
|
||||
|
||||
final StreamController<VoiceCallState> _stateController =
|
||||
StreamController<VoiceCallState>.broadcast();
|
||||
final StreamController<String> _transcriptController =
|
||||
StreamController<String>.broadcast();
|
||||
final StreamController<String> _responseController =
|
||||
StreamController<String>.broadcast();
|
||||
final StreamController<int> _intensityController =
|
||||
StreamController<int>.broadcast();
|
||||
|
||||
VoiceCallService({
|
||||
required VoiceInputService voiceInput,
|
||||
required TextToSpeechService tts,
|
||||
required SocketService socketService,
|
||||
required Ref ref,
|
||||
}) : _voiceInput = voiceInput,
|
||||
_tts = tts,
|
||||
_socketService = socketService,
|
||||
_ref = ref {
|
||||
_tts.bindHandlers(
|
||||
onStart: _handleTtsStart,
|
||||
onComplete: _handleTtsComplete,
|
||||
onError: _handleTtsError,
|
||||
);
|
||||
}
|
||||
|
||||
VoiceCallState get state => _state;
|
||||
Stream<VoiceCallState> get stateStream => _stateController.stream;
|
||||
Stream<String> get transcriptStream => _transcriptController.stream;
|
||||
Stream<String> get responseStream => _responseController.stream;
|
||||
Stream<int> get intensityStream => _intensityController.stream;
|
||||
|
||||
Future<void> initialize() async {
|
||||
if (_isDisposed) return;
|
||||
|
||||
// ignore: avoid_print
|
||||
print('[VoiceCall] Starting initialization...');
|
||||
|
||||
// Initialize voice input
|
||||
final voiceInitialized = await _voiceInput.initialize();
|
||||
// ignore: avoid_print
|
||||
print('[VoiceCall] Voice initialized: $voiceInitialized');
|
||||
if (!voiceInitialized) {
|
||||
_updateState(VoiceCallState.error);
|
||||
throw Exception('Voice input initialization failed');
|
||||
}
|
||||
|
||||
// Check if local STT is available
|
||||
final hasLocalStt = _voiceInput.hasLocalStt;
|
||||
// ignore: avoid_print
|
||||
print('[VoiceCall] Has local STT: $hasLocalStt');
|
||||
if (!hasLocalStt) {
|
||||
_updateState(VoiceCallState.error);
|
||||
throw Exception('Speech recognition not available on this device');
|
||||
}
|
||||
|
||||
// Check microphone permissions
|
||||
final hasMicPermission = await _voiceInput.checkPermissions();
|
||||
// ignore: avoid_print
|
||||
print('[VoiceCall] Has mic permission: $hasMicPermission');
|
||||
if (!hasMicPermission) {
|
||||
_updateState(VoiceCallState.error);
|
||||
throw Exception('Microphone permission not granted');
|
||||
}
|
||||
|
||||
// Initialize TTS
|
||||
await _tts.initialize();
|
||||
// ignore: avoid_print
|
||||
print('[VoiceCall] TTS initialized');
|
||||
}
|
||||
|
||||
Future<void> startCall(String? conversationId) async {
|
||||
// ignore: avoid_print
|
||||
print('[VoiceCall] startCall() entered. _isDisposed=$_isDisposed');
|
||||
|
||||
if (_isDisposed) {
|
||||
// ignore: avoid_print
|
||||
print('[VoiceCall] EARLY RETURN: Service is disposed');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// ignore: avoid_print
|
||||
print('[VoiceCall] Starting call for conversation: $conversationId');
|
||||
_updateState(VoiceCallState.connecting);
|
||||
|
||||
// Ensure socket connection
|
||||
// ignore: avoid_print
|
||||
print('[VoiceCall] Ensuring socket connection...');
|
||||
await _socketService.ensureConnected();
|
||||
_sessionId = _socketService.sessionId;
|
||||
// ignore: avoid_print
|
||||
print('[VoiceCall] Session ID: $_sessionId');
|
||||
|
||||
if (_sessionId == null) {
|
||||
throw Exception('Failed to establish socket connection');
|
||||
}
|
||||
|
||||
// Set up socket event listener for assistant responses
|
||||
// ignore: avoid_print
|
||||
print('[VoiceCall] Setting up socket event handler...');
|
||||
_socketSubscription = _socketService.addChatEventHandler(
|
||||
conversationId: conversationId,
|
||||
sessionId: _sessionId,
|
||||
requireFocus: false,
|
||||
handler: _handleSocketEvent,
|
||||
);
|
||||
|
||||
// Start listening for user voice input
|
||||
// ignore: avoid_print
|
||||
print('[VoiceCall] Starting to listen...');
|
||||
await _startListening();
|
||||
// ignore: avoid_print
|
||||
print('[VoiceCall] Listen started successfully');
|
||||
} catch (e) {
|
||||
// ignore: avoid_print
|
||||
print('[VoiceCall] Error in startCall: $e');
|
||||
_updateState(VoiceCallState.error);
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _startListening() async {
|
||||
if (_isDisposed) return;
|
||||
|
||||
try {
|
||||
_accumulatedTranscript = '';
|
||||
|
||||
// ignore: avoid_print
|
||||
print('[VoiceCall] _startListening called');
|
||||
|
||||
// Check if voice input is available
|
||||
if (!_voiceInput.hasLocalStt) {
|
||||
// ignore: avoid_print
|
||||
print('[VoiceCall] ERROR: No local STT available');
|
||||
_updateState(VoiceCallState.error);
|
||||
throw Exception('Voice input not available on this device');
|
||||
}
|
||||
|
||||
// ignore: avoid_print
|
||||
print('[VoiceCall] Setting state to listening...');
|
||||
_updateState(VoiceCallState.listening);
|
||||
|
||||
// ignore: avoid_print
|
||||
print('[VoiceCall] Calling beginListening...');
|
||||
final stream = await _voiceInput.beginListening();
|
||||
// ignore: avoid_print
|
||||
print('[VoiceCall] Got stream from beginListening');
|
||||
|
||||
_transcriptSubscription = stream.listen(
|
||||
(text) {
|
||||
// ignore: avoid_print
|
||||
print('[VoiceCall] Transcript received: $text');
|
||||
if (_isDisposed) return;
|
||||
_accumulatedTranscript = text;
|
||||
_transcriptController.add(text);
|
||||
},
|
||||
onError: (error) {
|
||||
// ignore: avoid_print
|
||||
print('[VoiceCall] Stream error: $error');
|
||||
if (_isDisposed) return;
|
||||
_updateState(VoiceCallState.error);
|
||||
},
|
||||
onDone: () async {
|
||||
// ignore: avoid_print
|
||||
print('[VoiceCall] Stream done. Transcript: $_accumulatedTranscript');
|
||||
if (_isDisposed) return;
|
||||
// User stopped speaking, send message to assistant
|
||||
if (_accumulatedTranscript.trim().isNotEmpty) {
|
||||
await _sendMessageToAssistant(_accumulatedTranscript);
|
||||
} else {
|
||||
// No input, restart listening
|
||||
await _startListening();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// ignore: avoid_print
|
||||
print('[VoiceCall] Setting up intensity stream...');
|
||||
// Forward intensity stream for waveform visualization
|
||||
_intensitySubscription = _voiceInput.intensityStream.listen(
|
||||
(intensity) {
|
||||
if (_isDisposed) return;
|
||||
_intensityController.add(intensity);
|
||||
},
|
||||
);
|
||||
// ignore: avoid_print
|
||||
print('[VoiceCall] _startListening completed successfully');
|
||||
} catch (e) {
|
||||
// ignore: avoid_print
|
||||
print('[VoiceCall] ERROR in _startListening: $e');
|
||||
_updateState(VoiceCallState.error);
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _sendMessageToAssistant(String text) async {
|
||||
if (_isDisposed) return;
|
||||
|
||||
try {
|
||||
_updateState(VoiceCallState.processing);
|
||||
|
||||
// Send message using the existing chat infrastructure
|
||||
sendMessageFromService(_ref, text, null);
|
||||
} catch (e) {
|
||||
_updateState(VoiceCallState.error);
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
void _handleSocketEvent(
|
||||
Map<String, dynamic> event,
|
||||
void Function(dynamic response)? ack,
|
||||
) {
|
||||
if (_isDisposed) return;
|
||||
|
||||
final type = event['type']?.toString();
|
||||
final data = event['data'];
|
||||
|
||||
if (data is Map<String, dynamic>) {
|
||||
// Handle streaming response chunks
|
||||
if (type == 'message' || type == 'delta') {
|
||||
final content = data['content']?.toString() ?? '';
|
||||
if (content.isNotEmpty) {
|
||||
_responseController.add(content);
|
||||
}
|
||||
}
|
||||
|
||||
// Handle completion
|
||||
if (data['done'] == true || type == 'completion') {
|
||||
final fullResponse = data['content']?.toString() ??
|
||||
data['message']?.toString() ??
|
||||
'';
|
||||
if (fullResponse.isNotEmpty) {
|
||||
_speakResponse(fullResponse);
|
||||
} else {
|
||||
// No response, restart listening
|
||||
_startListening();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _speakResponse(String response) async {
|
||||
if (_isDisposed) return;
|
||||
|
||||
try {
|
||||
_updateState(VoiceCallState.speaking);
|
||||
await _tts.speak(response);
|
||||
// After speaking completes, _handleTtsComplete will restart listening
|
||||
} catch (e) {
|
||||
_updateState(VoiceCallState.error);
|
||||
// Restart listening even if TTS fails
|
||||
await _startListening();
|
||||
}
|
||||
}
|
||||
|
||||
void _handleTtsStart() {
|
||||
if (_isDisposed) return;
|
||||
_updateState(VoiceCallState.speaking);
|
||||
}
|
||||
|
||||
void _handleTtsComplete() {
|
||||
if (_isDisposed) return;
|
||||
// After assistant finishes speaking, start listening for user again
|
||||
_startListening();
|
||||
}
|
||||
|
||||
void _handleTtsError(String error) {
|
||||
if (_isDisposed) return;
|
||||
_updateState(VoiceCallState.error);
|
||||
// Try to recover by restarting listening
|
||||
_startListening();
|
||||
}
|
||||
|
||||
Future<void> stopCall() async {
|
||||
if (_isDisposed) return;
|
||||
|
||||
await _transcriptSubscription?.cancel();
|
||||
await _intensitySubscription?.cancel();
|
||||
_socketSubscription?.dispose();
|
||||
|
||||
await _voiceInput.stopListening();
|
||||
await _tts.stop();
|
||||
|
||||
_sessionId = null;
|
||||
_accumulatedTranscript = '';
|
||||
_updateState(VoiceCallState.disconnected);
|
||||
}
|
||||
|
||||
Future<void> pauseListening() async {
|
||||
if (_isDisposed) return;
|
||||
await _voiceInput.stopListening();
|
||||
await _transcriptSubscription?.cancel();
|
||||
await _intensitySubscription?.cancel();
|
||||
}
|
||||
|
||||
Future<void> resumeListening() async {
|
||||
if (_isDisposed) return;
|
||||
await _startListening();
|
||||
}
|
||||
|
||||
Future<void> cancelSpeaking() async {
|
||||
if (_isDisposed) return;
|
||||
await _tts.stop();
|
||||
// Immediately restart listening
|
||||
await _startListening();
|
||||
}
|
||||
|
||||
void _updateState(VoiceCallState newState) {
|
||||
if (_isDisposed) return;
|
||||
_state = newState;
|
||||
_stateController.add(newState);
|
||||
}
|
||||
|
||||
Future<void> dispose() async {
|
||||
_isDisposed = true;
|
||||
|
||||
await _transcriptSubscription?.cancel();
|
||||
await _intensitySubscription?.cancel();
|
||||
_socketSubscription?.dispose();
|
||||
|
||||
_voiceInput.dispose();
|
||||
await _tts.dispose();
|
||||
|
||||
await _stateController.close();
|
||||
await _transcriptController.close();
|
||||
await _responseController.close();
|
||||
await _intensityController.close();
|
||||
}
|
||||
}
|
||||
|
||||
@Riverpod(keepAlive: true)
|
||||
VoiceCallService voiceCallService(Ref ref) {
|
||||
final voiceInput = ref.watch(voiceInputServiceProvider);
|
||||
final tts = TextToSpeechService();
|
||||
final socketService = ref.watch(socketServiceProvider);
|
||||
|
||||
if (socketService == null) {
|
||||
throw Exception('Socket service not available');
|
||||
}
|
||||
|
||||
final service = VoiceCallService(
|
||||
voiceInput: voiceInput,
|
||||
tts: tts,
|
||||
socketService: socketService,
|
||||
ref: ref,
|
||||
);
|
||||
|
||||
ref.onDispose(() {
|
||||
service.dispose();
|
||||
});
|
||||
|
||||
return service;
|
||||
}
|
||||
@@ -24,6 +24,7 @@ import '../widgets/file_attachment_widget.dart';
|
||||
// import '../widgets/voice_input_sheet.dart'; // deprecated: replaced by inline voice input
|
||||
import '../services/voice_input_service.dart';
|
||||
import '../services/file_attachment_service.dart';
|
||||
import 'voice_call_page.dart';
|
||||
import 'package:path/path.dart' as path;
|
||||
import '../../../shared/services/tasks/task_queue.dart';
|
||||
import '../../tools/providers/tools_providers.dart';
|
||||
@@ -529,6 +530,16 @@ class _ChatPageState extends ConsumerState<ChatPage> {
|
||||
}
|
||||
}
|
||||
|
||||
void _handleVoiceCall() {
|
||||
// Navigate to voice call page
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (context) => const VoiceCallPage(),
|
||||
fullscreenDialog: true,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Replaced bottom-sheet chat list with left drawer (see ChatsDrawer)
|
||||
|
||||
void _onScroll() {
|
||||
@@ -1434,6 +1445,16 @@ class _ChatPageState extends ConsumerState<ChatPage> {
|
||||
),
|
||||
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(
|
||||
|
||||
470
lib/features/chat/views/voice_call_page.dart
Normal file
470
lib/features/chat/views/voice_call_page.dart
Normal file
@@ -0,0 +1,470 @@
|
||||
import 'dart:async';
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../../core/providers/app_providers.dart';
|
||||
import '../services/voice_call_service.dart';
|
||||
|
||||
class VoiceCallPage extends ConsumerStatefulWidget {
|
||||
const VoiceCallPage({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<VoiceCallPage> createState() => _VoiceCallPageState();
|
||||
}
|
||||
|
||||
class _VoiceCallPageState extends ConsumerState<VoiceCallPage>
|
||||
with TickerProviderStateMixin {
|
||||
VoiceCallService? _service;
|
||||
StreamSubscription<VoiceCallState>? _stateSubscription;
|
||||
StreamSubscription<String>? _transcriptSubscription;
|
||||
StreamSubscription<String>? _responseSubscription;
|
||||
StreamSubscription<int>? _intensitySubscription;
|
||||
|
||||
VoiceCallState _currentState = VoiceCallState.idle;
|
||||
String _currentTranscript = '';
|
||||
String _currentResponse = '';
|
||||
int _currentIntensity = 0;
|
||||
|
||||
late AnimationController _pulseController;
|
||||
late AnimationController _waveController;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
_pulseController = AnimationController(
|
||||
vsync: this,
|
||||
duration: const Duration(milliseconds: 1500),
|
||||
)..repeat(reverse: true);
|
||||
|
||||
_waveController = AnimationController(
|
||||
vsync: this,
|
||||
duration: const Duration(milliseconds: 2000),
|
||||
)..repeat();
|
||||
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
_initializeCall();
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _initializeCall() async {
|
||||
try {
|
||||
// ignore: avoid_print
|
||||
print('[VoiceCallPage] _initializeCall started');
|
||||
|
||||
_service = ref.read(voiceCallServiceProvider);
|
||||
// ignore: avoid_print
|
||||
print('[VoiceCallPage] Service instance: ${_service.hashCode}');
|
||||
|
||||
// Subscribe to service streams
|
||||
_stateSubscription = _service!.stateStream.listen((state) {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_currentState = state;
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
_transcriptSubscription = _service!.transcriptStream.listen((text) {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_currentTranscript = text;
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
_responseSubscription = _service!.responseStream.listen((text) {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_currentResponse = text;
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
_intensitySubscription = _service!.intensityStream.listen((intensity) {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_currentIntensity = intensity;
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Initialize and start the call
|
||||
// ignore: avoid_print
|
||||
print('[VoiceCallPage] About to initialize service');
|
||||
await _service!.initialize();
|
||||
// ignore: avoid_print
|
||||
print('[VoiceCallPage] Service initialized, reading activeConversation');
|
||||
final activeConversation = ref.read(activeConversationProvider);
|
||||
// ignore: avoid_print
|
||||
print('[VoiceCallPage] Active conversation: ${activeConversation?.id}');
|
||||
// ignore: avoid_print
|
||||
print('[VoiceCallPage] About to call startCall');
|
||||
await _service!.startCall(activeConversation?.id);
|
||||
// ignore: avoid_print
|
||||
print('[VoiceCallPage] startCall completed');
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
// Show error details in a debug-friendly way
|
||||
final errorMessage = e.toString();
|
||||
_showErrorDialog(errorMessage);
|
||||
// Also print to console for debugging
|
||||
// ignore: avoid_print
|
||||
print('[VoiceCallPage] ERROR during initialization: $errorMessage');
|
||||
// ignore: avoid_print
|
||||
print('[VoiceCallPage] Stack trace: ${StackTrace.current}');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _showErrorDialog(String message) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: const Text('Error'),
|
||||
content: Text(message),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
Navigator.of(ctx).pop();
|
||||
if (mounted) {
|
||||
Navigator.of(context).pop();
|
||||
}
|
||||
},
|
||||
child: const Text('OK'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_stateSubscription?.cancel();
|
||||
_transcriptSubscription?.cancel();
|
||||
_responseSubscription?.cancel();
|
||||
_intensitySubscription?.cancel();
|
||||
_service?.stopCall();
|
||||
_pulseController.dispose();
|
||||
_waveController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final selectedModel = ref.watch(selectedModelProvider);
|
||||
final primaryColor = Theme.of(context).colorScheme.primary;
|
||||
final backgroundColor = Theme.of(context).scaffoldBackgroundColor;
|
||||
final textColor = Theme.of(context).colorScheme.onSurface;
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: backgroundColor,
|
||||
appBar: AppBar(
|
||||
title: const Text('Voice Call'),
|
||||
leading: IconButton(
|
||||
icon: const Icon(CupertinoIcons.xmark),
|
||||
onPressed: () async {
|
||||
await _service?.stopCall();
|
||||
if (mounted) {
|
||||
Navigator.of(context).pop();
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
body: SafeArea(
|
||||
child: Column(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
// Model name
|
||||
Text(
|
||||
selectedModel?.name ?? '',
|
||||
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
|
||||
color: textColor.withOpacity(0.7),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 48),
|
||||
|
||||
// Animated waveform/status indicator
|
||||
_buildStatusIndicator(primaryColor, textColor),
|
||||
|
||||
const SizedBox(height: 48),
|
||||
|
||||
// State label
|
||||
Text(
|
||||
_getStateLabel(),
|
||||
style: Theme.of(context).textTheme.headlineMedium?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 32),
|
||||
|
||||
// Transcript or response text
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 32),
|
||||
child: _buildTextDisplay(textColor),
|
||||
),
|
||||
|
||||
// Error state help text
|
||||
if (_currentState == VoiceCallState.error)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 32,
|
||||
vertical: 16,
|
||||
),
|
||||
child: Text(
|
||||
'Please check:\n'
|
||||
'• Microphone permissions are granted\n'
|
||||
'• Speech recognition is available on your device\n'
|
||||
'• You are connected to the server',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: Theme.of(context).colorScheme.error,
|
||||
height: 1.5,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// Control buttons
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(32),
|
||||
child: _buildControlButtons(primaryColor),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildStatusIndicator(Color primaryColor, Color textColor) {
|
||||
if (_currentState == VoiceCallState.listening) {
|
||||
// Animated waveform bars
|
||||
return SizedBox(
|
||||
height: 120,
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: List.generate(5, (index) {
|
||||
return AnimatedBuilder(
|
||||
animation: _waveController,
|
||||
builder: (context, child) {
|
||||
final offset = (index * 0.2) % 1.0;
|
||||
final animation = (_waveController.value + offset) % 1.0;
|
||||
final height = 20.0 +
|
||||
(math.sin(animation * math.pi * 2) * 30.0).abs() +
|
||||
(_currentIntensity * 4.0);
|
||||
|
||||
return Container(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 4),
|
||||
width: 8,
|
||||
height: height,
|
||||
decoration: BoxDecoration(
|
||||
color: primaryColor,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}),
|
||||
),
|
||||
);
|
||||
} else if (_currentState == VoiceCallState.speaking) {
|
||||
// Pulsing circle for speaking
|
||||
return AnimatedBuilder(
|
||||
animation: _pulseController,
|
||||
builder: (context, child) {
|
||||
final scale = 1.0 + (_pulseController.value * 0.2);
|
||||
return Transform.scale(
|
||||
scale: scale,
|
||||
child: Container(
|
||||
width: 120,
|
||||
height: 120,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: primaryColor.withOpacity(0.2),
|
||||
border: Border.all(
|
||||
color: primaryColor,
|
||||
width: 3,
|
||||
),
|
||||
),
|
||||
child: Center(
|
||||
child: Icon(
|
||||
CupertinoIcons.speaker_2_fill,
|
||||
size: 48,
|
||||
color: primaryColor,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
} else if (_currentState == VoiceCallState.processing) {
|
||||
// Spinning loader for processing
|
||||
return SizedBox(
|
||||
width: 120,
|
||||
height: 120,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 3,
|
||||
valueColor: AlwaysStoppedAnimation<Color>(primaryColor),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
// Default microphone icon
|
||||
return Container(
|
||||
width: 120,
|
||||
height: 120,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: textColor.withOpacity(0.1),
|
||||
),
|
||||
child: Icon(
|
||||
CupertinoIcons.mic_fill,
|
||||
size: 48,
|
||||
color: textColor.withOpacity(0.5),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildTextDisplay(Color textColor) {
|
||||
String displayText = '';
|
||||
|
||||
if (_currentState == VoiceCallState.listening &&
|
||||
_currentTranscript.isNotEmpty) {
|
||||
displayText = _currentTranscript;
|
||||
} else if (_currentState == VoiceCallState.speaking &&
|
||||
_currentResponse.isNotEmpty) {
|
||||
displayText = _currentResponse;
|
||||
}
|
||||
|
||||
if (displayText.isEmpty) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
return Container(
|
||||
constraints: const BoxConstraints(maxHeight: 200),
|
||||
child: SingleChildScrollView(
|
||||
child: Text(
|
||||
displayText,
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
color: textColor.withOpacity(0.8),
|
||||
height: 1.5,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildControlButtons(Color primaryColor) {
|
||||
final errorColor = Theme.of(context).colorScheme.error;
|
||||
final warningColor = Colors.orange;
|
||||
|
||||
return Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||
children: [
|
||||
// Retry button (only show in error state)
|
||||
if (_currentState == VoiceCallState.error)
|
||||
_buildActionButton(
|
||||
icon: CupertinoIcons.arrow_clockwise,
|
||||
label: 'Retry',
|
||||
color: primaryColor,
|
||||
onPressed: () async {
|
||||
await _initializeCall();
|
||||
},
|
||||
),
|
||||
|
||||
// Cancel speaking button (only show when speaking)
|
||||
if (_currentState == VoiceCallState.speaking)
|
||||
_buildActionButton(
|
||||
icon: CupertinoIcons.stop_fill,
|
||||
label: 'Stop',
|
||||
color: warningColor,
|
||||
onPressed: () async {
|
||||
await _service?.cancelSpeaking();
|
||||
},
|
||||
),
|
||||
|
||||
// End call button
|
||||
_buildActionButton(
|
||||
icon: CupertinoIcons.phone_down_fill,
|
||||
label: 'End Call',
|
||||
color: errorColor,
|
||||
onPressed: () async {
|
||||
await _service?.stopCall();
|
||||
if (mounted) {
|
||||
Navigator.of(context).pop();
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildActionButton({
|
||||
required IconData icon,
|
||||
required String label,
|
||||
required Color color,
|
||||
required VoidCallback onPressed,
|
||||
}) {
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
GestureDetector(
|
||||
onTap: onPressed,
|
||||
child: Container(
|
||||
width: 64,
|
||||
height: 64,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: color,
|
||||
),
|
||||
child: Icon(
|
||||
icon,
|
||||
color: Colors.white,
|
||||
size: 32,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: color,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
String _getStateLabel() {
|
||||
switch (_currentState) {
|
||||
case VoiceCallState.idle:
|
||||
return 'Ready';
|
||||
case VoiceCallState.connecting:
|
||||
return 'Connecting...';
|
||||
case VoiceCallState.listening:
|
||||
return 'Listening';
|
||||
case VoiceCallState.processing:
|
||||
return 'Thinking...';
|
||||
case VoiceCallState.speaking:
|
||||
return 'Speaking';
|
||||
case VoiceCallState.error:
|
||||
return 'Error';
|
||||
case VoiceCallState.disconnected:
|
||||
return 'Disconnected';
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user