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,6 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
import '../models/user.dart';
|
||||
import 'api_client.dart';
|
||||
@@ -14,11 +16,15 @@ class AuthService {
|
||||
{String? fcmToken}) async {
|
||||
await _api.setBaseUrl(serverUrl);
|
||||
|
||||
final response = await _api.dio.post('/auth/login', data: {
|
||||
'username': username,
|
||||
'password': password,
|
||||
if (fcmToken != null) 'fcm_token': fcmToken,
|
||||
});
|
||||
final response = await _api.dio.post(
|
||||
'/auth/login',
|
||||
data: {
|
||||
'username': username,
|
||||
'password': password,
|
||||
if (fcmToken != null) 'fcm_token': fcmToken,
|
||||
},
|
||||
options: Options(receiveTimeout: const Duration(seconds: 60)),
|
||||
);
|
||||
|
||||
final data = response.data;
|
||||
if (data['success'] != true) {
|
||||
@@ -27,24 +33,31 @@ class AuthService {
|
||||
|
||||
await _storage.write(key: 'access_token', value: data['access_token']);
|
||||
await _storage.write(key: 'refresh_token', value: data['refresh_token']);
|
||||
await _storage.write(key: 'user_data', value: jsonEncode(data['user']));
|
||||
|
||||
_scheduleRefresh(data['expires_in'] as int? ?? 3600);
|
||||
|
||||
return User.fromJson(data['user']);
|
||||
}
|
||||
|
||||
Future<bool> tryRestoreSession() async {
|
||||
Future<User?> tryRestoreSession() async {
|
||||
final token = await _storage.read(key: 'access_token');
|
||||
if (token == null) return false;
|
||||
if (token == null) return null;
|
||||
|
||||
await _api.restoreBaseUrl();
|
||||
if (_api.dio.options.baseUrl.isEmpty) return false;
|
||||
if (_api.dio.options.baseUrl.isEmpty) return null;
|
||||
|
||||
try {
|
||||
final response = await _api.dio.get('/agent/status');
|
||||
return response.statusCode == 200;
|
||||
if (response.statusCode != 200) return null;
|
||||
|
||||
final userData = await _storage.read(key: 'user_data');
|
||||
if (userData != null) {
|
||||
return User.fromJson(jsonDecode(userData) as Map<String, dynamic>);
|
||||
}
|
||||
return null;
|
||||
} catch (_) {
|
||||
return false;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,13 +1,60 @@
|
||||
import 'dart:typed_data';
|
||||
import 'package:firebase_core/firebase_core.dart';
|
||||
import 'package:firebase_messaging/firebase_messaging.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
|
||||
import 'api_client.dart';
|
||||
|
||||
/// Notification ID for queue alerts (fixed so we can cancel it).
|
||||
const int _queueAlertNotificationId = 9001;
|
||||
|
||||
/// Background handler — must be top-level function.
|
||||
@pragma('vm:entry-point')
|
||||
Future<void> _firebaseMessagingBackgroundHandler(RemoteMessage message) async {
|
||||
await Firebase.initializeApp();
|
||||
// VoIP pushes are handled natively by twilio_voice plugin.
|
||||
// Other data messages can show a local notification if needed.
|
||||
final data = message.data;
|
||||
final type = data['type'];
|
||||
|
||||
if (type == 'queue_alert') {
|
||||
await _showQueueAlertNotification(data);
|
||||
} else if (type == 'queue_alert_cancel') {
|
||||
final plugin = FlutterLocalNotificationsPlugin();
|
||||
await plugin.cancel(_queueAlertNotificationId);
|
||||
}
|
||||
// VoIP pushes handled natively by twilio_voice plugin.
|
||||
}
|
||||
|
||||
/// Show an insistent queue alert notification (works from background handler too).
|
||||
Future<void> _showQueueAlertNotification(Map<String, dynamic> data) async {
|
||||
final plugin = FlutterLocalNotificationsPlugin();
|
||||
|
||||
final title = data['title'] ?? 'Call Waiting';
|
||||
final body = data['body'] ?? 'New call in queue';
|
||||
|
||||
final androidDetails = AndroidNotificationDetails(
|
||||
'twp_queue_alerts',
|
||||
'Queue Alerts',
|
||||
channelDescription: 'Alerts when calls are waiting in queue',
|
||||
importance: Importance.max,
|
||||
priority: Priority.max,
|
||||
playSound: true,
|
||||
sound: const RawResourceAndroidNotificationSound('queue_alert'),
|
||||
enableVibration: true,
|
||||
vibrationPattern: Int64List.fromList([0, 500, 200, 500, 200, 500]),
|
||||
ongoing: true,
|
||||
autoCancel: false,
|
||||
category: AndroidNotificationCategory.alarm,
|
||||
additionalFlags: Int32List.fromList([4]), // FLAG_INSISTENT = 4
|
||||
fullScreenIntent: true,
|
||||
visibility: NotificationVisibility.public,
|
||||
);
|
||||
|
||||
await plugin.show(
|
||||
_queueAlertNotificationId,
|
||||
title,
|
||||
body,
|
||||
NotificationDetails(android: androidDetails),
|
||||
);
|
||||
}
|
||||
|
||||
class PushNotificationService {
|
||||
@@ -15,6 +62,9 @@ class PushNotificationService {
|
||||
final FirebaseMessaging _messaging = FirebaseMessaging.instance;
|
||||
final FlutterLocalNotificationsPlugin _localNotifications =
|
||||
FlutterLocalNotificationsPlugin();
|
||||
String? _fcmToken;
|
||||
|
||||
String? get fcmToken => _fcmToken;
|
||||
|
||||
PushNotificationService(this._api);
|
||||
|
||||
@@ -36,8 +86,12 @@ class PushNotificationService {
|
||||
|
||||
// Get and register FCM token
|
||||
final token = await _messaging.getToken();
|
||||
debugPrint('FCM token: ${token != null ? "${token.substring(0, 20)}..." : "NULL"}');
|
||||
if (token != null) {
|
||||
_fcmToken = token;
|
||||
await _registerToken(token);
|
||||
} else {
|
||||
debugPrint('FCM: Failed to get token - Firebase may not be configured correctly');
|
||||
}
|
||||
|
||||
// Listen for token refresh
|
||||
@@ -60,7 +114,19 @@ class PushNotificationService {
|
||||
// VoIP incoming_call is handled by twilio_voice natively
|
||||
if (type == 'incoming_call') return;
|
||||
|
||||
// Show local notification for other types (missed call, queue alert, etc.)
|
||||
// Queue alert — show insistent notification
|
||||
if (type == 'queue_alert') {
|
||||
_showQueueAlertNotification(data);
|
||||
return;
|
||||
}
|
||||
|
||||
// Queue alert cancel — dismiss notification
|
||||
if (type == 'queue_alert_cancel') {
|
||||
_localNotifications.cancel(_queueAlertNotificationId);
|
||||
return;
|
||||
}
|
||||
|
||||
// Show local notification for other types (missed call, etc.)
|
||||
_localNotifications.show(
|
||||
message.hashCode,
|
||||
data['title'] ?? 'TWP Softphone',
|
||||
@@ -75,4 +141,9 @@ class PushNotificationService {
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Cancel any active queue alert (called when agent accepts a call in-app).
|
||||
void cancelQueueAlert() {
|
||||
_localNotifications.cancel(_queueAlertNotificationId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:math';
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
import '../config/app_config.dart';
|
||||
import 'api_client.dart';
|
||||
@@ -25,6 +26,9 @@ class SseService {
|
||||
Timer? _reconnectTimer;
|
||||
int _reconnectAttempt = 0;
|
||||
bool _shouldReconnect = true;
|
||||
int _sseFailures = 0;
|
||||
Timer? _pollTimer;
|
||||
Map<String, dynamic>? _previousPollState;
|
||||
|
||||
Stream<SseEvent> get events => _eventController.stream;
|
||||
Stream<bool> get connectionState => _connectionController.stream;
|
||||
@@ -34,34 +38,63 @@ class SseService {
|
||||
Future<void> connect() async {
|
||||
_shouldReconnect = true;
|
||||
_reconnectAttempt = 0;
|
||||
_sseFailures = 0;
|
||||
await _doConnect();
|
||||
}
|
||||
|
||||
Future<void> _doConnect() async {
|
||||
// After 2 SSE failures, fall back to polling
|
||||
if (_sseFailures >= 2) {
|
||||
debugPrint('SSE: falling back to polling after $_sseFailures failures');
|
||||
_startPolling();
|
||||
return;
|
||||
}
|
||||
|
||||
_cancelToken?.cancel();
|
||||
_cancelToken = CancelToken();
|
||||
|
||||
// Timer to detect if SSE stream never delivers data (Apache buffering)
|
||||
Timer? firstDataTimer;
|
||||
bool gotData = false;
|
||||
|
||||
try {
|
||||
final token = await _storage.read(key: 'access_token');
|
||||
debugPrint('SSE: connecting via stream (attempt ${_sseFailures + 1})');
|
||||
|
||||
firstDataTimer = Timer(const Duration(seconds: 8), () {
|
||||
if (!gotData) {
|
||||
debugPrint('SSE: no data received in 8s, cancelling');
|
||||
_cancelToken?.cancel();
|
||||
}
|
||||
});
|
||||
|
||||
final response = await _api.dio.get(
|
||||
'/stream/events',
|
||||
options: Options(
|
||||
headers: {'Authorization': 'Bearer $token'},
|
||||
responseType: ResponseType.stream,
|
||||
receiveTimeout: Duration.zero,
|
||||
),
|
||||
cancelToken: _cancelToken,
|
||||
);
|
||||
|
||||
debugPrint('SSE: connected, status=${response.statusCode}');
|
||||
_connectionController.add(true);
|
||||
_reconnectAttempt = 0;
|
||||
_sseFailures = 0;
|
||||
|
||||
final stream = response.data.stream as Stream<List<int>>;
|
||||
String buffer = '';
|
||||
|
||||
await for (final chunk in stream) {
|
||||
if (!gotData) {
|
||||
gotData = true;
|
||||
firstDataTimer.cancel();
|
||||
debugPrint('SSE: first data received');
|
||||
}
|
||||
buffer += utf8.decode(chunk);
|
||||
final lines = buffer.split('\n');
|
||||
buffer = lines.removeLast(); // keep incomplete line in buffer
|
||||
buffer = lines.removeLast();
|
||||
|
||||
String? eventName;
|
||||
String? dataStr;
|
||||
@@ -82,8 +115,22 @@ class SseService {
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
if (e is DioException && e.type == DioExceptionType.cancel) return;
|
||||
_connectionController.add(false);
|
||||
firstDataTimer?.cancel();
|
||||
// Distinguish user-initiated cancel from timeout cancel
|
||||
if (e is DioException && e.type == DioExceptionType.cancel) {
|
||||
if (!gotData && _shouldReconnect) {
|
||||
// Cancelled by our firstDataTimer — count as SSE failure
|
||||
debugPrint('SSE: stream timed out (no data), failure ${_sseFailures + 1}');
|
||||
_sseFailures++;
|
||||
_connectionController.add(false);
|
||||
} else {
|
||||
return; // User-initiated disconnect
|
||||
}
|
||||
} else {
|
||||
debugPrint('SSE: stream error: $e');
|
||||
_sseFailures++;
|
||||
_connectionController.add(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (_shouldReconnect) {
|
||||
@@ -104,9 +151,81 @@ class SseService {
|
||||
_reconnectTimer = Timer(delay, _doConnect);
|
||||
}
|
||||
|
||||
// Polling fallback when SSE streaming doesn't work
|
||||
void _startPolling() {
|
||||
_pollTimer?.cancel();
|
||||
_previousPollState = null;
|
||||
_poll();
|
||||
_pollTimer = Timer.periodic(const Duration(seconds: 5), (_) => _poll());
|
||||
}
|
||||
|
||||
Future<void> _poll() async {
|
||||
if (!_shouldReconnect) return;
|
||||
try {
|
||||
final response = await _api.dio.get('/stream/poll');
|
||||
final data = Map<String, dynamic>.from(response.data);
|
||||
_connectionController.add(true);
|
||||
|
||||
if (_previousPollState != null) {
|
||||
_diffAndEmit(_previousPollState!, data);
|
||||
}
|
||||
_previousPollState = data;
|
||||
} catch (e) {
|
||||
debugPrint('SSE poll error: $e');
|
||||
_connectionController.add(false);
|
||||
}
|
||||
}
|
||||
|
||||
void _diffAndEmit(Map<String, dynamic> prev, Map<String, dynamic> curr) {
|
||||
final prevStatus = prev['agent_status']?.toString();
|
||||
final currStatus = curr['agent_status']?.toString();
|
||||
if (prevStatus != currStatus) {
|
||||
_eventController.add(SseEvent(
|
||||
event: 'agent_status_changed',
|
||||
data: (curr['agent_status'] as Map<String, dynamic>?) ?? {},
|
||||
));
|
||||
}
|
||||
|
||||
final prevQueues = prev['queues'] as Map<String, dynamic>? ?? {};
|
||||
final currQueues = curr['queues'] as Map<String, dynamic>? ?? {};
|
||||
for (final entry in currQueues.entries) {
|
||||
final currQueue = Map<String, dynamic>.from(entry.value);
|
||||
final prevQueue = prevQueues[entry.key] as Map<String, dynamic>?;
|
||||
if (prevQueue == null) {
|
||||
_eventController.add(SseEvent(event: 'queue_added', data: currQueue));
|
||||
continue;
|
||||
}
|
||||
final currCount = currQueue['waiting_count'] as int? ?? 0;
|
||||
final prevCount = prevQueue['waiting_count'] as int? ?? 0;
|
||||
if (currCount > prevCount) {
|
||||
_eventController.add(SseEvent(event: 'call_enqueued', data: currQueue));
|
||||
} else if (currCount < prevCount) {
|
||||
_eventController.add(SseEvent(event: 'call_dequeued', data: currQueue));
|
||||
}
|
||||
}
|
||||
|
||||
final prevCall = prev['current_call']?.toString();
|
||||
final currCall = curr['current_call']?.toString();
|
||||
if (prevCall != currCall) {
|
||||
if (curr['current_call'] != null && prev['current_call'] == null) {
|
||||
_eventController.add(SseEvent(
|
||||
event: 'call_started',
|
||||
data: curr['current_call'] as Map<String, dynamic>,
|
||||
));
|
||||
} else if (curr['current_call'] == null && prev['current_call'] != null) {
|
||||
_eventController.add(SseEvent(
|
||||
event: 'call_ended',
|
||||
data: prev['current_call'] as Map<String, dynamic>,
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void disconnect() {
|
||||
_shouldReconnect = false;
|
||||
_reconnectTimer?.cancel();
|
||||
_pollTimer?.cancel();
|
||||
_pollTimer = null;
|
||||
_cancelToken?.cancel();
|
||||
_connectionController.add(false);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
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';
|
||||
@@ -7,6 +9,8 @@ class VoiceService {
|
||||
final ApiClient _api;
|
||||
Timer? _tokenRefreshTimer;
|
||||
String? _identity;
|
||||
String? _deviceToken;
|
||||
StreamSubscription? _eventSubscription;
|
||||
|
||||
final StreamController<CallEvent> _callEventController =
|
||||
StreamController<CallEvent>.broadcast();
|
||||
@@ -14,11 +18,30 @@ class VoiceService {
|
||||
|
||||
VoiceService(this._api);
|
||||
|
||||
Future<void> initialize() async {
|
||||
Future<void> 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();
|
||||
|
||||
TwilioVoice.instance.callEventsListener.listen((event) {
|
||||
_callEventController.add(event);
|
||||
// Listen for call events (only once)
|
||||
_eventSubscription ??= TwilioVoice.instance.callEventsListener.listen((event) {
|
||||
if (!_callEventController.isClosed) {
|
||||
_callEventController.add(event);
|
||||
}
|
||||
});
|
||||
|
||||
// Refresh token every 50 minutes
|
||||
@@ -35,9 +58,13 @@ class VoiceService {
|
||||
final data = response.data;
|
||||
final token = data['token'] as String;
|
||||
_identity = data['identity'] as String;
|
||||
await TwilioVoice.instance.setTokens(accessToken: token);
|
||||
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}');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -69,11 +96,14 @@ class VoiceService {
|
||||
if (callerId != null && callerId.isNotEmpty) {
|
||||
extraOptions['CallerId'] = callerId;
|
||||
}
|
||||
return await TwilioVoice.instance.call.place(
|
||||
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;
|
||||
@@ -84,6 +114,17 @@ class VoiceService {
|
||||
await TwilioVoice.instance.call.sendDigits(digits);
|
||||
}
|
||||
|
||||
Future<List<Map<String, dynamic>>> getQueueCalls(int queueId) async {
|
||||
final response = await _api.dio.get('/queues/$queueId/calls');
|
||||
return List<Map<String, dynamic>>.from(response.data['calls'] ?? []);
|
||||
}
|
||||
|
||||
Future<void> acceptQueueCall(String callSid) async {
|
||||
await _api.dio.post('/calls/$callSid/accept', data: {
|
||||
'client_identity': _identity,
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> holdCall(String callSid) async {
|
||||
await _api.dio.post('/calls/$callSid/hold');
|
||||
}
|
||||
@@ -98,6 +139,8 @@ class VoiceService {
|
||||
|
||||
void dispose() {
|
||||
_tokenRefreshTimer?.cancel();
|
||||
_eventSubscription?.cancel();
|
||||
_eventSubscription = null;
|
||||
_callEventController.close();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user