Add FCM push notifications, queue alerts, caller ID fixes, and auto-revert agent status
All checks were successful
Create Release / build (push) Successful in 6s
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>
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import 'dart:async';
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import '../models/agent_status.dart';
|
||||
import '../models/queue_state.dart';
|
||||
@@ -25,6 +26,7 @@ class AgentProvider extends ChangeNotifier {
|
||||
List<PhoneNumber> _phoneNumbers = [];
|
||||
StreamSubscription? _sseSub;
|
||||
StreamSubscription? _connSub;
|
||||
Timer? _refreshTimer;
|
||||
|
||||
AgentStatus? get status => _status;
|
||||
List<QueueInfo> get queues => _queues;
|
||||
@@ -38,6 +40,11 @@ class AgentProvider extends ChangeNotifier {
|
||||
});
|
||||
|
||||
_sseSub = _sse.events.listen(_handleSseEvent);
|
||||
|
||||
_refreshTimer = Timer.periodic(
|
||||
const Duration(seconds: 15),
|
||||
(_) => fetchQueues(),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> fetchStatus() async {
|
||||
@@ -45,7 +52,10 @@ class AgentProvider extends ChangeNotifier {
|
||||
final response = await _api.dio.get('/agent/status');
|
||||
_status = AgentStatus.fromJson(response.data);
|
||||
notifyListeners();
|
||||
} catch (e) { debugPrint('AgentProvider.fetchStatus error: $e'); }
|
||||
} catch (e) {
|
||||
debugPrint('AgentProvider.fetchStatus error: $e');
|
||||
if (e is DioException) debugPrint(' response: ${e.response?.data}');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> updateStatus(AgentStatusValue newStatus) async {
|
||||
@@ -61,7 +71,12 @@ class AgentProvider extends ChangeNotifier {
|
||||
currentCallSid: _status?.currentCallSid,
|
||||
);
|
||||
notifyListeners();
|
||||
} catch (e) { debugPrint('AgentProvider.updateStatus error: $e'); }
|
||||
} catch (e) {
|
||||
debugPrint('AgentProvider.updateStatus error: $e');
|
||||
if (e is DioException) {
|
||||
debugPrint('AgentProvider.updateStatus response: ${e.response?.data}');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> fetchQueues() async {
|
||||
@@ -72,7 +87,10 @@ class AgentProvider extends ChangeNotifier {
|
||||
.map((q) => QueueInfo.fromJson(q as Map<String, dynamic>))
|
||||
.toList();
|
||||
notifyListeners();
|
||||
} catch (e) { debugPrint('AgentProvider.fetchQueues error: $e'); }
|
||||
} catch (e) {
|
||||
debugPrint('AgentProvider.fetchQueues error: $e');
|
||||
if (e is DioException) debugPrint(' response: ${e.response?.data}');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> fetchPhoneNumbers() async {
|
||||
@@ -106,6 +124,7 @@ class AgentProvider extends ChangeNotifier {
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_refreshTimer?.cancel();
|
||||
_sseSub?.cancel();
|
||||
_connSub?.cancel();
|
||||
super.dispose();
|
||||
|
||||
@@ -10,10 +10,10 @@ enum AuthState { unauthenticated, authenticating, authenticated }
|
||||
|
||||
class AuthProvider extends ChangeNotifier {
|
||||
final ApiClient _apiClient;
|
||||
late final AuthService _authService;
|
||||
late final VoiceService _voiceService;
|
||||
late final PushNotificationService _pushService;
|
||||
late final SseService _sseService;
|
||||
late AuthService _authService;
|
||||
late VoiceService _voiceService;
|
||||
late PushNotificationService _pushService;
|
||||
late SseService _sseService;
|
||||
|
||||
AuthState _state = AuthState.unauthenticated;
|
||||
User? _user;
|
||||
@@ -36,8 +36,9 @@ class AuthProvider extends ChangeNotifier {
|
||||
}
|
||||
|
||||
Future<void> tryRestoreSession() async {
|
||||
final restored = await _authService.tryRestoreSession();
|
||||
if (restored) {
|
||||
final user = await _authService.tryRestoreSession();
|
||||
if (user != null) {
|
||||
_user = user;
|
||||
_state = AuthState.authenticated;
|
||||
await _initializeServices();
|
||||
notifyListeners();
|
||||
@@ -67,7 +68,7 @@ class AuthProvider extends ChangeNotifier {
|
||||
debugPrint('AuthProvider: push service init error: $e');
|
||||
}
|
||||
try {
|
||||
await _voiceService.initialize();
|
||||
await _voiceService.initialize(deviceToken: _pushService.fcmToken);
|
||||
} catch (e) {
|
||||
debugPrint('AuthProvider: voice service init error: $e');
|
||||
}
|
||||
@@ -96,10 +97,18 @@ class AuthProvider extends ChangeNotifier {
|
||||
}
|
||||
|
||||
void _handleForceLogout() {
|
||||
_voiceService.dispose();
|
||||
_sseService.disconnect();
|
||||
|
||||
_state = AuthState.unauthenticated;
|
||||
_user = null;
|
||||
_error = 'Session expired. Please log in again.';
|
||||
_sseService.disconnect();
|
||||
|
||||
// Re-create services for potential re-login
|
||||
_voiceService = VoiceService(_apiClient);
|
||||
_pushService = PushNotificationService(_apiClient);
|
||||
_sseService = SseService(_apiClient);
|
||||
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ class CallProvider extends ChangeNotifier {
|
||||
Timer? _durationTimer;
|
||||
StreamSubscription? _eventSub;
|
||||
DateTime? _connectedAt;
|
||||
bool _pendingAutoAnswer = false;
|
||||
|
||||
CallInfo get callInfo => _callInfo;
|
||||
|
||||
@@ -20,9 +21,13 @@ class CallProvider extends ChangeNotifier {
|
||||
void _handleCallEvent(CallEvent event) {
|
||||
switch (event) {
|
||||
case CallEvent.incoming:
|
||||
_callInfo = _callInfo.copyWith(
|
||||
state: CallState.ringing,
|
||||
);
|
||||
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);
|
||||
@@ -47,20 +52,24 @@ class CallProvider extends ChangeNotifier {
|
||||
break;
|
||||
}
|
||||
|
||||
// Update caller info from active call
|
||||
final call = TwilioVoice.instance.call;
|
||||
final active = call.activeCall;
|
||||
if (active != null) {
|
||||
_callInfo = _callInfo.copyWith(
|
||||
callerNumber: active.from,
|
||||
);
|
||||
// Fetch SID asynchronously
|
||||
call.getSid().then((sid) {
|
||||
if (sid != null && sid != _callInfo.callSid) {
|
||||
_callInfo = _callInfo.copyWith(callSid: sid);
|
||||
notifyListeners();
|
||||
// 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();
|
||||
@@ -85,7 +94,16 @@ class CallProvider extends ChangeNotifier {
|
||||
|
||||
Future<void> answer() => _voiceService.answer();
|
||||
Future<void> reject() => _voiceService.reject();
|
||||
Future<void> hangUp() => _voiceService.hangUp();
|
||||
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;
|
||||
@@ -109,7 +127,12 @@ class CallProvider extends ChangeNotifier {
|
||||
callerNumber: number,
|
||||
);
|
||||
notifyListeners();
|
||||
await _voiceService.makeCall(number, callerId: callerId);
|
||||
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 {
|
||||
@@ -134,6 +157,20 @@ class CallProvider extends ChangeNotifier {
|
||||
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();
|
||||
|
||||
Reference in New Issue
Block a user