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:
176
mobile/README.md
Normal file
176
mobile/README.md
Normal file
@@ -0,0 +1,176 @@
|
||||
# TWP Softphone — Mobile App
|
||||
|
||||
Flutter-based VoIP softphone client for the Twilio WordPress Plugin. Uses the Twilio Voice SDK (WebRTC) to make and receive calls via the Android Telecom framework.
|
||||
|
||||
## Requirements
|
||||
|
||||
- Flutter 3.29+ (tested with 3.41.4)
|
||||
- Android device/tablet (API 26+)
|
||||
- TWP WordPress plugin installed and configured on server
|
||||
- Twilio account with Voice capability
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
cd mobile
|
||||
flutter pub get
|
||||
flutter build apk --debug
|
||||
adb install build/app/outputs/flutter-apk/app-debug.apk
|
||||
```
|
||||
|
||||
## Server Setup
|
||||
|
||||
The app connects to your WordPress site running the TWP plugin. The server must have:
|
||||
|
||||
1. **TWP Plugin** installed and activated
|
||||
2. **Twilio credentials** configured (Account SID, Auth Token)
|
||||
3. **At least one Twilio phone number** purchased
|
||||
4. **A WordPress user** with agent permissions
|
||||
|
||||
### SSE (Server-Sent Events) — Apache + PHP-FPM
|
||||
|
||||
The app uses SSE for real-time updates (queue changes, agent status). On Apache with PHP-FPM, `mod_proxy_fcgi` buffers output by default, which breaks SSE streaming.
|
||||
|
||||
**Fix** — Create a config file on the web server:
|
||||
|
||||
```bash
|
||||
echo 'ProxyPassMatch "^/wp-json/twilio-mobile/v1/stream/events$" "unix:/run/php-fpm/www.sock|fcgi://localhost/path/to/wordpress/index.php" flushpackets=on' > /etc/httpd/conf.d/twp-sse.conf
|
||||
httpd -t && systemctl restart httpd
|
||||
```
|
||||
|
||||
> **Adjust the paths:**
|
||||
> - Socket path must match your PHP-FPM config (check `grep fcgi /etc/httpd/conf.d/php.conf`)
|
||||
> - Document root must match your WordPress installation path
|
||||
|
||||
**Diagnosis** — If the green connection dot stays red:
|
||||
|
||||
```bash
|
||||
# Check current PHP-FPM proxy config
|
||||
grep -r "fcgi\|php-fpm" /etc/httpd/conf.d/
|
||||
|
||||
# Check if flushpackets is configured
|
||||
grep -r "flushpackets" /etc/httpd/conf.d/
|
||||
|
||||
# Test SSE endpoint (should stream data continuously, not hang)
|
||||
curl -N -H "Authorization: Bearer YOUR_TOKEN" \
|
||||
https://your-site.com/wp-json/twilio-mobile/v1/stream/events
|
||||
```
|
||||
|
||||
**Notes:**
|
||||
- `flushpackets=on` is a `ProxyPassMatch` directive — it **cannot** go in `.htaccess`
|
||||
- If using **nginx** instead of Apache, the `X-Accel-Buffering: no` header (already in the PHP code) handles this automatically
|
||||
- The app automatically falls back to 5-second polling if SSE fails, so the app still works without this config — just with higher latency
|
||||
|
||||
## App Setup (Android)
|
||||
|
||||
### First Launch
|
||||
|
||||
1. Open the app and enter your server URL (e.g., `https://phone.cloud-hosting.io`)
|
||||
2. Log in with your WordPress credentials
|
||||
3. Grant permissions when prompted:
|
||||
- Microphone (required for calls)
|
||||
- Phone/Call (required for Android Telecom integration)
|
||||
|
||||
### Phone Account
|
||||
|
||||
Android requires a registered and **enabled** phone account for VoIP apps. The app registers automatically, but enabling must be done manually:
|
||||
|
||||
1. If prompted, tap **"Open Settings"** to go to Android's Phone Account settings
|
||||
2. Find **"TWP Softphone"** in the list and toggle it **ON**
|
||||
3. Return to the app
|
||||
|
||||
If you skipped this step, tap the orange warning card on the dashboard.
|
||||
|
||||
> **Path:** Settings → Apps → Default apps → Phone → Calling accounts → TWP Softphone
|
||||
|
||||
### Making Calls
|
||||
|
||||
1. Tap the phone FAB (bottom right) to open the dialer
|
||||
2. Enter the phone number
|
||||
3. Caller ID is auto-selected from your Twilio numbers
|
||||
4. Tap **Call** — the Android system call screen (InCallUI) handles the active call
|
||||
|
||||
### Receiving Calls
|
||||
|
||||
Incoming calls appear via Android's native call UI. Answer/reject using the standard Android interface.
|
||||
|
||||
> **Note:** FCM push notifications are required for receiving calls when the app is in the background. This requires `google-services.json` in `android/app/`.
|
||||
|
||||
### Queue Management
|
||||
|
||||
- View assigned queues on the dashboard
|
||||
- Tap a queue with waiting calls to see callers
|
||||
- Tap **Accept** to take a call from the queue
|
||||
|
||||
### Agent Status
|
||||
|
||||
Toggle between **Available**, **Busy**, and **Offline** using the status bar at the top of the dashboard.
|
||||
|
||||
## Development
|
||||
|
||||
### Project Structure
|
||||
|
||||
```
|
||||
lib/
|
||||
├── config/ # App configuration
|
||||
├── models/ # Data models (CallInfo, QueueState, AgentStatus, User)
|
||||
├── providers/ # State management (AuthProvider, CallProvider, AgentProvider)
|
||||
├── screens/ # UI screens (Login, Dashboard, Settings, ActiveCall)
|
||||
├── services/ # API/SDK services (VoiceService, SseService, ApiClient, AuthService)
|
||||
├── widgets/ # Reusable widgets (Dialpad, QueueCard, AgentStatusToggle)
|
||||
└── main.dart # App entry point
|
||||
```
|
||||
|
||||
### Running Tests
|
||||
|
||||
```bash
|
||||
flutter test
|
||||
```
|
||||
|
||||
34 tests covering CallInfo, QueueState, and CallProvider.
|
||||
|
||||
### Building
|
||||
|
||||
```bash
|
||||
# Debug APK
|
||||
flutter build apk --debug
|
||||
|
||||
# Release APK (requires signing config)
|
||||
flutter build apk --release
|
||||
```
|
||||
|
||||
### ADB Deployment (WiFi)
|
||||
|
||||
```bash
|
||||
# Connect to device
|
||||
adb connect DEVICE_IP:PORT
|
||||
|
||||
# Install
|
||||
adb install -r build/app/outputs/flutter-apk/app-debug.apk
|
||||
|
||||
# Launch
|
||||
adb shell am start -n io.cloudhosting.twp.twp_softphone/.MainActivity
|
||||
|
||||
# View logs
|
||||
adb logcat -s flutter
|
||||
```
|
||||
|
||||
### Key Dependencies
|
||||
|
||||
| Package | Purpose |
|
||||
|---------|---------|
|
||||
| `twilio_voice` | Twilio Voice SDK (WebRTC calling) |
|
||||
| `provider` | State management |
|
||||
| `dio` | HTTP client (REST API, SSE) |
|
||||
| `firebase_messaging` | FCM push for incoming calls |
|
||||
| `flutter_secure_storage` | Secure token storage |
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
| Problem | Solution |
|
||||
|---------|----------|
|
||||
| Green dot stays red | SSE buffering — see [Server Setup](#sse-server-sent-events--apache--php-fpm) |
|
||||
| "No registered phone account" | Enable phone account in Android Settings (see [Phone Account](#phone-account)) |
|
||||
| Calls fail with "Invalid callerId" | Server webhook needs phone number validation — check `handle_browser_voice` in `class-twp-webhooks.php` |
|
||||
| App hangs on login | Check server is reachable: `curl https://your-site.com/wp-json/twilio-mobile/v1/auth/login` |
|
||||
| No incoming calls | Ensure FCM is configured (`google-services.json`) and phone account is enabled |
|
||||
@@ -51,6 +51,15 @@
|
||||
<category android:name="android.intent.category.DEFAULT"/>
|
||||
</intent-filter>
|
||||
</activity>
|
||||
<!-- Twilio Voice FCM handler — must have higher priority than Flutter's default -->
|
||||
<service
|
||||
android:name="com.twilio.twilio_voice.fcm.VoiceFirebaseMessagingService"
|
||||
android:exported="false">
|
||||
<intent-filter android:priority="10">
|
||||
<action android:name="com.google.firebase.MESSAGING_EVENT" />
|
||||
</intent-filter>
|
||||
</service>
|
||||
|
||||
<meta-data
|
||||
android:name="flutterEmbedding"
|
||||
android:value="2" />
|
||||
|
||||
BIN
mobile/android/app/src/main/res/raw/queue_alert.ogg
Normal file
BIN
mobile/android/app/src/main/res/raw/queue_alert.ogg
Normal file
Binary file not shown.
@@ -1,2 +0,0 @@
|
||||
flutter.sdk=/opt/flutter
|
||||
sdk.dir=/opt/android-sdk
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
)),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
129
mobile/test/call_info_test.dart
Normal file
129
mobile/test/call_info_test.dart
Normal file
@@ -0,0 +1,129 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:twp_softphone/models/call_info.dart';
|
||||
|
||||
void main() {
|
||||
group('CallInfo', () {
|
||||
test('default state is idle', () {
|
||||
const info = CallInfo();
|
||||
expect(info.state, CallState.idle);
|
||||
expect(info.callSid, isNull);
|
||||
expect(info.callerNumber, isNull);
|
||||
expect(info.duration, Duration.zero);
|
||||
expect(info.isMuted, false);
|
||||
expect(info.isSpeakerOn, false);
|
||||
expect(info.isOnHold, false);
|
||||
});
|
||||
|
||||
test('isActive returns true for ringing, connecting, connected', () {
|
||||
expect(const CallInfo(state: CallState.ringing).isActive, true);
|
||||
expect(const CallInfo(state: CallState.connecting).isActive, true);
|
||||
expect(const CallInfo(state: CallState.connected).isActive, true);
|
||||
});
|
||||
|
||||
test('isActive returns false for idle and disconnected', () {
|
||||
expect(const CallInfo(state: CallState.idle).isActive, false);
|
||||
expect(const CallInfo(state: CallState.disconnected).isActive, false);
|
||||
});
|
||||
|
||||
test('copyWith preserves unmodified fields', () {
|
||||
const original = CallInfo(
|
||||
state: CallState.connected,
|
||||
callSid: 'CA123',
|
||||
callerNumber: '+1234567890',
|
||||
isMuted: true,
|
||||
);
|
||||
|
||||
final modified = original.copyWith(isSpeakerOn: true);
|
||||
expect(modified.state, CallState.connected);
|
||||
expect(modified.callSid, 'CA123');
|
||||
expect(modified.callerNumber, '+1234567890');
|
||||
expect(modified.isMuted, true);
|
||||
expect(modified.isSpeakerOn, true);
|
||||
});
|
||||
|
||||
test('copyWith can change state', () {
|
||||
const info = CallInfo(state: CallState.connecting);
|
||||
final updated = info.copyWith(state: CallState.connected);
|
||||
expect(updated.state, CallState.connected);
|
||||
});
|
||||
|
||||
test('copyWith with callerNumber preserves it', () {
|
||||
const info = CallInfo(callerNumber: '+19095737372');
|
||||
final updated = info.copyWith(state: CallState.connected);
|
||||
expect(updated.callerNumber, '+19095737372');
|
||||
});
|
||||
|
||||
test('reset to idle clears all fields', () {
|
||||
// Verify a complex state exists
|
||||
const connected = CallInfo(
|
||||
state: CallState.connected,
|
||||
callSid: 'CA123',
|
||||
callerNumber: '+1234567890',
|
||||
isMuted: true,
|
||||
isSpeakerOn: true,
|
||||
isOnHold: true,
|
||||
duration: Duration(seconds: 30),
|
||||
);
|
||||
expect(connected.isActive, true);
|
||||
|
||||
// Simulating what callEnded does
|
||||
const reset = CallInfo();
|
||||
expect(reset.state, CallState.idle);
|
||||
expect(reset.callSid, isNull);
|
||||
expect(reset.callerNumber, isNull);
|
||||
expect(reset.isActive, false);
|
||||
});
|
||||
});
|
||||
|
||||
group('CallState transitions', () {
|
||||
test('outbound call flow: idle -> connecting -> connected -> idle', () {
|
||||
var info = const CallInfo();
|
||||
expect(info.state, CallState.idle);
|
||||
|
||||
// makeCall sets connecting + callerNumber
|
||||
info = info.copyWith(state: CallState.connecting, callerNumber: '+19095737372');
|
||||
expect(info.state, CallState.connecting);
|
||||
expect(info.callerNumber, '+19095737372');
|
||||
expect(info.isActive, true);
|
||||
|
||||
// SDK fires connected
|
||||
info = info.copyWith(state: CallState.connected);
|
||||
expect(info.state, CallState.connected);
|
||||
expect(info.callerNumber, '+19095737372'); // preserved
|
||||
expect(info.isActive, true);
|
||||
|
||||
// callEnded resets
|
||||
info = const CallInfo();
|
||||
expect(info.state, CallState.idle);
|
||||
expect(info.isActive, false);
|
||||
});
|
||||
|
||||
test('inbound call flow: idle -> ringing -> connected -> idle', () {
|
||||
var info = const CallInfo();
|
||||
|
||||
info = info.copyWith(state: CallState.ringing);
|
||||
expect(info.isActive, true);
|
||||
|
||||
// callerNumber set from active.from
|
||||
info = info.copyWith(callerNumber: '+18005551234');
|
||||
expect(info.callerNumber, '+18005551234');
|
||||
|
||||
info = info.copyWith(state: CallState.connected);
|
||||
expect(info.state, CallState.connected);
|
||||
|
||||
info = const CallInfo();
|
||||
expect(info.state, CallState.idle);
|
||||
});
|
||||
|
||||
test('outbound callerNumber not overwritten by null copyWith', () {
|
||||
var info = const CallInfo(
|
||||
state: CallState.connecting,
|
||||
callerNumber: '+19095737372',
|
||||
);
|
||||
|
||||
// copyWith without callerNumber should preserve it
|
||||
info = info.copyWith(state: CallState.connected);
|
||||
expect(info.callerNumber, '+19095737372');
|
||||
});
|
||||
});
|
||||
}
|
||||
367
mobile/test/call_provider_test.dart
Normal file
367
mobile/test/call_provider_test.dart
Normal file
@@ -0,0 +1,367 @@
|
||||
import 'dart:async';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:twilio_voice/twilio_voice.dart';
|
||||
import 'package:twp_softphone/models/call_info.dart';
|
||||
import 'package:twp_softphone/providers/call_provider.dart';
|
||||
import 'package:twp_softphone/services/voice_service.dart';
|
||||
|
||||
/// Minimal mock of VoiceService for testing CallProvider logic.
|
||||
/// Only stubs methods that CallProvider calls directly.
|
||||
class MockVoiceService implements VoiceService {
|
||||
final StreamController<CallEvent> _eventController =
|
||||
StreamController<CallEvent>.broadcast();
|
||||
bool makeCallResult = true;
|
||||
bool acceptQueueCallShouldThrow = false;
|
||||
String? lastCallTo;
|
||||
String? lastCallerId;
|
||||
bool answerCalled = false;
|
||||
bool hangUpCalled = false;
|
||||
|
||||
@override
|
||||
Stream<CallEvent> get callEvents => _eventController.stream;
|
||||
|
||||
void emitEvent(CallEvent event) => _eventController.add(event);
|
||||
|
||||
@override
|
||||
Future<bool> makeCall(String to, {String? callerId}) async {
|
||||
lastCallTo = to;
|
||||
lastCallerId = callerId;
|
||||
return makeCallResult;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> answer() async {
|
||||
answerCalled = true;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> hangUp() async {
|
||||
hangUpCalled = true;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> reject() async {}
|
||||
|
||||
@override
|
||||
Future<void> toggleMute(bool mute) async {}
|
||||
|
||||
@override
|
||||
Future<void> toggleSpeaker(bool speaker) async {}
|
||||
|
||||
@override
|
||||
Future<void> sendDigits(String digits) async {}
|
||||
|
||||
@override
|
||||
Future<List<Map<String, dynamic>>> getQueueCalls(int queueId) async => [];
|
||||
|
||||
@override
|
||||
Future<void> acceptQueueCall(String callSid) async {
|
||||
if (acceptQueueCallShouldThrow) {
|
||||
throw Exception('Network error');
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> holdCall(String callSid) async {}
|
||||
|
||||
@override
|
||||
Future<void> unholdCall(String callSid) async {}
|
||||
|
||||
@override
|
||||
Future<void> transferCall(String callSid, String target) async {}
|
||||
|
||||
@override
|
||||
Future<void> initialize({String? deviceToken}) async {}
|
||||
|
||||
@override
|
||||
String? get identity => 'agent2testuser';
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_eventController.close();
|
||||
}
|
||||
|
||||
// Unused stubs required by the interface
|
||||
@override
|
||||
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
|
||||
}
|
||||
|
||||
void main() {
|
||||
group('CallProvider.makeCall', () {
|
||||
late MockVoiceService mockVoice;
|
||||
late CallProvider provider;
|
||||
|
||||
setUp(() {
|
||||
mockVoice = MockVoiceService();
|
||||
provider = CallProvider(mockVoice);
|
||||
});
|
||||
|
||||
tearDown(() {
|
||||
provider.dispose();
|
||||
mockVoice.dispose();
|
||||
});
|
||||
|
||||
test('sets state to connecting and passes number', () async {
|
||||
mockVoice.makeCallResult = true;
|
||||
await provider.makeCall('+19095737372');
|
||||
|
||||
expect(mockVoice.lastCallTo, '+19095737372');
|
||||
expect(provider.callInfo.state, CallState.connecting);
|
||||
expect(provider.callInfo.callerNumber, '+19095737372');
|
||||
});
|
||||
|
||||
test('passes callerId when provided', () async {
|
||||
mockVoice.makeCallResult = true;
|
||||
await provider.makeCall('+19095737372', callerId: '+19516215107');
|
||||
|
||||
expect(mockVoice.lastCallerId, '+19516215107');
|
||||
});
|
||||
|
||||
test('resets to idle when call.place() returns false', () async {
|
||||
mockVoice.makeCallResult = false;
|
||||
await provider.makeCall('+19095737372');
|
||||
|
||||
expect(provider.callInfo.state, CallState.idle);
|
||||
expect(provider.callInfo.callerNumber, isNull);
|
||||
});
|
||||
|
||||
test('stays connecting when call.place() returns true', () async {
|
||||
mockVoice.makeCallResult = true;
|
||||
await provider.makeCall('+19095737372');
|
||||
|
||||
expect(provider.callInfo.state, CallState.connecting);
|
||||
expect(provider.callInfo.callerNumber, '+19095737372');
|
||||
});
|
||||
});
|
||||
|
||||
group('CallProvider.hangUp', () {
|
||||
late MockVoiceService mockVoice;
|
||||
late CallProvider provider;
|
||||
|
||||
setUp(() {
|
||||
mockVoice = MockVoiceService();
|
||||
provider = CallProvider(mockVoice);
|
||||
});
|
||||
|
||||
tearDown(() {
|
||||
provider.dispose();
|
||||
mockVoice.dispose();
|
||||
});
|
||||
|
||||
test('resets to idle even if SDK does not fire callEnded', () async {
|
||||
// Simulate a connecting state
|
||||
mockVoice.makeCallResult = true;
|
||||
await provider.makeCall('+19095737372');
|
||||
expect(provider.callInfo.state, CallState.connecting);
|
||||
|
||||
// Hang up without SDK firing callEnded
|
||||
await provider.hangUp();
|
||||
|
||||
expect(provider.callInfo.state, CallState.idle);
|
||||
expect(provider.callInfo.callerNumber, isNull);
|
||||
expect(mockVoice.hangUpCalled, true);
|
||||
});
|
||||
});
|
||||
|
||||
group('CallProvider state transitions', () {
|
||||
test('outbound: connecting preserves callerNumber through state changes', () {
|
||||
// Simulating what CallProvider does internally
|
||||
var info = const CallInfo();
|
||||
|
||||
// makeCall sets connecting + callerNumber
|
||||
info = info.copyWith(state: CallState.connecting, callerNumber: '+19095737372');
|
||||
expect(info.state, CallState.connecting);
|
||||
expect(info.callerNumber, '+19095737372');
|
||||
|
||||
// SDK fires connected — callerNumber preserved
|
||||
info = info.copyWith(state: CallState.connected);
|
||||
expect(info.state, CallState.connected);
|
||||
expect(info.callerNumber, '+19095737372');
|
||||
});
|
||||
|
||||
test('makeCall failure resets cleanly to idle', () {
|
||||
var info = const CallInfo();
|
||||
|
||||
// makeCall sets connecting
|
||||
info = info.copyWith(state: CallState.connecting, callerNumber: '+19095737372');
|
||||
expect(info.state, CallState.connecting);
|
||||
|
||||
// call.place() returns false -> reset
|
||||
info = const CallInfo();
|
||||
expect(info.state, CallState.idle);
|
||||
expect(info.callerNumber, isNull);
|
||||
expect(info.isActive, false);
|
||||
});
|
||||
});
|
||||
|
||||
group('CallProvider.acceptQueueCall', () {
|
||||
late MockVoiceService mockVoice;
|
||||
late CallProvider provider;
|
||||
|
||||
setUp(() {
|
||||
mockVoice = MockVoiceService();
|
||||
provider = CallProvider(mockVoice);
|
||||
});
|
||||
|
||||
tearDown(() {
|
||||
provider.dispose();
|
||||
mockVoice.dispose();
|
||||
});
|
||||
|
||||
test('sets state to connecting before server call', () async {
|
||||
await provider.acceptQueueCall('CA123abc');
|
||||
expect(provider.callInfo.state, CallState.connecting);
|
||||
});
|
||||
|
||||
test('auto-answers incoming call after acceptQueueCall', () async {
|
||||
await provider.acceptQueueCall('CA123abc');
|
||||
|
||||
// Simulate the FCM incoming call event that arrives after server redirect
|
||||
mockVoice.emitEvent(CallEvent.incoming);
|
||||
|
||||
// Allow the stream listener to process
|
||||
await Future.delayed(Duration.zero);
|
||||
|
||||
// Should have auto-answered
|
||||
expect(mockVoice.answerCalled, true);
|
||||
expect(provider.callInfo.state, CallState.connecting);
|
||||
});
|
||||
|
||||
test('normal incoming call shows ringing without auto-answer', () async {
|
||||
// Without calling acceptQueueCall first
|
||||
mockVoice.emitEvent(CallEvent.incoming);
|
||||
|
||||
await Future.delayed(Duration.zero);
|
||||
|
||||
expect(mockVoice.answerCalled, false);
|
||||
expect(provider.callInfo.state, CallState.ringing);
|
||||
});
|
||||
|
||||
test('connected event after auto-answer sets connected state', () async {
|
||||
await provider.acceptQueueCall('CA123abc');
|
||||
|
||||
mockVoice.emitEvent(CallEvent.incoming);
|
||||
await Future.delayed(Duration.zero);
|
||||
expect(mockVoice.answerCalled, true);
|
||||
|
||||
mockVoice.emitEvent(CallEvent.connected);
|
||||
await Future.delayed(Duration.zero);
|
||||
expect(provider.callInfo.state, CallState.connected);
|
||||
});
|
||||
|
||||
test('resets to idle on API error and clears pendingAutoAnswer', () async {
|
||||
mockVoice.acceptQueueCallShouldThrow = true;
|
||||
await provider.acceptQueueCall('CA123abc');
|
||||
|
||||
// Should have reset to idle after error
|
||||
expect(provider.callInfo.state, CallState.idle);
|
||||
|
||||
// Future incoming call should NOT be auto-answered
|
||||
mockVoice.emitEvent(CallEvent.incoming);
|
||||
await Future.delayed(Duration.zero);
|
||||
expect(mockVoice.answerCalled, false);
|
||||
expect(provider.callInfo.state, CallState.ringing);
|
||||
});
|
||||
});
|
||||
|
||||
group('CallProvider.hangUp edge cases', () {
|
||||
late MockVoiceService mockVoice;
|
||||
late CallProvider provider;
|
||||
|
||||
setUp(() {
|
||||
mockVoice = MockVoiceService();
|
||||
provider = CallProvider(mockVoice);
|
||||
});
|
||||
|
||||
tearDown(() {
|
||||
provider.dispose();
|
||||
mockVoice.dispose();
|
||||
});
|
||||
|
||||
test('hangUp when already idle is a no-op', () async {
|
||||
expect(provider.callInfo.state, CallState.idle);
|
||||
await provider.hangUp();
|
||||
expect(provider.callInfo.state, CallState.idle);
|
||||
expect(mockVoice.hangUpCalled, true);
|
||||
});
|
||||
|
||||
test('hangUp clears pendingAutoAnswer flag', () async {
|
||||
await provider.acceptQueueCall('CA123abc');
|
||||
expect(provider.callInfo.state, CallState.connecting);
|
||||
|
||||
await provider.hangUp();
|
||||
expect(provider.callInfo.state, CallState.idle);
|
||||
|
||||
// Incoming call should NOT auto-answer after hangUp cleared the flag
|
||||
mockVoice.emitEvent(CallEvent.incoming);
|
||||
await Future.delayed(Duration.zero);
|
||||
expect(mockVoice.answerCalled, false);
|
||||
expect(provider.callInfo.state, CallState.ringing);
|
||||
});
|
||||
});
|
||||
|
||||
group('CallProvider.toggleMute and toggleSpeaker', () {
|
||||
late MockVoiceService mockVoice;
|
||||
late CallProvider provider;
|
||||
|
||||
setUp(() {
|
||||
mockVoice = MockVoiceService();
|
||||
provider = CallProvider(mockVoice);
|
||||
});
|
||||
|
||||
tearDown(() {
|
||||
provider.dispose();
|
||||
mockVoice.dispose();
|
||||
});
|
||||
|
||||
test('toggleMute flips isMuted state', () async {
|
||||
expect(provider.callInfo.isMuted, false);
|
||||
await provider.toggleMute();
|
||||
expect(provider.callInfo.isMuted, true);
|
||||
await provider.toggleMute();
|
||||
expect(provider.callInfo.isMuted, false);
|
||||
});
|
||||
|
||||
test('toggleSpeaker flips isSpeakerOn state', () async {
|
||||
expect(provider.callInfo.isSpeakerOn, false);
|
||||
await provider.toggleSpeaker();
|
||||
expect(provider.callInfo.isSpeakerOn, true);
|
||||
await provider.toggleSpeaker();
|
||||
expect(provider.callInfo.isSpeakerOn, false);
|
||||
});
|
||||
});
|
||||
|
||||
group('CallProvider.callEnded', () {
|
||||
late MockVoiceService mockVoice;
|
||||
late CallProvider provider;
|
||||
|
||||
setUp(() {
|
||||
mockVoice = MockVoiceService();
|
||||
provider = CallProvider(mockVoice);
|
||||
});
|
||||
|
||||
tearDown(() {
|
||||
provider.dispose();
|
||||
mockVoice.dispose();
|
||||
});
|
||||
|
||||
test('callEnded resets state completely', () async {
|
||||
// Set up a connected call
|
||||
mockVoice.makeCallResult = true;
|
||||
await provider.makeCall('+19095737372');
|
||||
|
||||
mockVoice.emitEvent(CallEvent.connected);
|
||||
await Future.delayed(Duration.zero);
|
||||
expect(provider.callInfo.state, CallState.connected);
|
||||
expect(provider.callInfo.callerNumber, '+19095737372');
|
||||
|
||||
// End the call
|
||||
mockVoice.emitEvent(CallEvent.callEnded);
|
||||
await Future.delayed(Duration.zero);
|
||||
expect(provider.callInfo.state, CallState.idle);
|
||||
expect(provider.callInfo.callerNumber, isNull);
|
||||
expect(provider.callInfo.callSid, isNull);
|
||||
expect(provider.callInfo.isActive, false);
|
||||
});
|
||||
});
|
||||
}
|
||||
92
mobile/test/queue_state_test.dart
Normal file
92
mobile/test/queue_state_test.dart
Normal file
@@ -0,0 +1,92 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:twp_softphone/models/queue_state.dart';
|
||||
|
||||
void main() {
|
||||
group('QueueInfo', () {
|
||||
test('parses from JSON with all fields', () {
|
||||
final json = {
|
||||
'id': 1,
|
||||
'name': 'General',
|
||||
'type': 'general',
|
||||
'extension': '100',
|
||||
'waiting_count': 3,
|
||||
};
|
||||
|
||||
final queue = QueueInfo.fromJson(json);
|
||||
expect(queue.id, 1);
|
||||
expect(queue.name, 'General');
|
||||
expect(queue.type, 'general');
|
||||
expect(queue.extension, '100');
|
||||
expect(queue.waitingCount, 3);
|
||||
});
|
||||
|
||||
test('parses from JSON with string numbers', () {
|
||||
final json = {
|
||||
'id': '5',
|
||||
'name': 'Support',
|
||||
'type': 'personal',
|
||||
'waiting_count': '0',
|
||||
};
|
||||
|
||||
final queue = QueueInfo.fromJson(json);
|
||||
expect(queue.id, 5);
|
||||
expect(queue.waitingCount, 0);
|
||||
expect(queue.extension, isNull);
|
||||
});
|
||||
|
||||
test('handles missing fields gracefully', () {
|
||||
final json = <String, dynamic>{};
|
||||
final queue = QueueInfo.fromJson(json);
|
||||
expect(queue.id, 0);
|
||||
expect(queue.name, '');
|
||||
expect(queue.type, '');
|
||||
expect(queue.extension, isNull);
|
||||
expect(queue.waitingCount, 0);
|
||||
});
|
||||
});
|
||||
|
||||
group('QueueCall', () {
|
||||
test('parses from JSON', () {
|
||||
final json = {
|
||||
'call_sid': 'CA123abc',
|
||||
'from_number': '+18005551234',
|
||||
'to_number': '+19095737372',
|
||||
'position': 1,
|
||||
'status': 'waiting',
|
||||
'wait_time': 45,
|
||||
};
|
||||
|
||||
final call = QueueCall.fromJson(json);
|
||||
expect(call.callSid, 'CA123abc');
|
||||
expect(call.fromNumber, '+18005551234');
|
||||
expect(call.toNumber, '+19095737372');
|
||||
expect(call.position, 1);
|
||||
expect(call.status, 'waiting');
|
||||
expect(call.waitTime, 45);
|
||||
});
|
||||
|
||||
test('handles string wait_time', () {
|
||||
final json = {
|
||||
'call_sid': 'CA456',
|
||||
'from_number': '+1800',
|
||||
'to_number': '+1900',
|
||||
'position': '2',
|
||||
'status': 'waiting',
|
||||
'wait_time': '120',
|
||||
};
|
||||
|
||||
final call = QueueCall.fromJson(json);
|
||||
expect(call.position, 2);
|
||||
expect(call.waitTime, 120);
|
||||
});
|
||||
|
||||
test('handles missing fields gracefully', () {
|
||||
final json = <String, dynamic>{};
|
||||
final call = QueueCall.fromJson(json);
|
||||
expect(call.callSid, '');
|
||||
expect(call.fromNumber, '');
|
||||
expect(call.position, 0);
|
||||
expect(call.waitTime, 0);
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user