All checks were successful
Create Release / build (push) Successful in 6s
Server-side: - Add push credential auto-creation for FCM incoming call notifications - Add queue alert FCM notifications (data-only for background delivery) - Add queue alert cancellation on call accept/disconnect - Fix caller ID to show caller's number instead of Twilio number - Fix FCM token storage when refresh_token is null - Add pre_call_status tracking to revert agent status 30s after call ends - Add SSE fallback polling for mobile app connectivity Mobile app: - Add Android telecom permissions and phone account registration - Add VoiceFirebaseMessagingService for incoming call push handling - Add insistent queue alert notifications with custom sound - Fix caller number display on active call screen - Add caller ID selection dropdown on dashboard - Add phone numbers endpoint and provider support - Add unit tests for CallInfo, QueueState, and CallProvider - Remove local.properties from tracking, add .gitignore Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
181 lines
5.2 KiB
Dart
181 lines
5.2 KiB
Dart
import 'dart:async';
|
|
import 'package:flutter/foundation.dart';
|
|
import 'package:twilio_voice/twilio_voice.dart';
|
|
import '../models/call_info.dart';
|
|
import '../services/voice_service.dart';
|
|
|
|
class CallProvider extends ChangeNotifier {
|
|
final VoiceService _voiceService;
|
|
CallInfo _callInfo = const CallInfo();
|
|
Timer? _durationTimer;
|
|
StreamSubscription? _eventSub;
|
|
DateTime? _connectedAt;
|
|
bool _pendingAutoAnswer = false;
|
|
|
|
CallInfo get callInfo => _callInfo;
|
|
|
|
CallProvider(this._voiceService) {
|
|
_eventSub = _voiceService.callEvents.listen(_handleCallEvent);
|
|
}
|
|
|
|
void _handleCallEvent(CallEvent event) {
|
|
switch (event) {
|
|
case CallEvent.incoming:
|
|
if (_pendingAutoAnswer) {
|
|
_pendingAutoAnswer = false;
|
|
_callInfo = _callInfo.copyWith(state: CallState.connecting);
|
|
_voiceService.answer();
|
|
} else {
|
|
_callInfo = _callInfo.copyWith(state: CallState.ringing);
|
|
}
|
|
break;
|
|
case CallEvent.ringing:
|
|
_callInfo = _callInfo.copyWith(state: CallState.connecting);
|
|
break;
|
|
case CallEvent.connected:
|
|
_connectedAt = DateTime.now();
|
|
_callInfo = _callInfo.copyWith(state: CallState.connected);
|
|
_startDurationTimer();
|
|
break;
|
|
case CallEvent.callEnded:
|
|
_stopDurationTimer();
|
|
_callInfo = const CallInfo(); // reset to idle
|
|
break;
|
|
case CallEvent.returningCall:
|
|
_callInfo = _callInfo.copyWith(state: CallState.connecting);
|
|
break;
|
|
case CallEvent.reconnecting:
|
|
break;
|
|
case CallEvent.reconnected:
|
|
break;
|
|
default:
|
|
break;
|
|
}
|
|
|
|
// Update caller info from active call (skip if call just ended)
|
|
if (_callInfo.state != CallState.idle) {
|
|
final call = TwilioVoice.instance.call;
|
|
final active = call.activeCall;
|
|
if (active != null) {
|
|
if (_callInfo.callerNumber == null) {
|
|
_callInfo = _callInfo.copyWith(
|
|
callerNumber: active.from,
|
|
);
|
|
}
|
|
// Fetch SID asynchronously
|
|
call.getSid().then((sid) {
|
|
if (sid != null && sid != _callInfo.callSid && _callInfo.isActive) {
|
|
_callInfo = _callInfo.copyWith(callSid: sid);
|
|
notifyListeners();
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
notifyListeners();
|
|
}
|
|
|
|
void _startDurationTimer() {
|
|
_durationTimer?.cancel();
|
|
_durationTimer = Timer.periodic(const Duration(seconds: 1), (_) {
|
|
if (_connectedAt != null) {
|
|
_callInfo = _callInfo.copyWith(
|
|
duration: DateTime.now().difference(_connectedAt!),
|
|
);
|
|
notifyListeners();
|
|
}
|
|
});
|
|
}
|
|
|
|
void _stopDurationTimer() {
|
|
_durationTimer?.cancel();
|
|
_connectedAt = null;
|
|
}
|
|
|
|
Future<void> answer() => _voiceService.answer();
|
|
Future<void> reject() => _voiceService.reject();
|
|
Future<void> hangUp() async {
|
|
await _voiceService.hangUp();
|
|
// If SDK didn't fire callEnded (e.g. no active SDK call), reset manually
|
|
if (_callInfo.state != CallState.idle) {
|
|
_stopDurationTimer();
|
|
_callInfo = const CallInfo();
|
|
_pendingAutoAnswer = false;
|
|
notifyListeners();
|
|
}
|
|
}
|
|
|
|
Future<void> toggleMute() async {
|
|
final newMuted = !_callInfo.isMuted;
|
|
await _voiceService.toggleMute(newMuted);
|
|
_callInfo = _callInfo.copyWith(isMuted: newMuted);
|
|
notifyListeners();
|
|
}
|
|
|
|
Future<void> toggleSpeaker() async {
|
|
final newSpeaker = !_callInfo.isSpeakerOn;
|
|
await _voiceService.toggleSpeaker(newSpeaker);
|
|
_callInfo = _callInfo.copyWith(isSpeakerOn: newSpeaker);
|
|
notifyListeners();
|
|
}
|
|
|
|
Future<void> sendDigits(String digits) => _voiceService.sendDigits(digits);
|
|
|
|
Future<void> makeCall(String number, {String? callerId}) async {
|
|
_callInfo = _callInfo.copyWith(
|
|
state: CallState.connecting,
|
|
callerNumber: number,
|
|
);
|
|
notifyListeners();
|
|
final success = await _voiceService.makeCall(number, callerId: callerId);
|
|
if (!success) {
|
|
debugPrint('CallProvider.makeCall: call.place() returned false');
|
|
_callInfo = const CallInfo(); // reset to idle
|
|
notifyListeners();
|
|
}
|
|
}
|
|
|
|
Future<void> holdCall() async {
|
|
final sid = _callInfo.callSid;
|
|
if (sid == null) return;
|
|
await _voiceService.holdCall(sid);
|
|
_callInfo = _callInfo.copyWith(isOnHold: true);
|
|
notifyListeners();
|
|
}
|
|
|
|
Future<void> unholdCall() async {
|
|
final sid = _callInfo.callSid;
|
|
if (sid == null) return;
|
|
await _voiceService.unholdCall(sid);
|
|
_callInfo = _callInfo.copyWith(isOnHold: false);
|
|
notifyListeners();
|
|
}
|
|
|
|
Future<void> transferCall(String target) async {
|
|
final sid = _callInfo.callSid;
|
|
if (sid == null) return;
|
|
await _voiceService.transferCall(sid, target);
|
|
}
|
|
|
|
Future<void> acceptQueueCall(String callSid) async {
|
|
_pendingAutoAnswer = true;
|
|
_callInfo = _callInfo.copyWith(state: CallState.connecting);
|
|
notifyListeners();
|
|
try {
|
|
await _voiceService.acceptQueueCall(callSid);
|
|
} catch (e) {
|
|
debugPrint('CallProvider.acceptQueueCall error: $e');
|
|
_pendingAutoAnswer = false;
|
|
_callInfo = const CallInfo();
|
|
notifyListeners();
|
|
}
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_stopDurationTimer();
|
|
_eventSub?.cancel();
|
|
super.dispose();
|
|
}
|
|
}
|