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

View File

@@ -1,11 +1,16 @@
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import 'package:provider/provider.dart';
import 'package:twilio_voice/twilio_voice.dart';
import '../models/queue_state.dart';
import '../providers/agent_provider.dart';
import '../providers/auth_provider.dart';
import '../providers/call_provider.dart';
import '../widgets/agent_status_toggle.dart';
import '../widgets/dialpad.dart';
import '../widgets/queue_card.dart';
import 'active_call_screen.dart';
import 'settings_screen.dart';
class DashboardScreen extends StatefulWidget {
@@ -16,17 +21,74 @@ class DashboardScreen extends StatefulWidget {
}
class _DashboardScreenState extends State<DashboardScreen> {
bool _phoneAccountEnabled = true; // assume true until checked
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
context.read<AgentProvider>().refresh();
_checkPhoneAccount();
});
}
Future<void> _checkPhoneAccount() async {
if (!kIsWeb && Platform.isAndroid) {
final enabled = await TwilioVoice.instance.isPhoneAccountEnabled();
if (mounted && !enabled) {
setState(() => _phoneAccountEnabled = false);
_showPhoneAccountDialog();
} else if (mounted) {
setState(() => _phoneAccountEnabled = true);
}
}
}
void _showPhoneAccountDialog() {
showDialog(
context: context,
barrierDismissible: false,
builder: (ctx) => AlertDialog(
title: const Text('Enable Phone Account'),
content: const Text(
'TWP Softphone needs to be enabled as a calling account to make and receive calls.\n\n'
'Tap "Open Settings" below, then find "TWP Softphone" in the list and toggle it ON.',
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx),
child: const Text('Later'),
),
FilledButton(
onPressed: () async {
Navigator.pop(ctx);
await TwilioVoice.instance.openPhoneAccountSettings();
// Poll until enabled or user comes back
for (int i = 0; i < 30; i++) {
await Future.delayed(const Duration(seconds: 1));
if (!mounted) return;
final enabled = await TwilioVoice.instance.isPhoneAccountEnabled();
if (enabled) {
setState(() => _phoneAccountEnabled = true);
return;
}
}
// Re-check one more time when coming back
_checkPhoneAccount();
},
child: const Text('Open Settings'),
),
],
),
);
}
void _showDialer(BuildContext context) {
final numberController = TextEditingController();
String? selectedCallerId;
final phoneNumbers = context.read<AgentProvider>().phoneNumbers;
// Auto-select first phone number as caller ID
String? selectedCallerId =
phoneNumbers.isNotEmpty ? phoneNumbers.first.phoneNumber : null;
showModalBottomSheet(
context: context,
@@ -35,7 +97,6 @@ class _DashboardScreenState extends State<DashboardScreen> {
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
),
builder: (ctx) {
final phoneNumbers = context.read<AgentProvider>().phoneNumbers;
return StatefulBuilder(
builder: (ctx, setSheetState) {
return Padding(
@@ -72,8 +133,8 @@ class _DashboardScreenState extends State<DashboardScreen> {
),
),
),
// Caller ID selector
if (phoneNumbers.isNotEmpty) ...[
// Caller ID selector (only if multiple numbers)
if (phoneNumbers.length > 1) ...[
const SizedBox(height: 12),
DropdownButtonFormField<String>(
initialValue: selectedCallerId,
@@ -82,22 +143,24 @@ class _DashboardScreenState extends State<DashboardScreen> {
isDense: true,
contentPadding: EdgeInsets.symmetric(horizontal: 12, vertical: 8),
),
items: [
const DropdownMenuItem<String>(
value: null,
child: Text('Default'),
),
...phoneNumbers.map((p) => DropdownMenuItem<String>(
value: p.phoneNumber,
child: Text('${p.friendlyName} (${p.phoneNumber})'),
)),
],
items: phoneNumbers.map((p) => DropdownMenuItem<String>(
value: p.phoneNumber,
child: Text('${p.friendlyName} (${p.phoneNumber})'),
)).toList(),
onChanged: (value) {
setSheetState(() {
selectedCallerId = value;
});
},
),
] else if (phoneNumbers.length == 1) ...[
const SizedBox(height: 8),
Text(
'Caller ID: ${phoneNumbers.first.phoneNumber}',
style: Theme.of(ctx).textTheme.bodySmall?.copyWith(
color: Theme.of(ctx).colorScheme.onSurfaceVariant,
),
),
],
const SizedBox(height: 16),
// Dialpad
@@ -125,10 +188,15 @@ class _DashboardScreenState extends State<DashboardScreen> {
label: const Text('Call'),
onPressed: () {
final number = numberController.text.trim();
if (number.isNotEmpty) {
context.read<CallProvider>().makeCall(number, callerId: selectedCallerId);
Navigator.pop(ctx);
if (number.isEmpty) return;
if (selectedCallerId == null) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('No caller ID available. Add a phone number first.')),
);
return;
}
context.read<CallProvider>().makeCall(number, callerId: selectedCallerId);
Navigator.pop(ctx);
},
),
const SizedBox(height: 16),
@@ -141,20 +209,99 @@ class _DashboardScreenState extends State<DashboardScreen> {
);
}
void _showQueueCalls(BuildContext context, QueueInfo queue) {
final voiceService = context.read<AuthProvider>().voiceService;
final callProvider = context.read<CallProvider>();
showModalBottomSheet(
context: context,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
),
builder: (ctx) {
return FutureBuilder<List<Map<String, dynamic>>>(
future: voiceService.getQueueCalls(queue.id),
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return const Padding(
padding: EdgeInsets.all(32),
child: Center(child: CircularProgressIndicator()),
);
}
if (snapshot.hasError) {
return Padding(
padding: const EdgeInsets.all(24),
child: Center(
child: Text('Error loading calls: ${snapshot.error}'),
),
);
}
final calls = (snapshot.data ?? [])
.map((c) => QueueCall.fromJson(c))
.toList();
if (calls.isEmpty) {
return const Padding(
padding: EdgeInsets.all(24),
child: Center(child: Text('No calls waiting')),
);
}
return Padding(
padding: const EdgeInsets.symmetric(vertical: 16),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Text(
'${queue.name} - Waiting Calls',
style: Theme.of(context).textTheme.titleMedium,
),
),
const SizedBox(height: 8),
...calls.map((call) => ListTile(
leading: const CircleAvatar(
child: Icon(Icons.phone_in_talk),
),
title: Text(call.fromNumber),
subtitle: Text('Waiting ${_formatWaitTime(call.waitTime)}'),
trailing: FilledButton.icon(
icon: const Icon(Icons.call, size: 18),
label: const Text('Accept'),
onPressed: () {
Navigator.pop(ctx);
callProvider.acceptQueueCall(call.callSid);
// Cancel queue alert notification
FlutterLocalNotificationsPlugin().cancel(9001);
},
),
)),
],
),
);
},
);
},
);
}
String _formatWaitTime(int seconds) {
if (seconds < 60) return '${seconds}s';
final minutes = seconds ~/ 60;
final secs = seconds % 60;
return '${minutes}m ${secs}s';
}
@override
Widget build(BuildContext context) {
final agent = context.watch<AgentProvider>();
final call = context.watch<CallProvider>();
// Navigate to active call screen when a call comes in
if (call.callInfo.isActive) {
WidgetsBinding.instance.addPostFrameCallback((_) {
Navigator.of(context).pushAndRemoveUntil(
MaterialPageRoute(builder: (_) => const ActiveCallScreen()),
(route) => route.isFirst,
);
});
}
// Android Telecom framework handles the call UI via the native InCallUI,
// so we don't navigate to our own ActiveCallScreen.
return Scaffold(
appBar: AppBar(
@@ -185,6 +332,18 @@ class _DashboardScreenState extends State<DashboardScreen> {
child: ListView(
padding: const EdgeInsets.all(16),
children: [
if (!_phoneAccountEnabled)
Card(
color: Colors.orange.shade50,
child: ListTile(
leading: Icon(Icons.warning, color: Colors.orange.shade700),
title: const Text('Phone Account Not Enabled'),
subtitle: const Text('Tap to enable calling in settings'),
trailing: const Icon(Icons.chevron_right),
onTap: () => _showPhoneAccountDialog(),
),
),
if (!_phoneAccountEnabled) const SizedBox(height: 8),
const AgentStatusToggle(),
const SizedBox(height: 24),
Text('Queues',
@@ -200,7 +359,12 @@ class _DashboardScreenState extends State<DashboardScreen> {
else
...agent.queues.map((q) => Padding(
padding: const EdgeInsets.only(bottom: 8),
child: QueueCard(queue: q),
child: QueueCard(
queue: q,
onTap: q.waitingCount > 0
? () => _showQueueCalls(context, q)
: null,
),
)),
],
),

View File

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

View File

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

View File

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

View File

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

View File

@@ -3,13 +3,15 @@ import '../models/queue_state.dart';
class QueueCard extends StatelessWidget {
final QueueInfo queue;
final VoidCallback? onTap;
const QueueCard({super.key, required this.queue});
const QueueCard({super.key, required this.queue, this.onTap});
@override
Widget build(BuildContext context) {
return Card(
child: ListTile(
onTap: onTap,
leading: CircleAvatar(
backgroundColor: queue.waitingCount > 0
? Colors.orange.shade100