import 'dart:async'; import 'dart:io'; import 'package:dio/dio.dart'; import 'package:flutter/foundation.dart'; import 'package:twilio_voice/twilio_voice.dart'; import 'api_client.dart'; class VoiceService { final ApiClient _api; Timer? _tokenRefreshTimer; String? _identity; String? _deviceToken; StreamSubscription? _eventSubscription; final StreamController _callEventController = StreamController.broadcast(); Stream get callEvents => _callEventController.stream; VoiceService(this._api); Future initialize({String? deviceToken}) async { _deviceToken = deviceToken; debugPrint('VoiceService.initialize: deviceToken=${deviceToken != null ? "present (${deviceToken.length} chars)" : "NULL"}'); // Request permissions (Android telecom requires these) await TwilioVoice.instance.requestMicAccess(); if (!kIsWeb && Platform.isAndroid) { await TwilioVoice.instance.requestReadPhoneStatePermission(); await TwilioVoice.instance.requestReadPhoneNumbersPermission(); await TwilioVoice.instance.requestCallPhonePermission(); await TwilioVoice.instance.requestManageOwnCallsPermission(); // Register phone account with Android telecom // (enabling is handled by dashboard UI with a user-friendly dialog) await TwilioVoice.instance.registerPhoneAccount(); } // Fetch token and register await _fetchAndRegisterToken(); // Listen for call events (only once) _eventSubscription ??= TwilioVoice.instance.callEventsListener.listen((event) { if (!_callEventController.isClosed) { _callEventController.add(event); } }); // Refresh token every 50 minutes _tokenRefreshTimer?.cancel(); _tokenRefreshTimer = Timer.periodic( const Duration(minutes: 50), (_) => _fetchAndRegisterToken(), ); } Future _fetchAndRegisterToken() async { try { final response = await _api.dio.get('/voice/token'); final data = response.data; final token = data['token'] as String; _identity = data['identity'] as String; await TwilioVoice.instance.setTokens( accessToken: token, deviceToken: _deviceToken ?? 'no-fcm', ); } catch (e) { debugPrint('VoiceService._fetchAndRegisterToken error: $e'); if (e is DioException) debugPrint(' response: ${e.response?.data}'); } } String? get identity => _identity; Future answer() async { await TwilioVoice.instance.call.answer(); } Future reject() async { await TwilioVoice.instance.call.hangUp(); } Future hangUp() async { await TwilioVoice.instance.call.hangUp(); } Future toggleMute(bool mute) async { await TwilioVoice.instance.call.toggleMute(mute); } Future toggleSpeaker(bool speaker) async { await TwilioVoice.instance.call.toggleSpeaker(speaker); } Future makeCall(String to, {String? callerId}) async { try { final extraOptions = {}; if (callerId != null && callerId.isNotEmpty) { extraOptions['CallerId'] = callerId; } debugPrint('VoiceService.makeCall: to=$to, from=$_identity, extras=$extraOptions'); final result = await TwilioVoice.instance.call.place( to: to, from: _identity ?? '', extraOptions: extraOptions, ) ?? false; debugPrint('VoiceService.makeCall: result=$result'); return result; } catch (e) { debugPrint('VoiceService.makeCall error: $e'); return false; } } Future sendDigits(String digits) async { await TwilioVoice.instance.call.sendDigits(digits); } Future>> getQueueCalls(int queueId) async { final response = await _api.dio.get('/queues/$queueId/calls'); return List>.from(response.data['calls'] ?? []); } Future acceptQueueCall(String callSid) async { await _api.dio.post('/calls/$callSid/accept', data: { 'client_identity': _identity, }); } Future holdCall(String callSid) async { await _api.dio.post('/calls/$callSid/hold'); } Future unholdCall(String callSid) async { await _api.dio.post('/calls/$callSid/unhold'); } Future transferCall(String callSid, String target) async { await _api.dio.post('/calls/$callSid/transfer', data: {'target': target}); } void dispose() { _tokenRefreshTimer?.cancel(); _eventSubscription?.cancel(); _eventSubscription = null; _callEventController.close(); } }