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

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:
Claude
2026-03-07 17:11:02 -08:00
parent 78e6c5a4ee
commit 4af4be94a4
26 changed files with 1829 additions and 189 deletions

View File

@@ -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();

View File

@@ -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();
}

View File

@@ -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();