Compare commits

...

7 Commits

Author SHA1 Message Date
Claude
5adfa694c1 Migrate FCM from legacy v1 API to HTTP v2 with service account auth
All checks were successful
Create Release / build (push) Successful in 3s
Replace deprecated FCM server key authentication with Google service
account OAuth2 flow. The class now creates a signed JWT from the
service account credentials, exchanges it for a short-lived access
token (cached via WordPress transients), and sends messages to the
FCM v2 endpoint (projects/{id}/messages:send).

Settings page updated: FCM Server Key field replaced with Firebase
Project ID + Service Account JSON textarea with validation.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 14:04:33 -08:00
Claude
826fd3ae39 Fix Android build: bump AGP to 8.7, minSdk to 26, enable desugaring
All checks were successful
Create Release / build (push) Successful in 4s
- AGP 8.1.0 -> 8.7.0 (Flutter 3.41 minimum)
- Kotlin 1.8.22 -> 2.1.0
- minSdkVersion 24 -> 26 (twilio_voice requirement)
- Enable coreLibraryDesugaring for flutter_local_notifications
- Add placeholder google-services.json for build

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 13:12:16 -08:00
Claude
5c6932f1d1 Add TWP Softphone Flutter app and complete mobile backend API
All checks were successful
Create Release / build (push) Successful in 4s
Backend: Add /voice/token endpoint with AccessToken + VoiceGrant for
mobile VoIP, implement unhold_call() with call leg detection, wire FCM
push notifications into call queue and webhook missed call handlers,
add data-only FCM message support for Android background wake, and add
Twilio API Key / Push Credential settings fields.

Flutter app: Full softphone with Twilio Voice SDK integration, JWT auth
with auto-refresh, SSE real-time queue updates, FCM push notifications,
Material 3 UI with dashboard, active call screen, dialpad, and call
controls (mute/speaker/hold/transfer).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 13:01:23 -08:00
03692608cc Speed up browser phone initialization
All checks were successful
Create Release / build (push) Successful in 3s
- Add preload hint for Twilio SDK to start loading earlier
- Add DNS prefetch and preconnect for Twilio servers
- Check SDK immediately instead of waiting 500ms
- Reduce polling interval from 100ms to 50ms

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-23 19:31:12 -08:00
b95d1dc461 Remove debug logging from browser phone
All checks were successful
Create Release / build (push) Successful in 4s
Debug code was added to diagnose mobile connection issues. The fix
(polling for SDK instead of waiting for window.load) is now working,
so removing the temporary debug output.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-23 19:27:26 -08:00
59df695530 Fix browser phone init not starting on mobile (window.load not firing)
All checks were successful
Create Release / build (push) Successful in 3s
The window.load event was never firing on mobile tablets, preventing
the browser phone from initializing. Changed to poll for Twilio SDK
availability instead of waiting for window.load event.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-23 19:12:18 -08:00
03b6e5d70f Add debug logging to browser phone for mobile troubleshooting
All checks were successful
Create Release / build (push) Successful in 4s
Adds visible debug output to track SDK loading and device registration
steps on mobile devices where the phone stays stuck on "Connecting".

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-23 19:02:03 -08:00
51 changed files with 3495 additions and 63 deletions

View File

@@ -0,0 +1,13 @@
{
"permissions": {
"allow": [
"Bash(scp:*)",
"Bash(grep:*)",
"Bash(git add:*)",
"Bash(git commit:*)",
"Bash(git push)"
],
"deny": [],
"defaultMode": "acceptEdits"
}
}

View File

@@ -7016,7 +7016,7 @@ class TWP_Admin {
<div class="phone-interface">
<div class="phone-display">
<div id="phone-status">Ready</div>
<div id="device-connection-status" style="font-size: 12px; color: #999; margin-top: 5px;">Connecting...</div>
<div id="device-connection-status" style="font-size: 12px; color: #999; margin-top: 5px;">Loading...</div>
<div id="phone-number-display"></div>
<div id="call-timer" style="display: none;">00:00</div>
</div>
@@ -7445,6 +7445,11 @@ class TWP_Admin {
}
</style>
<!-- Preload and preconnect for faster loading -->
<link rel="preload" href="https://unpkg.com/@twilio/voice-sdk@2.11.0/dist/twilio.min.js" as="script">
<link rel="dns-prefetch" href="//unpkg.com">
<link rel="dns-prefetch" href="//chunderw-vpc-gll.twilio.com">
<link rel="preconnect" href="https://chunderw-vpc-gll.twilio.com" crossorigin>
<!-- Twilio Voice SDK v2 from unpkg CDN -->
<script src="https://unpkg.com/@twilio/voice-sdk@2.11.0/dist/twilio.min.js"></script>
<script>
@@ -7807,7 +7812,7 @@ class TWP_Admin {
});
console.log('Twilio Device created with audio constraints:', audioConstraints);
// Set up event handlers BEFORE registering
// Device registered and ready
device.on('registered', function() {
@@ -7894,7 +7899,7 @@ class TWP_Admin {
// Register device AFTER setting up event handlers
await device.register();
} catch (error) {
console.error('Error setting up Twilio Device:', error);
showError('Failed to setup device: ' + error.message);
@@ -8233,16 +8238,40 @@ class TWP_Admin {
});
// Check if SDK loaded and initialize
// Poll for Twilio SDK availability (window.load may not fire on mobile)
var sdkCheckAttempts = 0;
var maxSdkCheckAttempts = 100; // 5 seconds max (100 * 50ms)
function checkAndInitialize() {
sdkCheckAttempts++;
if (typeof Twilio !== 'undefined' && Twilio.Device) {
console.log('Twilio SDK loaded successfully');
initializeBrowserPhone();
} else if (sdkCheckAttempts < maxSdkCheckAttempts) {
// Keep checking every 50ms for faster response
setTimeout(checkAndInitialize, 50);
} else {
showError('Twilio Voice SDK failed to load. Please check your internet connection and try refreshing the page.');
console.error('Twilio SDK not found after ' + sdkCheckAttempts + ' attempts.');
}
}
// Check immediately - SDK script is synchronous so should be loaded
// If not ready yet (mobile), polling will catch it
if (typeof Twilio !== 'undefined' && Twilio.Device) {
console.log('Twilio SDK already loaded');
initializeBrowserPhone();
} else {
// Start polling immediately
checkAndInitialize();
}
// Also keep the window.load as backup for desktop
$(window).on('load', function() {
setTimeout(function() {
if (typeof Twilio === 'undefined') {
showError('Twilio Voice SDK failed to load. Please check your internet connection and try refreshing the page.');
console.error('Twilio SDK not found. Script may be blocked or failed to load.');
} else {
console.log('Twilio SDK loaded successfully');
initializeBrowserPhone();
}
}, 1000);
if (typeof Twilio !== 'undefined' && !device) {
initializeBrowserPhone();
}
});
// Clean up on page unload

View File

@@ -36,19 +36,39 @@ if (isset($_POST['twp_test_notification']) && check_admin_referer('twp_mobile_se
// Save settings
if (isset($_POST['twp_save_mobile_settings']) && check_admin_referer('twp_mobile_settings')) {
update_option('twp_fcm_server_key', sanitize_text_field($_POST['twp_fcm_server_key']));
update_option('twp_fcm_project_id', sanitize_text_field($_POST['twp_fcm_project_id']));
// Service account JSON — validate it parses as JSON before saving
$sa_json_raw = isset($_POST['twp_fcm_service_account_json']) ? wp_unslash($_POST['twp_fcm_service_account_json']) : '';
if (!empty($sa_json_raw)) {
$sa_parsed = json_decode($sa_json_raw, true);
if ($sa_parsed && isset($sa_parsed['client_email'], $sa_parsed['private_key'])) {
update_option('twp_fcm_service_account_json', $sa_json_raw);
} else {
$sa_json_error = 'Invalid service account JSON — must contain client_email and private_key fields.';
}
} else {
update_option('twp_fcm_service_account_json', '');
}
update_option('twp_auto_update_enabled', isset($_POST['twp_auto_update_enabled']) ? '1' : '0');
update_option('twp_gitea_repo', sanitize_text_field($_POST['twp_gitea_repo']));
update_option('twp_gitea_token', sanitize_text_field($_POST['twp_gitea_token']));
update_option('twp_twilio_api_key_sid', sanitize_text_field($_POST['twp_twilio_api_key_sid']));
update_option('twp_twilio_api_key_secret', sanitize_text_field($_POST['twp_twilio_api_key_secret']));
update_option('twp_fcm_push_credential_sid', sanitize_text_field($_POST['twp_fcm_push_credential_sid']));
$settings_saved = true;
}
// Get current settings
$fcm_server_key = get_option('twp_fcm_server_key', '');
$fcm_project_id = get_option('twp_fcm_project_id', '');
$fcm_service_account_json = get_option('twp_fcm_service_account_json', '');
$fcm_sa_configured = !empty($fcm_service_account_json) && !empty($fcm_project_id);
$auto_update_enabled = get_option('twp_auto_update_enabled', '1') === '1';
$gitea_repo = get_option('twp_gitea_repo', 'wp-plugins/twilio-wp-plugin');
$gitea_token = get_option('twp_gitea_token', '');
$twilio_api_key_sid = get_option('twp_twilio_api_key_sid', '');
$twilio_api_key_secret = get_option('twp_twilio_api_key_secret', '');
$fcm_push_credential_sid = get_option('twp_fcm_push_credential_sid', '');
// Get update status
require_once TWP_PLUGIN_DIR . 'includes/class-twp-auto-updater.php';
@@ -84,6 +104,12 @@ $total_sessions = $wpdb->get_var("SELECT COUNT(*) FROM $sessions_table");
</div>
<?php endif; ?>
<?php if (isset($sa_json_error)): ?>
<div class="notice notice-error is-dismissible">
<p><strong><?php echo esc_html($sa_json_error); ?></strong></p>
</div>
<?php endif; ?>
<div class="twp-mobile-settings">
<!-- Mobile App Overview -->
<div class="card" style="max-width: 100%; margin-bottom: 20px;">
@@ -112,29 +138,95 @@ $total_sessions = $wpdb->get_var("SELECT COUNT(*) FROM $sessions_table");
<!-- FCM Configuration -->
<div class="card" style="max-width: 100%; margin-bottom: 20px;">
<h2>Firebase Cloud Messaging (FCM)</h2>
<p>Configure FCM to enable push notifications for the mobile app.</p>
<h2>Firebase Cloud Messaging (FCM) — HTTP v2 API</h2>
<p>Configure FCM using a service account for push notifications. The legacy server key API has been retired by Google.</p>
<table class="form-table">
<tr>
<th scope="row">
<label for="twp_fcm_server_key">FCM Server Key</label>
<label for="twp_fcm_project_id">Firebase Project ID</label>
</th>
<td>
<input type="text"
id="twp_fcm_server_key"
name="twp_fcm_server_key"
value="<?php echo esc_attr($fcm_server_key); ?>"
id="twp_fcm_project_id"
name="twp_fcm_project_id"
value="<?php echo esc_attr($fcm_project_id); ?>"
class="regular-text"
placeholder="AAAA...">
placeholder="my-project-12345">
<p class="description">
Get your server key from Firebase Console > Project Settings > Cloud Messaging > Server Key
Found in Firebase Console &gt; Project Settings &gt; General &gt; Project ID
</p>
</td>
</tr>
<tr>
<th scope="row">
<label for="twp_fcm_service_account_json">Service Account JSON</label>
</th>
<td>
<textarea id="twp_fcm_service_account_json"
name="twp_fcm_service_account_json"
rows="6"
class="large-text code"
placeholder='Paste the entire contents of your service account JSON file...'><?php echo esc_textarea($fcm_service_account_json); ?></textarea>
<p class="description">
Generate in Firebase Console &gt; Project Settings &gt; Service Accounts &gt; Generate New Private Key.
Paste the entire JSON file contents here. Must contain <code>client_email</code> and <code>private_key</code> fields.
</p>
<?php if ($fcm_sa_configured): ?>
<p style="color: #00a32a; margin-top: 5px;">&#10003; Service account configured</p>
<?php endif; ?>
</td>
</tr>
<tr>
<th scope="row">
<label for="twp_twilio_api_key_sid">Twilio API Key SID</label>
</th>
<td>
<input type="text"
id="twp_twilio_api_key_sid"
name="twp_twilio_api_key_sid"
value="<?php echo esc_attr($twilio_api_key_sid); ?>"
class="regular-text"
placeholder="SK...">
<p class="description">
Create an API Key in Twilio Console &gt; Account &gt; API Keys. Required for mobile VoIP tokens.
</p>
</td>
</tr>
<tr>
<th scope="row">
<label for="twp_twilio_api_key_secret">Twilio API Key Secret</label>
</th>
<td>
<input type="password"
id="twp_twilio_api_key_secret"
name="twp_twilio_api_key_secret"
value="<?php echo esc_attr($twilio_api_key_secret); ?>"
class="regular-text">
<p class="description">
The secret associated with the API Key SID above. Shown only once when key is created.
</p>
</td>
</tr>
<tr>
<th scope="row">
<label for="twp_fcm_push_credential_sid">Push Credential SID</label>
</th>
<td>
<input type="text"
id="twp_fcm_push_credential_sid"
name="twp_fcm_push_credential_sid"
value="<?php echo esc_attr($fcm_push_credential_sid); ?>"
class="regular-text"
placeholder="CR...">
<p class="description">
Twilio Push Credential SID. Create in Twilio Console &gt; Messaging &gt; Push Credentials using your FCM service account JSON. Required for incoming call push notifications.
</p>
</td>
</tr>
</table>
<?php if (!empty($fcm_server_key)): ?>
<?php if ($fcm_sa_configured): ?>
<p>
<button type="submit" name="twp_test_notification" class="button">
Send Test Notification
@@ -273,6 +365,11 @@ $total_sessions = $wpdb->get_var("SELECT COUNT(*) FROM $sessions_table");
<td>GET</td>
<td>Server-Sent Events stream for real-time updates</td>
</tr>
<tr>
<td><code>/twilio-mobile/v1/voice/token</code></td>
<td>GET</td>
<td>Get Twilio Voice access token for VoIP</td>
</tr>
</tbody>
</table>

View File

@@ -617,7 +617,14 @@ class TWP_Call_Queue {
// Get members of the assigned agent group
require_once dirname(__FILE__) . '/class-twp-agent-groups.php';
$members = TWP_Agent_Groups::get_group_members($queue->agent_group_id);
// Send FCM push notifications to agents' mobile devices
require_once dirname(__FILE__) . '/class-twp-fcm.php';
$fcm = new TWP_FCM();
foreach ($members as $member) {
$fcm->notify_incoming_call($member->user_id, $caller_number, $queue->queue_name, '');
}
if (empty($members)) {
error_log("TWP: No members found in agent group {$queue->agent_group_id} for queue {$queue_id}");
return;

View File

@@ -1,27 +1,33 @@
<?php
/**
* Firebase Cloud Messaging (FCM) Integration
* Firebase Cloud Messaging (FCM) Integration — HTTP v2 API
*
* Handles push notifications to mobile devices via FCM
* Handles push notifications to mobile devices via FCM using
* service account credentials and OAuth2 access tokens.
*/
class TWP_FCM {
private $server_key;
private $fcm_url = 'https://fcm.googleapis.com/fcm/send';
private $project_id;
private $service_account;
private $fcm_url_template = 'https://fcm.googleapis.com/v1/projects/%s/messages:send';
/**
* Constructor
*/
public function __construct() {
$this->server_key = get_option('twp_fcm_server_key', '');
$this->project_id = get_option('twp_fcm_project_id', '');
$sa_json = get_option('twp_fcm_service_account_json', '');
if (!empty($sa_json)) {
$this->service_account = json_decode($sa_json, true);
}
}
/**
* Send push notification to user's devices
*/
public function send_notification($user_id, $title, $body, $data = array()) {
if (empty($this->server_key)) {
error_log('TWP FCM: Server key not configured');
public function send_notification($user_id, $title, $body, $data = array(), $data_only = false) {
if (empty($this->project_id) || empty($this->service_account)) {
error_log('TWP FCM: Project ID or service account not configured');
return false;
}
@@ -37,7 +43,7 @@ class TWP_FCM {
$failed_tokens = array();
foreach ($tokens as $token) {
$result = $this->send_to_token($token, $title, $body, $data);
$result = $this->send_to_token($token, $title, $body, $data, $data_only);
if ($result['success']) {
$success_count++;
@@ -57,35 +63,54 @@ class TWP_FCM {
}
/**
* Send notification to specific token
* Send notification to specific token via FCM HTTP v2 API
*/
private function send_to_token($token, $title, $body, $data = array()) {
$notification = array(
'title' => $title,
'body' => $body,
'sound' => 'default',
'priority' => 'high',
'click_action' => 'FLUTTER_NOTIFICATION_CLICK'
private function send_to_token($token, $title, $body, $data = array(), $data_only = false) {
$access_token = $this->get_access_token();
if (!$access_token) {
return array('success' => false, 'error' => 'auth_failed');
}
// FCM v2 requires all data values to be strings
$string_data = array();
foreach ($data as $key => $value) {
$string_data[$key] = is_string($value) ? $value : (string)$value;
}
$string_data['title'] = $title;
$string_data['body'] = $body;
$string_data['timestamp'] = (string)time();
// Build the v2 message payload
$message = array(
'token' => $token,
'data' => $string_data,
'android' => array(
'priority' => 'high',
),
);
$payload = array(
'to' => $token,
'notification' => $notification,
'data' => array_merge($data, array(
if (!$data_only) {
$message['notification'] = array(
'title' => $title,
'body' => $body,
'timestamp' => time()
)),
'priority' => 'high'
);
);
$message['android']['notification'] = array(
'sound' => 'default',
'click_action' => 'FLUTTER_NOTIFICATION_CLICK',
);
}
$payload = array('message' => $message);
$url = sprintf($this->fcm_url_template, $this->project_id);
$headers = array(
'Authorization: key=' . $this->server_key,
'Authorization: Bearer ' . $access_token,
'Content-Type: application/json'
);
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $this->fcm_url);
curl_setopt($ch, CURLOPT_URL, $url);
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
@@ -99,10 +124,14 @@ class TWP_FCM {
if ($http_code !== 200) {
error_log("TWP FCM: Failed to send notification. HTTP $http_code: $response");
// Check if token is invalid
$response_data = json_decode($response, true);
if (isset($response_data['results'][0]['error']) &&
in_array($response_data['results'][0]['error'], array('InvalidRegistration', 'NotRegistered'))) {
$error_code = isset($response_data['error']['details'][0]['errorCode'])
? $response_data['error']['details'][0]['errorCode'] : '';
$error_status = isset($response_data['error']['status'])
? $response_data['error']['status'] : '';
if (in_array($error_code, array('UNREGISTERED', 'INVALID_ARGUMENT')) ||
$error_status === 'NOT_FOUND') {
return array('success' => false, 'error' => 'invalid_token');
}
@@ -112,6 +141,107 @@ class TWP_FCM {
return array('success' => true);
}
/**
* Get OAuth2 access token from service account credentials.
* Caches the token in a transient until near expiry.
*/
private function get_access_token() {
$cached = get_transient('twp_fcm_access_token');
if ($cached) {
return $cached;
}
if (empty($this->service_account)) {
error_log('TWP FCM: Service account not configured');
return false;
}
$jwt = $this->create_jwt();
if (!$jwt) {
return false;
}
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $this->service_account['token_uri']);
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query(array(
'grant_type' => 'urn:ietf:params:oauth:grant-type:jwt-bearer',
'assertion' => $jwt,
)));
$response = curl_exec($ch);
$http_code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($http_code !== 200) {
error_log("TWP FCM: Failed to get access token. HTTP $http_code: $response");
return false;
}
$token_data = json_decode($response, true);
$access_token = $token_data['access_token'];
$expires_in = isset($token_data['expires_in']) ? (int)$token_data['expires_in'] : 3600;
// Cache token for 5 minutes less than actual expiry
set_transient('twp_fcm_access_token', $access_token, max(60, $expires_in - 300));
return $access_token;
}
/**
* Create a signed JWT for the service account OAuth2 flow
*/
private function create_jwt() {
$sa = $this->service_account;
if (empty($sa['client_email']) || empty($sa['private_key']) || empty($sa['token_uri'])) {
error_log('TWP FCM: Service account JSON missing required fields');
return false;
}
$now = time();
$header = array('alg' => 'RS256', 'typ' => 'JWT');
$claims = array(
'iss' => $sa['client_email'],
'scope' => 'https://www.googleapis.com/auth/firebase.messaging',
'aud' => $sa['token_uri'],
'iat' => $now,
'exp' => $now + 3600,
);
$segments = array(
$this->base64url_encode(json_encode($header)),
$this->base64url_encode(json_encode($claims)),
);
$signing_input = implode('.', $segments);
$private_key = openssl_pkey_get_private($sa['private_key']);
if (!$private_key) {
error_log('TWP FCM: Failed to parse service account private key');
return false;
}
$signature = '';
if (!openssl_sign($signing_input, $signature, $private_key, OPENSSL_ALGO_SHA256)) {
error_log('TWP FCM: Failed to sign JWT');
return false;
}
$segments[] = $this->base64url_encode($signature);
return implode('.', $segments);
}
/**
* Base64url encode (RFC 4648)
*/
private function base64url_encode($data) {
return rtrim(strtr(base64_encode($data), '+/', '-_'), '=');
}
/**
* Get all active FCM tokens for a user
*/
@@ -162,7 +292,7 @@ class TWP_FCM {
'queue_name' => $queue_name
);
return $this->send_notification($user_id, $title, $body, $data);
return $this->send_notification($user_id, $title, $body, $data, true);
}
/**
@@ -206,7 +336,7 @@ class TWP_FCM {
$data = array(
'type' => 'test',
'test' => true
'test' => 'true'
);
return $this->send_notification($user_id, $title, $body, $data);

View File

@@ -99,6 +99,13 @@ class TWP_Mobile_API {
'callback' => array($this, 'update_agent_phone'),
'permission_callback' => array($this->auth, 'verify_token')
));
// Voice token for VoIP
register_rest_route('twilio-mobile/v1', '/voice/token', array(
'methods' => 'GET',
'callback' => array($this, 'get_voice_token'),
'permission_callback' => array($this->auth, 'verify_token')
));
});
}
@@ -507,11 +514,46 @@ class TWP_Mobile_API {
* Unhold a call (resume from hold queue)
*/
public function unhold_call($request) {
// Implementation would retrieve from hold queue and reconnect
return new WP_REST_Response(array(
'success' => true,
'message' => 'Unhold functionality - to be implemented with queue retrieval'
), 501);
$user_id = $this->auth->get_current_user_id();
$call_sid = $request['call_sid'];
try {
require_once plugin_dir_path(dirname(__FILE__)) . 'includes/class-twp-admin.php';
require_once plugin_dir_path(dirname(__FILE__)) . 'includes/class-twp-twilio-api.php';
$admin = new TWP_Admin('twilio-wp-plugin', TWP_VERSION);
$twilio = new TWP_Twilio_API();
// Find customer call leg
$customer_call_sid = $admin->find_customer_call_leg($call_sid, $twilio);
if (!$customer_call_sid) {
return new WP_Error('call_not_found', 'Could not find customer call leg', array('status' => 404));
}
// Build identity for this agent
$user = get_userdata($user_id);
$clean_name = preg_replace('/[^a-zA-Z0-9]/', '', $user->user_login);
if (empty($clean_name)) {
$clean_name = 'user';
}
$identity = 'agent' . $user_id . $clean_name;
// Redirect customer back to agent's client
$twiml = new \Twilio\TwiML\VoiceResponse();
$dial = $twiml->dial();
$dial->client($identity);
$twilio->update_call($customer_call_sid, array('twiml' => $twiml->asXML()));
return new WP_REST_Response(array(
'success' => true,
'message' => 'Call resumed from hold'
), 200);
} catch (Exception $e) {
return new WP_Error('unhold_error', $e->getMessage(), array('status' => 500));
}
}
/**
@@ -641,6 +683,55 @@ class TWP_Mobile_API {
), 200);
}
/**
* Get Voice access token for VoIP
*/
public function get_voice_token($request) {
$user_id = $this->auth->get_current_user_id();
$user = get_userdata($user_id);
$identity = 'agent' . $user_id . preg_replace('/[^a-zA-Z0-9]/', '', $user->user_login);
$account_sid = get_option('twp_twilio_account_sid');
$auth_token = get_option('twp_twilio_auth_token');
$api_key_sid = get_option('twp_twilio_api_key_sid');
$api_key_secret = get_option('twp_twilio_api_key_secret');
$twiml_app_sid = get_option('twp_twiml_app_sid');
$push_credential_sid = get_option('twp_fcm_push_credential_sid');
if (empty($api_key_sid) || empty($api_key_secret)) {
return new WP_Error('missing_api_key', 'Twilio API Key SID and Secret must be configured', array('status' => 500));
}
try {
$token = new \Twilio\Jwt\AccessToken(
$account_sid,
$api_key_sid,
$api_key_secret,
3600,
$identity
);
$voiceGrant = new \Twilio\Jwt\Grants\VoiceGrant();
$voiceGrant->setOutgoingApplicationSid($twiml_app_sid);
$voiceGrant->setIncomingAllow(true);
if (!empty($push_credential_sid)) {
$voiceGrant->setPushCredentialSid($push_credential_sid);
}
$token->addGrant($voiceGrant);
return new WP_REST_Response(array(
'token' => $token->toJWT(),
'identity' => $identity,
'expires_in' => 3600
), 200);
} catch (Exception $e) {
return new WP_Error('token_error', $e->getMessage(), array('status' => 500));
}
}
/**
* Check if user has access to a queue
*/

View File

@@ -477,8 +477,15 @@ class TWP_Webhooks {
$twilio_api->send_sms($agent_phone, $message, $twilio_number);
}
}
// Send FCM push notifications for missed browser call
require_once dirname(__FILE__) . '/class-twp-fcm.php';
$fcm = new TWP_FCM();
foreach ($agents as $agent) {
$fcm->notify_incoming_call($agent->ID, $customer_number, 'Browser Phone', '');
}
}
/**
* Handle smart routing based on user preferences
*/
@@ -704,8 +711,15 @@ class TWP_Webhooks {
$twilio_api->send_sms($agent_phone, $message, $twilio_number);
}
}
// Send FCM push notifications for missed call
require_once dirname(__FILE__) . '/class-twp-fcm.php';
$fcm = new TWP_FCM();
foreach ($agents as $agent) {
$fcm->notify_incoming_call($agent->ID, $customer_number, 'General', '');
}
}
/**
* Verify Twilio signature
*/

45
mobile/.gitignore vendored Normal file
View File

@@ -0,0 +1,45 @@
# Miscellaneous
*.class
*.log
*.pyc
*.swp
.DS_Store
.atom/
.build/
.buildlog/
.history
.svn/
.swiftpm/
migrate_working_dir/
# IntelliJ related
*.iml
*.ipr
*.iws
.idea/
# The .vscode folder contains launch configuration and tasks you configure in
# VS Code which you may wish to be included in version control, so this line
# is commented out by default.
#.vscode/
# Flutter/Dart/Pub related
**/doc/api/
**/ios/Flutter/.last_build_id
.dart_tool/
.flutter-plugins-dependencies
.pub-cache/
.pub/
/build/
/coverage/
# Symbolication related
app.*.symbols
# Obfuscation related
app.*.map.json
# Android Studio will place build artifacts here
/android/app/debug
/android/app/profile
/android/app/release

45
mobile/.metadata Normal file
View File

@@ -0,0 +1,45 @@
# This file tracks properties of this Flutter project.
# Used by Flutter tool to assess capabilities and perform upgrades etc.
#
# This file should be version controlled and should not be manually edited.
version:
revision: "ff37bef603469fb030f2b72995ab929ccfc227f0"
channel: "stable"
project_type: app
# Tracks metadata for the flutter migrate command
migration:
platforms:
- platform: root
create_revision: ff37bef603469fb030f2b72995ab929ccfc227f0
base_revision: ff37bef603469fb030f2b72995ab929ccfc227f0
- platform: android
create_revision: ff37bef603469fb030f2b72995ab929ccfc227f0
base_revision: ff37bef603469fb030f2b72995ab929ccfc227f0
- platform: ios
create_revision: ff37bef603469fb030f2b72995ab929ccfc227f0
base_revision: ff37bef603469fb030f2b72995ab929ccfc227f0
- platform: linux
create_revision: ff37bef603469fb030f2b72995ab929ccfc227f0
base_revision: ff37bef603469fb030f2b72995ab929ccfc227f0
- platform: macos
create_revision: ff37bef603469fb030f2b72995ab929ccfc227f0
base_revision: ff37bef603469fb030f2b72995ab929ccfc227f0
- platform: web
create_revision: ff37bef603469fb030f2b72995ab929ccfc227f0
base_revision: ff37bef603469fb030f2b72995ab929ccfc227f0
- platform: windows
create_revision: ff37bef603469fb030f2b72995ab929ccfc227f0
base_revision: ff37bef603469fb030f2b72995ab929ccfc227f0
# User provided section
# List of Local paths (relative to this file) that should be
# ignored by the migrate tool.
#
# Files that are not part of the templates will be ignored by default.
unmanaged_files:
- 'lib/main.dart'
- 'ios/Runner.xcodeproj/project.pbxproj'

View File

@@ -0,0 +1,7 @@
include: package:flutter_lints/flutter.yaml
linter:
rules:
prefer_const_constructors: true
prefer_const_declarations: true
avoid_print: true

View File

@@ -0,0 +1,49 @@
plugins {
id "com.android.application"
id "kotlin-android"
id "dev.flutter.flutter-gradle-plugin"
id "com.google.gms.google-services"
}
android {
namespace = "io.cloudhosting.twp.twp_softphone"
compileSdk = flutter.compileSdkVersion
ndkVersion = flutter.ndkVersion
compileOptions {
coreLibraryDesugaringEnabled true
sourceCompatibility = JavaVersion.VERSION_11
targetCompatibility = JavaVersion.VERSION_11
}
kotlinOptions {
jvmTarget = JavaVersion.VERSION_11
}
defaultConfig {
applicationId = "io.cloudhosting.twp.twp_softphone"
minSdkVersion 26
targetSdk = flutter.targetSdkVersion
versionCode = flutter.versionCode
versionName = flutter.versionName
multiDexEnabled true
}
buildTypes {
release {
signingConfig = signingConfigs.debug
minifyEnabled true
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
}
}
}
flutter {
source = "../.."
}
dependencies {
coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:2.1.4'
implementation platform('com.google.firebase:firebase-bom:33.0.0')
implementation 'com.google.firebase:firebase-messaging'
}

View File

@@ -0,0 +1,29 @@
{
"project_info": {
"project_number": "000000000000",
"project_id": "twp-softphone-placeholder",
"storage_bucket": "twp-softphone-placeholder.appspot.com"
},
"client": [
{
"client_info": {
"mobilesdk_app_id": "1:000000000000:android:0000000000000000",
"android_client_info": {
"package_name": "io.cloudhosting.twp.twp_softphone"
}
},
"oauth_client": [],
"api_key": [
{
"current_key": "PLACEHOLDER_API_KEY"
}
],
"services": {
"appinvite_service": {
"other_platform_oauth_client": []
}
}
}
],
"configuration_version": "1"
}

9
mobile/android/app/proguard-rules.pro vendored Normal file
View File

@@ -0,0 +1,9 @@
# Twilio Voice SDK
-keep class com.twilio.** { *; }
-keep class tvo.webrtc.** { *; }
# Firebase
-keep class com.google.firebase.** { *; }
# Flutter
-keep class io.flutter.** { *; }

View File

@@ -0,0 +1,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- The INTERNET permission is required for development. Specifically,
the Flutter tool needs it to communicate with the running application
to allow setting breakpoints, to provide hot reload, etc.
-->
<uses-permission android:name="android.permission.INTERNET"/>
</manifest>

View File

@@ -0,0 +1,65 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- Audio/Phone permissions for VoIP -->
<uses-permission android:name="android.permission.RECORD_AUDIO"/>
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS"/>
<uses-permission android:name="android.permission.BLUETOOTH"/>
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT"/>
<!-- Foreground service for active calls -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_PHONE_CALL"/>
<!-- Full screen intent for incoming calls on lock screen -->
<uses-permission android:name="android.permission.USE_FULL_SCREEN_INTENT"/>
<uses-permission android:name="android.permission.WAKE_LOCK"/>
<uses-permission android:name="android.permission.MANAGE_OWN_CALLS"/>
<!-- Push notifications -->
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
<!-- Internet -->
<uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/>
<application
android:label="TWP Softphone"
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher">
<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTop"
android:taskAffinity=""
android:theme="@style/LaunchTheme"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:hardwareAccelerated="true"
android:windowSoftInputMode="adjustResize"
android:showOnLockScreen="true"
android:turnScreenOn="true">
<meta-data
android:name="io.flutter.embedding.android.NormalTheme"
android:resource="@style/NormalTheme"
/>
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
<!-- FCM click action -->
<intent-filter>
<action android:name="FLUTTER_NOTIFICATION_CLICK"/>
<category android:name="android.intent.category.DEFAULT"/>
</intent-filter>
</activity>
<meta-data
android:name="flutterEmbedding"
android:value="2" />
</application>
<queries>
<intent>
<action android:name="android.intent.action.PROCESS_TEXT"/>
<data android:mimeType="text/plain"/>
</intent>
</queries>
</manifest>

View File

@@ -0,0 +1,5 @@
package io.cloudhosting.twp.twp_softphone
import io.flutter.embedding.android.FlutterActivity
class MainActivity: FlutterActivity()

View File

@@ -0,0 +1,49 @@
package io.flutter.plugins;
import androidx.annotation.Keep;
import androidx.annotation.NonNull;
import io.flutter.Log;
import io.flutter.embedding.engine.FlutterEngine;
/**
* Generated file. Do not edit.
* This file is generated by the Flutter tool based on the
* plugins that support the Android platform.
*/
@Keep
public final class GeneratedPluginRegistrant {
private static final String TAG = "GeneratedPluginRegistrant";
public static void registerWith(@NonNull FlutterEngine flutterEngine) {
try {
flutterEngine.getPlugins().add(new io.flutter.plugins.firebase.core.FlutterFirebaseCorePlugin());
} catch (Exception e) {
Log.e(TAG, "Error registering plugin firebase_core, io.flutter.plugins.firebase.core.FlutterFirebaseCorePlugin", e);
}
try {
flutterEngine.getPlugins().add(new io.flutter.plugins.firebase.messaging.FlutterFirebaseMessagingPlugin());
} catch (Exception e) {
Log.e(TAG, "Error registering plugin firebase_messaging, io.flutter.plugins.firebase.messaging.FlutterFirebaseMessagingPlugin", e);
}
try {
flutterEngine.getPlugins().add(new com.dexterous.flutterlocalnotifications.FlutterLocalNotificationsPlugin());
} catch (Exception e) {
Log.e(TAG, "Error registering plugin flutter_local_notifications, com.dexterous.flutterlocalnotifications.FlutterLocalNotificationsPlugin", e);
}
try {
flutterEngine.getPlugins().add(new com.it_nomads.fluttersecurestorage.FlutterSecureStoragePlugin());
} catch (Exception e) {
Log.e(TAG, "Error registering plugin flutter_secure_storage, com.it_nomads.fluttersecurestorage.FlutterSecureStoragePlugin", e);
}
try {
flutterEngine.getPlugins().add(new io.flutter.plugins.pathprovider.PathProviderPlugin());
} catch (Exception e) {
Log.e(TAG, "Error registering plugin path_provider_android, io.flutter.plugins.pathprovider.PathProviderPlugin", e);
}
try {
flutterEngine.getPlugins().add(new com.twilio.twilio_voice.TwilioVoicePlugin());
} catch (Exception e) {
Log.e(TAG, "Error registering plugin twilio_voice, com.twilio.twilio_voice.TwilioVoicePlugin", e);
}
}
}

View File

@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="LaunchTheme" parent="@android:style/Theme.Light.NoTitleBar">
<item name="android:windowBackground">@android:color/white</item>
</style>
<style name="NormalTheme" parent="@android:style/Theme.Light.NoTitleBar">
<item name="android:windowBackground">@android:color/white</item>
</style>
</resources>

View File

@@ -0,0 +1,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- The INTERNET permission is required for development. Specifically,
the Flutter tool needs it to communicate with the running application
to allow setting breakpoints, to provide hot reload, etc.
-->
<uses-permission android:name="android.permission.INTERNET"/>
</manifest>

View File

@@ -0,0 +1,18 @@
allprojects {
repositories {
google()
mavenCentral()
}
}
rootProject.buildDir = "../build"
subprojects {
project.buildDir = "${rootProject.buildDir}/${project.name}"
}
subprojects {
project.evaluationDependsOn(":app")
}
tasks.register("clean", Delete) {
delete rootProject.buildDir
}

View File

@@ -0,0 +1,3 @@
org.gradle.jvmargs=-Xmx4G -XX:MaxMetaspaceSize=2G -XX:+HeapDumpOnOutOfMemoryError
android.useAndroidX=true
android.enableJetifier=true

Binary file not shown.

View File

@@ -0,0 +1,5 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14-all.zip

View File

@@ -0,0 +1,2 @@
flutter.sdk=/opt/flutter
sdk.dir=/opt/android-sdk

View File

@@ -0,0 +1,26 @@
pluginManagement {
def flutterSdkPath = {
def properties = new Properties()
file("local.properties").withInputStream { properties.load(it) }
def flutterSdkPath = properties.getProperty("flutter.sdk")
assert flutterSdkPath != null, "flutter.sdk not set in local.properties"
return flutterSdkPath
}()
includeBuild("$flutterSdkPath/packages/flutter_tools/gradle")
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
plugins {
id "dev.flutter.flutter-plugin-loader" version "1.0.0"
id "com.android.application" version "8.7.0" apply false
id "org.jetbrains.kotlin.android" version "2.1.0" apply false
id "com.google.gms.google-services" version "4.4.0" apply false
}
include ":app"

65
mobile/lib/app.dart Normal file
View File

@@ -0,0 +1,65 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'services/api_client.dart';
import 'providers/auth_provider.dart';
import 'providers/agent_provider.dart';
import 'providers/call_provider.dart';
import 'screens/login_screen.dart';
import 'screens/dashboard_screen.dart';
class TwpSoftphoneApp extends StatefulWidget {
const TwpSoftphoneApp({super.key});
@override
State<TwpSoftphoneApp> createState() => _TwpSoftphoneAppState();
}
class _TwpSoftphoneAppState extends State<TwpSoftphoneApp> {
final _apiClient = ApiClient();
@override
Widget build(BuildContext context) {
return ChangeNotifierProvider(
create: (_) {
final auth = AuthProvider(_apiClient);
auth.tryRestoreSession();
return auth;
},
child: MaterialApp(
title: 'TWP Softphone',
debugShowCheckedModeBanner: false,
theme: ThemeData(
colorSchemeSeed: Colors.blue,
useMaterial3: true,
brightness: Brightness.light,
),
darkTheme: ThemeData(
colorSchemeSeed: Colors.blue,
useMaterial3: true,
brightness: Brightness.dark,
),
home: Consumer<AuthProvider>(
builder: (context, auth, _) {
if (auth.state == AuthState.authenticated) {
return MultiProvider(
providers: [
ChangeNotifierProvider(
create: (_) => AgentProvider(
auth.apiClient,
auth.sseService,
)..refresh(),
),
ChangeNotifierProvider(
create: (_) => CallProvider(auth.voiceService),
),
],
child: const DashboardScreen(),
);
}
return const LoginScreen();
},
),
),
);
}
}

View File

@@ -0,0 +1,8 @@
class AppConfig {
static const String appName = 'TWP Softphone';
static const Duration tokenRefreshInterval = Duration(minutes: 50);
static const Duration sseReconnectBase = Duration(seconds: 2);
static const Duration sseMaxReconnect = Duration(seconds: 60);
static const int sseServerTimeout = 300; // server closes after 5 min
static const String defaultScheme = 'https';
}

9
mobile/lib/main.dart Normal file
View File

@@ -0,0 +1,9 @@
import 'package:flutter/material.dart';
import 'package:firebase_core/firebase_core.dart';
import 'app.dart';
void main() async {
WidgetsFlutterBinding.ensureInitialized();
await Firebase.initializeApp();
runApp(const TwpSoftphoneApp());
}

View File

@@ -0,0 +1,38 @@
enum AgentStatusValue { available, busy, offline }
class AgentStatus {
final AgentStatusValue status;
final bool isLoggedIn;
final String? currentCallSid;
final String? lastActivity;
final bool availableForQueues;
AgentStatus({
required this.status,
required this.isLoggedIn,
this.currentCallSid,
this.lastActivity,
this.availableForQueues = true,
});
factory AgentStatus.fromJson(Map<String, dynamic> json) {
return AgentStatus(
status: _parseStatus(json['status'] as String),
isLoggedIn: json['is_logged_in'] as bool,
currentCallSid: json['current_call_sid'] as String?,
lastActivity: json['last_activity'] as String?,
availableForQueues: json['available_for_queues'] as bool? ?? true,
);
}
static AgentStatusValue _parseStatus(String s) {
switch (s) {
case 'available':
return AgentStatusValue.available;
case 'busy':
return AgentStatusValue.busy;
default:
return AgentStatusValue.offline;
}
}
}

View File

@@ -0,0 +1,46 @@
enum CallState { idle, ringing, connecting, connected, disconnected }
class CallInfo {
final CallState state;
final String? callSid;
final String? callerNumber;
final Duration duration;
final bool isMuted;
final bool isSpeakerOn;
final bool isOnHold;
const CallInfo({
this.state = CallState.idle,
this.callSid,
this.callerNumber,
this.duration = Duration.zero,
this.isMuted = false,
this.isSpeakerOn = false,
this.isOnHold = false,
});
CallInfo copyWith({
CallState? state,
String? callSid,
String? callerNumber,
Duration? duration,
bool? isMuted,
bool? isSpeakerOn,
bool? isOnHold,
}) {
return CallInfo(
state: state ?? this.state,
callSid: callSid ?? this.callSid,
callerNumber: callerNumber ?? this.callerNumber,
duration: duration ?? this.duration,
isMuted: isMuted ?? this.isMuted,
isSpeakerOn: isSpeakerOn ?? this.isSpeakerOn,
isOnHold: isOnHold ?? this.isOnHold,
);
}
bool get isActive =>
state == CallState.ringing ||
state == CallState.connecting ||
state == CallState.connected;
}

View File

@@ -0,0 +1,54 @@
class QueueInfo {
final int id;
final String name;
final String type;
final String? extension;
final int waitingCount;
QueueInfo({
required this.id,
required this.name,
required this.type,
this.extension,
required this.waitingCount,
});
factory QueueInfo.fromJson(Map<String, dynamic> json) {
return QueueInfo(
id: json['id'] as int,
name: json['name'] as String,
type: json['type'] as String,
extension: json['extension'] as String?,
waitingCount: json['waiting_count'] as int,
);
}
}
class QueueCall {
final String callSid;
final String fromNumber;
final String toNumber;
final int position;
final String status;
final int waitTime;
QueueCall({
required this.callSid,
required this.fromNumber,
required this.toNumber,
required this.position,
required this.status,
required this.waitTime,
});
factory QueueCall.fromJson(Map<String, dynamic> json) {
return QueueCall(
callSid: json['call_sid'] as String,
fromNumber: json['from_number'] as String,
toNumber: json['to_number'] as String,
position: json['position'] as int,
status: json['status'] as String,
waitTime: json['wait_time'] as int,
);
}
}

View File

@@ -0,0 +1,22 @@
class User {
final int id;
final String login;
final String displayName;
final String? email;
User({
required this.id,
required this.login,
required this.displayName,
this.email,
});
factory User.fromJson(Map<String, dynamic> json) {
return User(
id: json['user_id'] as int,
login: json['user_login'] as String,
displayName: json['display_name'] as String,
email: json['email'] as String?,
);
}
}

View File

@@ -0,0 +1,88 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import '../models/agent_status.dart';
import '../models/queue_state.dart';
import '../services/api_client.dart';
import '../services/sse_service.dart';
class AgentProvider extends ChangeNotifier {
final ApiClient _api;
final SseService _sse;
AgentStatus? _status;
List<QueueInfo> _queues = [];
bool _sseConnected = false;
StreamSubscription? _sseSub;
StreamSubscription? _connSub;
AgentStatus? get status => _status;
List<QueueInfo> get queues => _queues;
bool get sseConnected => _sseConnected;
AgentProvider(this._api, this._sse) {
_connSub = _sse.connectionState.listen((connected) {
_sseConnected = connected;
notifyListeners();
});
_sseSub = _sse.events.listen(_handleSseEvent);
}
Future<void> fetchStatus() async {
try {
final response = await _api.dio.get('/agent/status');
_status = AgentStatus.fromJson(response.data);
notifyListeners();
} catch (_) {}
}
Future<void> updateStatus(AgentStatusValue newStatus) async {
final statusStr = newStatus.name;
try {
await _api.dio.post('/agent/status', data: {
'status': statusStr,
'is_logged_in': true,
});
_status = AgentStatus(
status: newStatus,
isLoggedIn: true,
currentCallSid: _status?.currentCallSid,
);
notifyListeners();
} catch (_) {}
}
Future<void> fetchQueues() async {
try {
final response = await _api.dio.get('/queues/state');
final data = response.data;
_queues = (data['queues'] as List)
.map((q) => QueueInfo.fromJson(q as Map<String, dynamic>))
.toList();
notifyListeners();
} catch (_) {}
}
Future<void> refresh() async {
await Future.wait([fetchStatus(), fetchQueues()]);
}
void _handleSseEvent(SseEvent event) {
switch (event.event) {
case 'call_enqueued':
case 'call_dequeued':
fetchQueues();
break;
case 'agent_status_changed':
fetchStatus();
break;
}
}
@override
void dispose() {
_sseSub?.cancel();
_connSub?.cancel();
super.dispose();
}
}

View File

@@ -0,0 +1,107 @@
import 'package:flutter/foundation.dart';
import '../models/user.dart';
import '../services/api_client.dart';
import '../services/auth_service.dart';
import '../services/voice_service.dart';
import '../services/push_notification_service.dart';
import '../services/sse_service.dart';
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;
AuthState _state = AuthState.unauthenticated;
User? _user;
String? _error;
AuthState get state => _state;
User? get user => _user;
String? get error => _error;
VoiceService get voiceService => _voiceService;
SseService get sseService => _sseService;
ApiClient get apiClient => _apiClient;
AuthProvider(this._apiClient) {
_authService = AuthService(_apiClient);
_voiceService = VoiceService(_apiClient);
_pushService = PushNotificationService(_apiClient);
_sseService = SseService(_apiClient);
_apiClient.onForceLogout = _handleForceLogout;
}
Future<void> tryRestoreSession() async {
final restored = await _authService.tryRestoreSession();
if (restored) {
_state = AuthState.authenticated;
await _initializeServices();
notifyListeners();
}
}
Future<void> login(String serverUrl, String username, String password) async {
_state = AuthState.authenticating;
_error = null;
notifyListeners();
try {
_user = await _authService.login(serverUrl, username, password);
_state = AuthState.authenticated;
await _initializeServices();
} catch (e) {
_state = AuthState.unauthenticated;
_error = e.toString().replaceFirst('Exception: ', '');
}
notifyListeners();
}
Future<void> _initializeServices() async {
try {
await _pushService.initialize();
} catch (_) {}
try {
await _voiceService.initialize();
} catch (_) {}
try {
await _sseService.connect();
} catch (_) {}
}
Future<void> logout() async {
_voiceService.dispose();
_sseService.disconnect();
await _authService.logout();
_state = AuthState.unauthenticated;
_user = null;
_error = null;
// Re-create services for potential re-login
_voiceService = VoiceService(_apiClient);
_pushService = PushNotificationService(_apiClient);
_sseService = SseService(_apiClient);
notifyListeners();
}
void _handleForceLogout() {
_state = AuthState.unauthenticated;
_user = null;
_error = 'Session expired. Please log in again.';
_sseService.disconnect();
notifyListeners();
}
@override
void dispose() {
_authService.dispose();
_voiceService.dispose();
_sseService.dispose();
super.dispose();
}
}

View File

@@ -0,0 +1,134 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:twilio_voice/twilio_voice.dart';
import '../models/call_info.dart';
import '../services/voice_service.dart';
class CallProvider extends ChangeNotifier {
final VoiceService _voiceService;
CallInfo _callInfo = const CallInfo();
Timer? _durationTimer;
StreamSubscription? _eventSub;
DateTime? _connectedAt;
CallInfo get callInfo => _callInfo;
CallProvider(this._voiceService) {
_eventSub = _voiceService.callEvents.listen(_handleCallEvent);
}
void _handleCallEvent(CallEvent event) {
switch (event) {
case CallEvent.incoming:
_callInfo = _callInfo.copyWith(
state: CallState.ringing,
);
break;
case CallEvent.ringing:
_callInfo = _callInfo.copyWith(state: CallState.connecting);
break;
case CallEvent.connected:
_connectedAt = DateTime.now();
_callInfo = _callInfo.copyWith(state: CallState.connected);
_startDurationTimer();
break;
case CallEvent.callEnded:
_stopDurationTimer();
_callInfo = const CallInfo(); // reset to idle
break;
case CallEvent.returningCall:
_callInfo = _callInfo.copyWith(state: CallState.connecting);
break;
case CallEvent.reconnecting:
break;
case CallEvent.reconnected:
break;
default:
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();
}
});
}
notifyListeners();
}
void _startDurationTimer() {
_durationTimer?.cancel();
_durationTimer = Timer.periodic(const Duration(seconds: 1), (_) {
if (_connectedAt != null) {
_callInfo = _callInfo.copyWith(
duration: DateTime.now().difference(_connectedAt!),
);
notifyListeners();
}
});
}
void _stopDurationTimer() {
_durationTimer?.cancel();
_connectedAt = null;
}
Future<void> answer() => _voiceService.answer();
Future<void> reject() => _voiceService.reject();
Future<void> hangUp() => _voiceService.hangUp();
Future<void> toggleMute() async {
final newMuted = !_callInfo.isMuted;
await _voiceService.toggleMute(newMuted);
_callInfo = _callInfo.copyWith(isMuted: newMuted);
notifyListeners();
}
Future<void> toggleSpeaker() async {
final newSpeaker = !_callInfo.isSpeakerOn;
await _voiceService.toggleSpeaker(newSpeaker);
_callInfo = _callInfo.copyWith(isSpeakerOn: newSpeaker);
notifyListeners();
}
Future<void> sendDigits(String digits) => _voiceService.sendDigits(digits);
Future<void> holdCall() async {
final sid = _callInfo.callSid;
if (sid == null) return;
await _voiceService.holdCall(sid);
_callInfo = _callInfo.copyWith(isOnHold: true);
notifyListeners();
}
Future<void> unholdCall() async {
final sid = _callInfo.callSid;
if (sid == null) return;
await _voiceService.unholdCall(sid);
_callInfo = _callInfo.copyWith(isOnHold: false);
notifyListeners();
}
Future<void> transferCall(String target) async {
final sid = _callInfo.callSid;
if (sid == null) return;
await _voiceService.transferCall(sid, target);
}
@override
void dispose() {
_stopDurationTimer();
_eventSub?.cancel();
super.dispose();
}
}

View File

@@ -0,0 +1,137 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../providers/call_provider.dart';
import '../models/call_info.dart';
import '../widgets/call_controls.dart';
import '../widgets/dialpad.dart';
class ActiveCallScreen extends StatefulWidget {
const ActiveCallScreen({super.key});
@override
State<ActiveCallScreen> createState() => _ActiveCallScreenState();
}
class _ActiveCallScreenState extends State<ActiveCallScreen> {
bool _showDialpad = false;
@override
Widget build(BuildContext context) {
final call = context.watch<CallProvider>();
final info = call.callInfo;
// Pop back when call ends
if (info.state == CallState.idle) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) Navigator.of(context).pop();
});
}
return Scaffold(
backgroundColor: Theme.of(context).colorScheme.surfaceContainerHighest,
body: SafeArea(
child: Column(
children: [
const Spacer(flex: 2),
// Caller info
Text(
info.callerNumber ?? 'Unknown',
style: Theme.of(context)
.textTheme
.headlineMedium
?.copyWith(fontWeight: FontWeight.bold),
),
const SizedBox(height: 8),
Text(
_stateLabel(info.state),
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 4),
if (info.state == CallState.connected)
Text(
_formatDuration(info.duration),
style: Theme.of(context).textTheme.titleMedium,
),
const Spacer(flex: 2),
// Dialpad overlay
if (_showDialpad)
Dialpad(
onDigit: (d) => call.sendDigits(d),
onClose: () => setState(() => _showDialpad = false),
),
// Controls
if (!_showDialpad)
CallControls(
callInfo: info,
onMute: () => call.toggleMute(),
onSpeaker: () => call.toggleSpeaker(),
onHold: () =>
info.isOnHold ? call.unholdCall() : call.holdCall(),
onDialpad: () => setState(() => _showDialpad = true),
onTransfer: () => _showTransferDialog(context, call),
onHangUp: () => call.hangUp(),
),
const Spacer(),
],
),
),
);
}
String _stateLabel(CallState state) {
switch (state) {
case CallState.ringing:
return 'Ringing...';
case CallState.connecting:
return 'Connecting...';
case CallState.connected:
return 'Connected';
case CallState.disconnected:
return 'Disconnected';
case CallState.idle:
return '';
}
}
String _formatDuration(Duration d) {
final minutes = d.inMinutes.toString().padLeft(2, '0');
final seconds = (d.inSeconds % 60).toString().padLeft(2, '0');
return '$minutes:$seconds';
}
void _showTransferDialog(BuildContext context, CallProvider call) {
final controller = TextEditingController();
showDialog(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('Transfer Call'),
content: TextField(
controller: controller,
decoration: const InputDecoration(
labelText: 'Extension or Queue ID',
border: OutlineInputBorder(),
),
keyboardType: TextInputType.number,
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx),
child: const Text('Cancel'),
),
FilledButton(
onPressed: () {
final target = controller.text.trim();
if (target.isNotEmpty) {
call.transferCall(target);
Navigator.pop(ctx);
}
},
child: const Text('Transfer'),
),
],
),
);
}
}

View File

@@ -0,0 +1,88 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../providers/agent_provider.dart';
import '../providers/call_provider.dart';
import '../widgets/agent_status_toggle.dart';
import '../widgets/queue_card.dart';
import 'active_call_screen.dart';
import 'settings_screen.dart';
class DashboardScreen extends StatefulWidget {
const DashboardScreen({super.key});
@override
State<DashboardScreen> createState() => _DashboardScreenState();
}
class _DashboardScreenState extends State<DashboardScreen> {
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
context.read<AgentProvider>().refresh();
});
}
@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,
);
});
}
return Scaffold(
appBar: AppBar(
title: const Text('TWP Softphone'),
actions: [
// SSE connection indicator
Padding(
padding: const EdgeInsets.only(right: 8),
child: Icon(
Icons.circle,
size: 12,
color: agent.sseConnected ? Colors.green : Colors.red,
),
),
IconButton(
icon: const Icon(Icons.settings),
onPressed: () => Navigator.push(context,
MaterialPageRoute(builder: (_) => const SettingsScreen())),
),
],
),
body: RefreshIndicator(
onRefresh: () => agent.refresh(),
child: ListView(
padding: const EdgeInsets.all(16),
children: [
const AgentStatusToggle(),
const SizedBox(height: 24),
Text('Queues',
style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: 8),
if (agent.queues.isEmpty)
const Card(
child: Padding(
padding: EdgeInsets.all(24),
child: Center(child: Text('No queues assigned')),
),
)
else
...agent.queues.map((q) => Padding(
padding: const EdgeInsets.only(bottom: 8),
child: QueueCard(queue: q),
)),
],
),
),
);
}
}

View File

@@ -0,0 +1,158 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../providers/auth_provider.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
class LoginScreen extends StatefulWidget {
const LoginScreen({super.key});
@override
State<LoginScreen> createState() => _LoginScreenState();
}
class _LoginScreenState extends State<LoginScreen> {
final _formKey = GlobalKey<FormState>();
final _serverController = TextEditingController();
final _usernameController = TextEditingController();
final _passwordController = TextEditingController();
bool _obscurePassword = true;
@override
void initState() {
super.initState();
_loadSavedServer();
}
Future<void> _loadSavedServer() async {
const storage = FlutterSecureStorage();
final saved = await storage.read(key: 'server_url');
if (saved != null && mounted) {
_serverController.text = saved;
}
}
void _submit() {
if (!_formKey.currentState!.validate()) return;
var serverUrl = _serverController.text.trim();
if (!serverUrl.startsWith('http')) {
serverUrl = 'https://$serverUrl';
}
context.read<AuthProvider>().login(
serverUrl,
_usernameController.text.trim(),
_passwordController.text,
);
}
@override
Widget build(BuildContext context) {
final auth = context.watch<AuthProvider>();
return Scaffold(
body: SafeArea(
child: Center(
child: SingleChildScrollView(
padding: const EdgeInsets.all(24),
child: Form(
key: _formKey,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.phone_in_talk,
size: 64,
color: Theme.of(context).colorScheme.primary,
),
const SizedBox(height: 16),
Text(
'TWP Softphone',
style: Theme.of(context).textTheme.headlineMedium,
),
const SizedBox(height: 32),
TextFormField(
controller: _serverController,
decoration: const InputDecoration(
labelText: 'Server URL',
hintText: 'https://your-site.com',
prefixIcon: Icon(Icons.dns),
border: OutlineInputBorder(),
),
keyboardType: TextInputType.url,
validator: (v) =>
v == null || v.trim().isEmpty ? 'Required' : null,
),
const SizedBox(height: 16),
TextFormField(
controller: _usernameController,
decoration: const InputDecoration(
labelText: 'Username',
prefixIcon: Icon(Icons.person),
border: OutlineInputBorder(),
),
validator: (v) =>
v == null || v.trim().isEmpty ? 'Required' : null,
),
const SizedBox(height: 16),
TextFormField(
controller: _passwordController,
decoration: InputDecoration(
labelText: 'Password',
prefixIcon: const Icon(Icons.lock),
border: const OutlineInputBorder(),
suffixIcon: IconButton(
icon: Icon(_obscurePassword
? Icons.visibility_off
: Icons.visibility),
onPressed: () =>
setState(() => _obscurePassword = !_obscurePassword),
),
),
obscureText: _obscurePassword,
validator: (v) =>
v == null || v.isEmpty ? 'Required' : null,
),
if (auth.error != null) ...[
const SizedBox(height: 16),
Text(
auth.error!,
style: TextStyle(
color: Theme.of(context).colorScheme.error),
),
],
const SizedBox(height: 24),
SizedBox(
width: double.infinity,
height: 48,
child: FilledButton(
onPressed: auth.state == AuthState.authenticating
? null
: _submit,
child: auth.state == AuthState.authenticating
? const SizedBox(
width: 24,
height: 24,
child: CircularProgressIndicator(
strokeWidth: 2, color: Colors.white),
)
: const Text('Connect'),
),
),
],
),
),
),
),
),
);
}
@override
void dispose() {
_serverController.dispose();
_usernameController.dispose();
_passwordController.dispose();
super.dispose();
}
}

View File

@@ -0,0 +1,68 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import '../providers/auth_provider.dart';
class SettingsScreen extends StatefulWidget {
const SettingsScreen({super.key});
@override
State<SettingsScreen> createState() => _SettingsScreenState();
}
class _SettingsScreenState extends State<SettingsScreen> {
String? _serverUrl;
@override
void initState() {
super.initState();
_loadServerUrl();
}
Future<void> _loadServerUrl() async {
const storage = FlutterSecureStorage();
final url = await storage.read(key: 'server_url');
if (mounted) setState(() => _serverUrl = url);
}
@override
Widget build(BuildContext context) {
final auth = context.watch<AuthProvider>();
return Scaffold(
appBar: AppBar(title: const Text('Settings')),
body: ListView(
children: [
ListTile(
leading: const Icon(Icons.dns),
title: const Text('Server'),
subtitle: Text(_serverUrl ?? 'Not configured'),
),
if (auth.user != null) ...[
ListTile(
leading: const Icon(Icons.person),
title: const Text('User'),
subtitle: Text(auth.user!.displayName),
),
ListTile(
leading: const Icon(Icons.badge),
title: const Text('Login'),
subtitle: Text(auth.user!.login),
),
],
const Divider(),
ListTile(
leading: const Icon(Icons.logout, color: Colors.red),
title: const Text('Logout', style: TextStyle(color: Colors.red)),
onTap: () async {
await auth.logout();
if (context.mounted) {
Navigator.of(context).popUntil((route) => route.isFirst);
}
},
),
],
),
);
}
}

View File

@@ -0,0 +1,85 @@
import 'package:dio/dio.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
class ApiClient {
late final Dio dio;
final FlutterSecureStorage _storage = const FlutterSecureStorage();
VoidCallback? onForceLogout;
ApiClient() {
dio = Dio(BaseOptions(
connectTimeout: const Duration(seconds: 15),
receiveTimeout: const Duration(seconds: 30),
));
dio.interceptors.add(InterceptorsWrapper(
onRequest: (options, handler) async {
final token = await _storage.read(key: 'access_token');
if (token != null) {
options.headers['Authorization'] = 'Bearer $token';
}
handler.next(options);
},
onError: (error, handler) async {
if (error.response?.statusCode == 401) {
final refreshed = await _tryRefreshToken();
if (refreshed) {
final opts = error.requestOptions;
final token = await _storage.read(key: 'access_token');
opts.headers['Authorization'] = 'Bearer $token';
try {
final response = await dio.fetch(opts);
return handler.resolve(response);
} catch (e) {
return handler.next(error);
}
} else {
onForceLogout?.call();
}
}
handler.next(error);
},
));
}
Future<void> setBaseUrl(String serverUrl) async {
final url = serverUrl.endsWith('/')
? serverUrl.substring(0, serverUrl.length - 1)
: serverUrl;
dio.options.baseUrl = '$url/wp-json/twilio-mobile/v1';
await _storage.write(key: 'server_url', value: url);
}
Future<void> restoreBaseUrl() async {
final url = await _storage.read(key: 'server_url');
if (url != null) {
dio.options.baseUrl = '$url/wp-json/twilio-mobile/v1';
}
}
Future<bool> _tryRefreshToken() async {
try {
final refreshToken = await _storage.read(key: 'refresh_token');
if (refreshToken == null) return false;
final response = await dio.post(
'/auth/refresh',
data: {'refresh_token': refreshToken},
options: Options(headers: {'Authorization': ''}),
);
if (response.statusCode == 200 && response.data['success'] == true) {
await _storage.write(
key: 'access_token', value: response.data['access_token']);
if (response.data['refresh_token'] != null) {
await _storage.write(
key: 'refresh_token', value: response.data['refresh_token']);
}
return true;
}
} catch (_) {}
return false;
}
}
typedef VoidCallback = void Function();

View File

@@ -0,0 +1,95 @@
import 'dart:async';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import '../models/user.dart';
import 'api_client.dart';
class AuthService {
final ApiClient _api;
final FlutterSecureStorage _storage = const FlutterSecureStorage();
Timer? _refreshTimer;
AuthService(this._api);
Future<User> login(String serverUrl, String username, String password,
{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 data = response.data;
if (data['success'] != true) {
throw Exception(data['message'] ?? 'Login failed');
}
await _storage.write(key: 'access_token', value: data['access_token']);
await _storage.write(key: 'refresh_token', value: data['refresh_token']);
_scheduleRefresh(data['expires_in'] as int? ?? 3600);
return User.fromJson(data['user']);
}
Future<bool> tryRestoreSession() async {
final token = await _storage.read(key: 'access_token');
if (token == null) return false;
await _api.restoreBaseUrl();
if (_api.dio.options.baseUrl.isEmpty) return false;
try {
final response = await _api.dio.get('/agent/status');
return response.statusCode == 200;
} catch (_) {
return false;
}
}
Future<void> refreshToken() async {
final refreshToken = await _storage.read(key: 'refresh_token');
if (refreshToken == null) throw Exception('No refresh token');
final response = await _api.dio.post('/auth/refresh', data: {
'refresh_token': refreshToken,
});
final data = response.data;
if (data['success'] != true) {
throw Exception('Token refresh failed');
}
await _storage.write(key: 'access_token', value: data['access_token']);
if (data['refresh_token'] != null) {
await _storage.write(key: 'refresh_token', value: data['refresh_token']);
}
_scheduleRefresh(data['expires_in'] as int? ?? 3600);
}
void _scheduleRefresh(int expiresInSeconds) {
_refreshTimer?.cancel();
// Refresh 2 minutes before expiry
final refreshIn = Duration(seconds: expiresInSeconds - 120);
if (refreshIn.isNegative) return;
_refreshTimer = Timer(refreshIn, () async {
try {
await refreshToken();
} catch (_) {}
});
}
Future<void> logout() async {
_refreshTimer?.cancel();
try {
await _api.dio.post('/auth/logout');
} catch (_) {}
await _storage.deleteAll();
}
void dispose() {
_refreshTimer?.cancel();
}
}

View File

@@ -0,0 +1,78 @@
import 'package:firebase_core/firebase_core.dart';
import 'package:firebase_messaging/firebase_messaging.dart';
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import 'api_client.dart';
@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.
}
class PushNotificationService {
final ApiClient _api;
final FirebaseMessaging _messaging = FirebaseMessaging.instance;
final FlutterLocalNotificationsPlugin _localNotifications =
FlutterLocalNotificationsPlugin();
PushNotificationService(this._api);
Future<void> initialize() async {
FirebaseMessaging.onBackgroundMessage(_firebaseMessagingBackgroundHandler);
await _messaging.requestPermission(
alert: true,
badge: true,
sound: true,
criticalAlert: true,
);
// Initialize local notifications
const androidSettings =
AndroidInitializationSettings('@mipmap/ic_launcher');
const initSettings = InitializationSettings(android: androidSettings);
await _localNotifications.initialize(initSettings);
// Get and register FCM token
final token = await _messaging.getToken();
if (token != null) {
await _registerToken(token);
}
// Listen for token refresh
_messaging.onTokenRefresh.listen(_registerToken);
// Handle foreground messages (non-VoIP)
FirebaseMessaging.onMessage.listen(_handleForegroundMessage);
}
Future<void> _registerToken(String token) async {
try {
await _api.dio.post('/fcm/register', data: {'fcm_token': token});
} catch (_) {}
}
void _handleForegroundMessage(RemoteMessage message) {
final data = message.data;
final type = data['type'];
// 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.)
_localNotifications.show(
message.hashCode,
data['title'] ?? 'TWP Softphone',
data['body'] ?? '',
const NotificationDetails(
android: AndroidNotificationDetails(
'twp_general',
'General Notifications',
importance: Importance.high,
priority: Priority.high,
),
),
);
}
}

View File

@@ -0,0 +1,119 @@
import 'dart:async';
import 'dart:convert';
import 'dart:math';
import 'package:dio/dio.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import '../config/app_config.dart';
import 'api_client.dart';
class SseEvent {
final String event;
final Map<String, dynamic> data;
SseEvent({required this.event, required this.data});
}
class SseService {
final ApiClient _api;
final FlutterSecureStorage _storage = const FlutterSecureStorage();
final StreamController<SseEvent> _eventController =
StreamController<SseEvent>.broadcast();
final StreamController<bool> _connectionController =
StreamController<bool>.broadcast();
CancelToken? _cancelToken;
Timer? _reconnectTimer;
int _reconnectAttempt = 0;
bool _shouldReconnect = true;
Stream<SseEvent> get events => _eventController.stream;
Stream<bool> get connectionState => _connectionController.stream;
SseService(this._api);
Future<void> connect() async {
_shouldReconnect = true;
_reconnectAttempt = 0;
await _doConnect();
}
Future<void> _doConnect() async {
_cancelToken?.cancel();
_cancelToken = CancelToken();
try {
final token = await _storage.read(key: 'access_token');
final response = await _api.dio.get(
'/stream/events',
options: Options(
headers: {'Authorization': 'Bearer $token'},
responseType: ResponseType.stream,
),
cancelToken: _cancelToken,
);
_connectionController.add(true);
_reconnectAttempt = 0;
final stream = response.data.stream as Stream<List<int>>;
String buffer = '';
await for (final chunk in stream) {
buffer += utf8.decode(chunk);
final lines = buffer.split('\n');
buffer = lines.removeLast(); // keep incomplete line in buffer
String? eventName;
String? dataStr;
for (final line in lines) {
if (line.startsWith('event:')) {
eventName = line.substring(6).trim();
} else if (line.startsWith('data:')) {
dataStr = line.substring(5).trim();
} else if (line.isEmpty && eventName != null && dataStr != null) {
try {
final data = jsonDecode(dataStr) as Map<String, dynamic>;
_eventController.add(SseEvent(event: eventName, data: data));
} catch (_) {}
eventName = null;
dataStr = null;
}
}
}
} catch (e) {
if (e is DioException && e.type == DioExceptionType.cancel) return;
_connectionController.add(false);
}
if (_shouldReconnect) {
_scheduleReconnect();
}
}
void _scheduleReconnect() {
_reconnectTimer?.cancel();
final delay = Duration(
milliseconds: min(
AppConfig.sseMaxReconnect.inMilliseconds,
AppConfig.sseReconnectBase.inMilliseconds *
pow(2, _reconnectAttempt).toInt(),
),
);
_reconnectAttempt++;
_reconnectTimer = Timer(delay, _doConnect);
}
void disconnect() {
_shouldReconnect = false;
_reconnectTimer?.cancel();
_cancelToken?.cancel();
_connectionController.add(false);
}
void dispose() {
disconnect();
_eventController.close();
_connectionController.close();
}
}

View File

@@ -0,0 +1,85 @@
import 'dart:async';
import 'package:twilio_voice/twilio_voice.dart';
import 'api_client.dart';
class VoiceService {
final ApiClient _api;
Timer? _tokenRefreshTimer;
String? _identity;
final StreamController<CallEvent> _callEventController =
StreamController<CallEvent>.broadcast();
Stream<CallEvent> get callEvents => _callEventController.stream;
VoiceService(this._api);
Future<void> initialize() async {
await _fetchAndRegisterToken();
TwilioVoice.instance.callEventsListener.listen((event) {
_callEventController.add(event);
});
// Refresh token every 50 minutes
_tokenRefreshTimer?.cancel();
_tokenRefreshTimer = Timer.periodic(
const Duration(minutes: 50),
(_) => _fetchAndRegisterToken(),
);
}
Future<void> _fetchAndRegisterToken() async {
try {
final response = await _api.dio.get('/voice/token');
final data = response.data;
final token = data['token'] as String;
_identity = data['identity'] as String;
await TwilioVoice.instance.setTokens(accessToken: token);
} catch (e) {
// Token fetch failed - will retry on next interval
}
}
String? get identity => _identity;
Future<void> answer() async {
await TwilioVoice.instance.call.answer();
}
Future<void> reject() async {
await TwilioVoice.instance.call.hangUp();
}
Future<void> hangUp() async {
await TwilioVoice.instance.call.hangUp();
}
Future<void> toggleMute(bool mute) async {
await TwilioVoice.instance.call.toggleMute(mute);
}
Future<void> toggleSpeaker(bool speaker) async {
await TwilioVoice.instance.call.toggleSpeaker(speaker);
}
Future<void> sendDigits(String digits) async {
await TwilioVoice.instance.call.sendDigits(digits);
}
Future<void> holdCall(String callSid) async {
await _api.dio.post('/calls/$callSid/hold');
}
Future<void> unholdCall(String callSid) async {
await _api.dio.post('/calls/$callSid/unhold');
}
Future<void> transferCall(String callSid, String target) async {
await _api.dio.post('/calls/$callSid/transfer', data: {'target': target});
}
void dispose() {
_tokenRefreshTimer?.cancel();
_callEventController.close();
}
}

View File

@@ -0,0 +1,51 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../models/agent_status.dart';
import '../providers/agent_provider.dart';
class AgentStatusToggle extends StatelessWidget {
const AgentStatusToggle({super.key});
@override
Widget build(BuildContext context) {
final agent = context.watch<AgentProvider>();
final current = agent.status?.status ?? AgentStatusValue.offline;
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Agent Status',
style: Theme.of(context).textTheme.titleSmall),
const SizedBox(height: 12),
SegmentedButton<AgentStatusValue>(
segments: const [
ButtonSegment(
value: AgentStatusValue.available,
label: Text('Available'),
icon: Icon(Icons.circle, color: Colors.green, size: 12),
),
ButtonSegment(
value: AgentStatusValue.busy,
label: Text('Busy'),
icon: Icon(Icons.circle, color: Colors.orange, size: 12),
),
ButtonSegment(
value: AgentStatusValue.offline,
label: Text('Offline'),
icon: Icon(Icons.circle, color: Colors.red, size: 12),
),
],
selected: {current},
onSelectionChanged: (selection) {
agent.updateStatus(selection.first);
},
),
],
),
),
);
}
}

View File

@@ -0,0 +1,118 @@
import 'package:flutter/material.dart';
import '../models/call_info.dart';
class CallControls extends StatelessWidget {
final CallInfo callInfo;
final VoidCallback onMute;
final VoidCallback onSpeaker;
final VoidCallback onHold;
final VoidCallback onDialpad;
final VoidCallback onTransfer;
final VoidCallback onHangUp;
const CallControls({
super.key,
required this.callInfo,
required this.onMute,
required this.onSpeaker,
required this.onHold,
required this.onDialpad,
required this.onTransfer,
required this.onHangUp,
});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 24),
child: Column(
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
_ControlButton(
icon: callInfo.isMuted ? Icons.mic_off : Icons.mic,
label: 'Mute',
active: callInfo.isMuted,
onTap: onMute,
),
_ControlButton(
icon: callInfo.isSpeakerOn
? Icons.volume_up
: Icons.volume_down,
label: 'Speaker',
active: callInfo.isSpeakerOn,
onTap: onSpeaker,
),
_ControlButton(
icon: callInfo.isOnHold ? Icons.play_arrow : Icons.pause,
label: callInfo.isOnHold ? 'Resume' : 'Hold',
active: callInfo.isOnHold,
onTap: onHold,
),
],
),
const SizedBox(height: 16),
Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
_ControlButton(
icon: Icons.dialpad,
label: 'Dialpad',
onTap: onDialpad,
),
_ControlButton(
icon: Icons.phone_forwarded,
label: 'Transfer',
onTap: onTransfer,
),
],
),
const SizedBox(height: 24),
FloatingActionButton.large(
onPressed: onHangUp,
backgroundColor: Colors.red,
child: const Icon(Icons.call_end, color: Colors.white, size: 36),
),
],
),
);
}
}
class _ControlButton extends StatelessWidget {
final IconData icon;
final String label;
final bool active;
final VoidCallback onTap;
const _ControlButton({
required this.icon,
required this.label,
this.active = false,
required this.onTap,
});
@override
Widget build(BuildContext context) {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
IconButton.filled(
onPressed: onTap,
icon: Icon(icon),
style: IconButton.styleFrom(
backgroundColor: active
? Theme.of(context).colorScheme.primaryContainer
: Theme.of(context).colorScheme.surfaceContainerHighest,
foregroundColor: active
? Theme.of(context).colorScheme.primary
: Theme.of(context).colorScheme.onSurface,
),
),
const SizedBox(height: 4),
Text(label, style: Theme.of(context).textTheme.labelSmall),
],
);
}
}

View File

@@ -0,0 +1,55 @@
import 'package:flutter/material.dart';
class Dialpad extends StatelessWidget {
final void Function(String digit) onDigit;
final VoidCallback onClose;
const Dialpad({super.key, required this.onDigit, required this.onClose});
static const _keys = [
['1', '2', '3'],
['4', '5', '6'],
['7', '8', '9'],
['*', '0', '#'],
];
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 48),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
..._keys.map((row) => Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: row
.map((key) => Padding(
padding: const EdgeInsets.all(4),
child: InkWell(
onTap: () => onDigit(key),
borderRadius: BorderRadius.circular(40),
child: Container(
width: 64,
height: 64,
alignment: Alignment.center,
child: Text(
key,
style: Theme.of(context)
.textTheme
.headlineSmall,
),
),
),
))
.toList(),
)),
const SizedBox(height: 8),
TextButton(
onPressed: onClose,
child: const Text('Close'),
),
],
),
);
}
}

View File

@@ -0,0 +1,37 @@
import 'package:flutter/material.dart';
import '../models/queue_state.dart';
class QueueCard extends StatelessWidget {
final QueueInfo queue;
const QueueCard({super.key, required this.queue});
@override
Widget build(BuildContext context) {
return Card(
child: ListTile(
leading: CircleAvatar(
backgroundColor: queue.waitingCount > 0
? Colors.orange.shade100
: Colors.green.shade100,
child: Text(
'${queue.waitingCount}',
style: TextStyle(
color: queue.waitingCount > 0 ? Colors.orange : Colors.green,
fontWeight: FontWeight.bold,
),
),
),
title: Text(queue.name),
subtitle: Text(
queue.waitingCount > 0
? '${queue.waitingCount} waiting'
: 'No calls waiting',
),
trailing: queue.extension != null
? Chip(label: Text('Ext ${queue.extension}'))
: null,
),
);
}
}

890
mobile/pubspec.lock Normal file
View File

@@ -0,0 +1,890 @@
# Generated by pub
# See https://dart.dev/tools/pub/glossary#lockfile
packages:
_fe_analyzer_shared:
dependency: transitive
description:
name: _fe_analyzer_shared
sha256: "8d7ff3948166b8ec5da0fbb5962000926b8e02f2ed9b3e51d1738905fbd4c98d"
url: "https://pub.dev"
source: hosted
version: "93.0.0"
_flutterfire_internals:
dependency: transitive
description:
name: _flutterfire_internals
sha256: ff0a84a2734d9e1089f8aedd5c0af0061b82fb94e95260d943404e0ef2134b11
url: "https://pub.dev"
source: hosted
version: "1.3.59"
analyzer:
dependency: transitive
description:
name: analyzer
sha256: de7148ed2fcec579b19f122c1800933dfa028f6d9fd38a152b04b1516cec120b
url: "https://pub.dev"
source: hosted
version: "10.0.1"
args:
dependency: transitive
description:
name: args
sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04
url: "https://pub.dev"
source: hosted
version: "2.7.0"
async:
dependency: transitive
description:
name: async
sha256: "758e6d74e971c3e5aceb4110bfd6698efc7f501675bcfe0c775459a8140750eb"
url: "https://pub.dev"
source: hosted
version: "2.13.0"
boolean_selector:
dependency: transitive
description:
name: boolean_selector
sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea"
url: "https://pub.dev"
source: hosted
version: "2.1.2"
build:
dependency: transitive
description:
name: build
sha256: "275bf6bb2a00a9852c28d4e0b410da1d833a734d57d39d44f94bfc895a484ec3"
url: "https://pub.dev"
source: hosted
version: "4.0.4"
build_config:
dependency: transitive
description:
name: build_config
sha256: "4070d2a59f8eec34c97c86ceb44403834899075f66e8a9d59706f8e7834f6f71"
url: "https://pub.dev"
source: hosted
version: "1.3.0"
build_daemon:
dependency: transitive
description:
name: build_daemon
sha256: bf05f6e12cfea92d3c09308d7bcdab1906cd8a179b023269eed00c071004b957
url: "https://pub.dev"
source: hosted
version: "4.1.1"
build_runner:
dependency: "direct dev"
description:
name: build_runner
sha256: "7981eb922842c77033026eb4341d5af651562008cdb116bdfa31fc46516b6462"
url: "https://pub.dev"
source: hosted
version: "2.12.2"
built_collection:
dependency: transitive
description:
name: built_collection
sha256: "376e3dd27b51ea877c28d525560790aee2e6fbb5f20e2f85d5081027d94e2100"
url: "https://pub.dev"
source: hosted
version: "5.1.1"
built_value:
dependency: transitive
description:
name: built_value
sha256: "6ae8a6435a8c6520c7077b107e77f1fb4ba7009633259a4d49a8afd8e7efc5e9"
url: "https://pub.dev"
source: hosted
version: "8.12.4"
characters:
dependency: transitive
description:
name: characters
sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b
url: "https://pub.dev"
source: hosted
version: "1.4.1"
checked_yaml:
dependency: transitive
description:
name: checked_yaml
sha256: "959525d3162f249993882720d52b7e0c833978df229be20702b33d48d91de70f"
url: "https://pub.dev"
source: hosted
version: "2.0.4"
clock:
dependency: transitive
description:
name: clock
sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b
url: "https://pub.dev"
source: hosted
version: "1.1.2"
code_assets:
dependency: transitive
description:
name: code_assets
sha256: "83ccdaa064c980b5596c35dd64a8d3ecc68620174ab9b90b6343b753aa721687"
url: "https://pub.dev"
source: hosted
version: "1.0.0"
code_builder:
dependency: transitive
description:
name: code_builder
sha256: "6a6cab2ba4680d6423f34a9b972a4c9a94ebe1b62ecec4e1a1f2cba91fd1319d"
url: "https://pub.dev"
source: hosted
version: "4.11.1"
collection:
dependency: transitive
description:
name: collection
sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76"
url: "https://pub.dev"
source: hosted
version: "1.19.1"
convert:
dependency: transitive
description:
name: convert
sha256: b30acd5944035672bc15c6b7a8b47d773e41e2f17de064350988c5d02adb1c68
url: "https://pub.dev"
source: hosted
version: "3.1.2"
crypto:
dependency: transitive
description:
name: crypto
sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf
url: "https://pub.dev"
source: hosted
version: "3.0.7"
dart_style:
dependency: transitive
description:
name: dart_style
sha256: "6f6b30cba0301e7b38f32bdc9a6bdae6f5921a55f0a1eb9450e1e6515645dbb2"
url: "https://pub.dev"
source: hosted
version: "3.1.6"
dbus:
dependency: transitive
description:
name: dbus
sha256: d0c98dcd4f5169878b6cf8f6e0a52403a9dff371a3e2f019697accbf6f44a270
url: "https://pub.dev"
source: hosted
version: "0.7.12"
dio:
dependency: "direct main"
description:
name: dio
sha256: aff32c08f92787a557dd5c0145ac91536481831a01b4648136373cddb0e64f8c
url: "https://pub.dev"
source: hosted
version: "5.9.2"
dio_web_adapter:
dependency: transitive
description:
name: dio_web_adapter
sha256: "2f9e64323a7c3c7ef69567d5c800424a11f8337b8b228bad02524c9fb3c1f340"
url: "https://pub.dev"
source: hosted
version: "2.1.2"
fake_async:
dependency: transitive
description:
name: fake_async
sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44"
url: "https://pub.dev"
source: hosted
version: "1.3.3"
ffi:
dependency: transitive
description:
name: ffi
sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45"
url: "https://pub.dev"
source: hosted
version: "2.2.0"
file:
dependency: transitive
description:
name: file
sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4
url: "https://pub.dev"
source: hosted
version: "7.0.1"
firebase_core:
dependency: "direct main"
description:
name: firebase_core
sha256: "7be63a3f841fc9663342f7f3a011a42aef6a61066943c90b1c434d79d5c995c5"
url: "https://pub.dev"
source: hosted
version: "3.15.2"
firebase_core_platform_interface:
dependency: transitive
description:
name: firebase_core_platform_interface
sha256: cccb4f572325dc14904c02fcc7db6323ad62ba02536833dddb5c02cac7341c64
url: "https://pub.dev"
source: hosted
version: "6.0.2"
firebase_core_web:
dependency: transitive
description:
name: firebase_core_web
sha256: "0ed0dc292e8f9ac50992e2394e9d336a0275b6ae400d64163fdf0a8a8b556c37"
url: "https://pub.dev"
source: hosted
version: "2.24.1"
firebase_messaging:
dependency: "direct main"
description:
name: firebase_messaging
sha256: "60be38574f8b5658e2f22b7e311ff2064bea835c248424a383783464e8e02fcc"
url: "https://pub.dev"
source: hosted
version: "15.2.10"
firebase_messaging_platform_interface:
dependency: transitive
description:
name: firebase_messaging_platform_interface
sha256: "685e1771b3d1f9c8502771ccc9f91485b376ffe16d553533f335b9183ea99754"
url: "https://pub.dev"
source: hosted
version: "4.6.10"
firebase_messaging_web:
dependency: transitive
description:
name: firebase_messaging_web
sha256: "0d1be17bc89ed3ff5001789c92df678b2e963a51b6fa2bdb467532cc9dbed390"
url: "https://pub.dev"
source: hosted
version: "3.10.10"
fixnum:
dependency: transitive
description:
name: fixnum
sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be
url: "https://pub.dev"
source: hosted
version: "1.1.1"
flutter:
dependency: "direct main"
description: flutter
source: sdk
version: "0.0.0"
flutter_lints:
dependency: "direct dev"
description:
name: flutter_lints
sha256: "3f41d009ba7172d5ff9be5f6e6e6abb4300e263aab8866d2a0842ed2a70f8f0c"
url: "https://pub.dev"
source: hosted
version: "4.0.0"
flutter_local_notifications:
dependency: "direct main"
description:
name: flutter_local_notifications
sha256: "674173fd3c9eda9d4c8528da2ce0ea69f161577495a9cc835a2a4ecd7eadeb35"
url: "https://pub.dev"
source: hosted
version: "17.2.4"
flutter_local_notifications_linux:
dependency: transitive
description:
name: flutter_local_notifications_linux
sha256: c49bd06165cad9beeb79090b18cd1eb0296f4bf4b23b84426e37dd7c027fc3af
url: "https://pub.dev"
source: hosted
version: "4.0.1"
flutter_local_notifications_platform_interface:
dependency: transitive
description:
name: flutter_local_notifications_platform_interface
sha256: "85f8d07fe708c1bdcf45037f2c0109753b26ae077e9d9e899d55971711a4ea66"
url: "https://pub.dev"
source: hosted
version: "7.2.0"
flutter_secure_storage:
dependency: "direct main"
description:
name: flutter_secure_storage
sha256: da922f2aab2d733db7e011a6bcc4a825b844892d4edd6df83ff156b09a9b2e40
url: "https://pub.dev"
source: hosted
version: "10.0.0"
flutter_secure_storage_darwin:
dependency: transitive
description:
name: flutter_secure_storage_darwin
sha256: "8878c25136a79def1668c75985e8e193d9d7d095453ec28730da0315dc69aee3"
url: "https://pub.dev"
source: hosted
version: "0.2.0"
flutter_secure_storage_linux:
dependency: transitive
description:
name: flutter_secure_storage_linux
sha256: "2b5c76dce569ab752d55a1cee6a2242bcc11fdba927078fb88c503f150767cda"
url: "https://pub.dev"
source: hosted
version: "3.0.0"
flutter_secure_storage_platform_interface:
dependency: transitive
description:
name: flutter_secure_storage_platform_interface
sha256: "8ceea1223bee3c6ac1a22dabd8feefc550e4729b3675de4b5900f55afcb435d6"
url: "https://pub.dev"
source: hosted
version: "2.0.1"
flutter_secure_storage_web:
dependency: transitive
description:
name: flutter_secure_storage_web
sha256: "6a1137df62b84b54261dca582c1c09ea72f4f9a4b2fcee21b025964132d5d0c3"
url: "https://pub.dev"
source: hosted
version: "2.1.0"
flutter_secure_storage_windows:
dependency: transitive
description:
name: flutter_secure_storage_windows
sha256: "3b7c8e068875dfd46719ff57c90d8c459c87f2302ed6b00ff006b3c9fcad1613"
url: "https://pub.dev"
source: hosted
version: "4.1.0"
flutter_test:
dependency: "direct dev"
description: flutter
source: sdk
version: "0.0.0"
flutter_web_plugins:
dependency: transitive
description: flutter
source: sdk
version: "0.0.0"
glob:
dependency: transitive
description:
name: glob
sha256: c3f1ee72c96f8f78935e18aa8cecced9ab132419e8625dc187e1c2408efc20de
url: "https://pub.dev"
source: hosted
version: "2.1.3"
graphs:
dependency: transitive
description:
name: graphs
sha256: "741bbf84165310a68ff28fe9e727332eef1407342fca52759cb21ad8177bb8d0"
url: "https://pub.dev"
source: hosted
version: "2.3.2"
hooks:
dependency: transitive
description:
name: hooks
sha256: e79ed1e8e1929bc6ecb6ec85f0cb519c887aa5b423705ded0d0f2d9226def388
url: "https://pub.dev"
source: hosted
version: "1.0.2"
http_multi_server:
dependency: transitive
description:
name: http_multi_server
sha256: aa6199f908078bb1c5efb8d8638d4ae191aac11b311132c3ef48ce352fb52ef8
url: "https://pub.dev"
source: hosted
version: "3.2.2"
http_parser:
dependency: transitive
description:
name: http_parser
sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571"
url: "https://pub.dev"
source: hosted
version: "4.1.2"
io:
dependency: transitive
description:
name: io
sha256: dfd5a80599cf0165756e3181807ed3e77daf6dd4137caaad72d0b7931597650b
url: "https://pub.dev"
source: hosted
version: "1.0.5"
js:
dependency: transitive
description:
name: js
sha256: "53385261521cc4a0c4658fd0ad07a7d14591cf8fc33abbceae306ddb974888dc"
url: "https://pub.dev"
source: hosted
version: "0.7.2"
js_notifications:
dependency: transitive
description:
name: js_notifications
sha256: "980280649b29d618669866bdbf99e4a813009033101a434652d231eaf976c975"
url: "https://pub.dev"
source: hosted
version: "0.0.5"
json_annotation:
dependency: "direct main"
description:
name: json_annotation
sha256: cb09e7dac6210041fad964ed7fbee004f14258b4eca4040f72d1234062ace4c8
url: "https://pub.dev"
source: hosted
version: "4.11.0"
json_serializable:
dependency: "direct dev"
description:
name: json_serializable
sha256: "44729f5c45748e6748f6b9a57ab8f7e4336edc8ae41fc295070e3814e616a6c0"
url: "https://pub.dev"
source: hosted
version: "6.13.0"
leak_tracker:
dependency: transitive
description:
name: leak_tracker
sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de"
url: "https://pub.dev"
source: hosted
version: "11.0.2"
leak_tracker_flutter_testing:
dependency: transitive
description:
name: leak_tracker_flutter_testing
sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1"
url: "https://pub.dev"
source: hosted
version: "3.0.10"
leak_tracker_testing:
dependency: transitive
description:
name: leak_tracker_testing
sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1"
url: "https://pub.dev"
source: hosted
version: "3.0.2"
lints:
dependency: transitive
description:
name: lints
sha256: "976c774dd944a42e83e2467f4cc670daef7eed6295b10b36ae8c85bcbf828235"
url: "https://pub.dev"
source: hosted
version: "4.0.0"
logging:
dependency: transitive
description:
name: logging
sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61
url: "https://pub.dev"
source: hosted
version: "1.3.0"
matcher:
dependency: transitive
description:
name: matcher
sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861
url: "https://pub.dev"
source: hosted
version: "0.12.19"
material_color_utilities:
dependency: transitive
description:
name: material_color_utilities
sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b"
url: "https://pub.dev"
source: hosted
version: "0.13.0"
meta:
dependency: transitive
description:
name: meta
sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394"
url: "https://pub.dev"
source: hosted
version: "1.17.0"
mime:
dependency: transitive
description:
name: mime
sha256: "41a20518f0cb1256669420fdba0cd90d21561e560ac240f26ef8322e45bb7ed6"
url: "https://pub.dev"
source: hosted
version: "2.0.0"
native_toolchain_c:
dependency: transitive
description:
name: native_toolchain_c
sha256: "92b2ca62c8bd2b8d2f267cdfccf9bfbdb7322f778f8f91b3ce5b5cda23a3899f"
url: "https://pub.dev"
source: hosted
version: "0.17.5"
nested:
dependency: transitive
description:
name: nested
sha256: "03bac4c528c64c95c722ec99280375a6f2fc708eec17c7b3f07253b626cd2a20"
url: "https://pub.dev"
source: hosted
version: "1.0.0"
objective_c:
dependency: transitive
description:
name: objective_c
sha256: "100a1c87616ab6ed41ec263b083c0ef3261ee6cd1dc3b0f35f8ddfa4f996fe52"
url: "https://pub.dev"
source: hosted
version: "9.3.0"
package_config:
dependency: transitive
description:
name: package_config
sha256: f096c55ebb7deb7e384101542bfba8c52696c1b56fca2eb62827989ef2353bbc
url: "https://pub.dev"
source: hosted
version: "2.2.0"
path:
dependency: transitive
description:
name: path
sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5"
url: "https://pub.dev"
source: hosted
version: "1.9.1"
path_provider:
dependency: transitive
description:
name: path_provider
sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd"
url: "https://pub.dev"
source: hosted
version: "2.1.5"
path_provider_android:
dependency: transitive
description:
name: path_provider_android
sha256: f2c65e21139ce2c3dad46922be8272bb5963516045659e71bb16e151c93b580e
url: "https://pub.dev"
source: hosted
version: "2.2.22"
path_provider_foundation:
dependency: transitive
description:
name: path_provider_foundation
sha256: "2a376b7d6392d80cd3705782d2caa734ca4727776db0b6ec36ef3f1855197699"
url: "https://pub.dev"
source: hosted
version: "2.6.0"
path_provider_linux:
dependency: transitive
description:
name: path_provider_linux
sha256: f7a1fe3a634fe7734c8d3f2766ad746ae2a2884abe22e241a8b301bf5cac3279
url: "https://pub.dev"
source: hosted
version: "2.2.1"
path_provider_platform_interface:
dependency: transitive
description:
name: path_provider_platform_interface
sha256: "88f5779f72ba699763fa3a3b06aa4bf6de76c8e5de842cf6f29e2e06476c2334"
url: "https://pub.dev"
source: hosted
version: "2.1.2"
path_provider_windows:
dependency: transitive
description:
name: path_provider_windows
sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7
url: "https://pub.dev"
source: hosted
version: "2.3.0"
petitparser:
dependency: transitive
description:
name: petitparser
sha256: "91bd59303e9f769f108f8df05e371341b15d59e995e6806aefab827b58336675"
url: "https://pub.dev"
source: hosted
version: "7.0.2"
platform:
dependency: transitive
description:
name: platform
sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984"
url: "https://pub.dev"
source: hosted
version: "3.1.6"
plugin_platform_interface:
dependency: transitive
description:
name: plugin_platform_interface
sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02"
url: "https://pub.dev"
source: hosted
version: "2.1.8"
pool:
dependency: transitive
description:
name: pool
sha256: "978783255c543aa3586a1b3c21f6e9d720eb315376a915872c61ef8b5c20177d"
url: "https://pub.dev"
source: hosted
version: "1.5.2"
provider:
dependency: "direct main"
description:
name: provider
sha256: "4e82183fa20e5ca25703ead7e05de9e4cceed1fbd1eadc1ac3cb6f565a09f272"
url: "https://pub.dev"
source: hosted
version: "6.1.5+1"
pub_semver:
dependency: transitive
description:
name: pub_semver
sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585"
url: "https://pub.dev"
source: hosted
version: "2.2.0"
pubspec_parse:
dependency: transitive
description:
name: pubspec_parse
sha256: "0560ba233314abbed0a48a2956f7f022cce7c3e1e73df540277da7544cad4082"
url: "https://pub.dev"
source: hosted
version: "1.5.0"
shelf:
dependency: transitive
description:
name: shelf
sha256: e7dd780a7ffb623c57850b33f43309312fc863fb6aa3d276a754bb299839ef12
url: "https://pub.dev"
source: hosted
version: "1.4.2"
shelf_web_socket:
dependency: transitive
description:
name: shelf_web_socket
sha256: "3632775c8e90d6c9712f883e633716432a27758216dfb61bd86a8321c0580925"
url: "https://pub.dev"
source: hosted
version: "3.0.0"
simple_print:
dependency: transitive
description:
name: simple_print
sha256: "49b6796fb93b557bbba4eca687b8521d3d20ffee47d74d8a0857f6ee0727042b"
url: "https://pub.dev"
source: hosted
version: "0.0.1+2"
sky_engine:
dependency: transitive
description: flutter
source: sdk
version: "0.0.0"
source_gen:
dependency: transitive
description:
name: source_gen
sha256: "1d562a3c1f713904ebbed50d2760217fd8a51ca170ac4b05b0db490699dbac17"
url: "https://pub.dev"
source: hosted
version: "4.2.0"
source_helper:
dependency: transitive
description:
name: source_helper
sha256: "4a85e90b50694e652075cbe4575665539d253e6ec10e46e76b45368ab5e3caae"
url: "https://pub.dev"
source: hosted
version: "1.3.10"
source_span:
dependency: transitive
description:
name: source_span
sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab"
url: "https://pub.dev"
source: hosted
version: "1.10.2"
stack_trace:
dependency: transitive
description:
name: stack_trace
sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1"
url: "https://pub.dev"
source: hosted
version: "1.12.1"
stream_channel:
dependency: transitive
description:
name: stream_channel
sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d"
url: "https://pub.dev"
source: hosted
version: "2.1.4"
stream_transform:
dependency: transitive
description:
name: stream_transform
sha256: ad47125e588cfd37a9a7f86c7d6356dde8dfe89d071d293f80ca9e9273a33871
url: "https://pub.dev"
source: hosted
version: "2.1.1"
string_scanner:
dependency: transitive
description:
name: string_scanner
sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43"
url: "https://pub.dev"
source: hosted
version: "1.4.1"
term_glyph:
dependency: transitive
description:
name: term_glyph
sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e"
url: "https://pub.dev"
source: hosted
version: "1.2.2"
test_api:
dependency: transitive
description:
name: test_api
sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a"
url: "https://pub.dev"
source: hosted
version: "0.7.10"
timezone:
dependency: transitive
description:
name: timezone
sha256: "2236ec079a174ce07434e89fcd3fcda430025eb7692244139a9cf54fdcf1fc7d"
url: "https://pub.dev"
source: hosted
version: "0.9.4"
twilio_voice:
dependency: "direct main"
description:
name: twilio_voice
sha256: "010ac416dc8bcc842486407aec2e6f97fd5bb34b521c04fd4a4a5710f9ec045b"
url: "https://pub.dev"
source: hosted
version: "0.3.2+2"
typed_data:
dependency: transitive
description:
name: typed_data
sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006
url: "https://pub.dev"
source: hosted
version: "1.4.0"
uuid:
dependency: transitive
description:
name: uuid
sha256: "1fef9e8e11e2991bb773070d4656b7bd5d850967a2456cfc83cf47925ba79489"
url: "https://pub.dev"
source: hosted
version: "4.5.3"
vector_math:
dependency: transitive
description:
name: vector_math
sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b
url: "https://pub.dev"
source: hosted
version: "2.2.0"
vm_service:
dependency: transitive
description:
name: vm_service
sha256: "45caa6c5917fa127b5dbcfbd1fa60b14e583afdc08bfc96dda38886ca252eb60"
url: "https://pub.dev"
source: hosted
version: "15.0.2"
watcher:
dependency: transitive
description:
name: watcher
sha256: "1398c9f081a753f9226febe8900fce8f7d0a67163334e1c94a2438339d79d635"
url: "https://pub.dev"
source: hosted
version: "1.2.1"
web:
dependency: transitive
description:
name: web
sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a"
url: "https://pub.dev"
source: hosted
version: "1.1.1"
web_callkit:
dependency: transitive
description:
name: web_callkit
sha256: ca05b0fd79366ea072c1ea4982c8a7880ad219e4d1cc74a3a541b010533febee
url: "https://pub.dev"
source: hosted
version: "0.0.4+1"
web_socket:
dependency: transitive
description:
name: web_socket
sha256: "34d64019aa8e36bf9842ac014bb5d2f5586ca73df5e4d9bf5c936975cae6982c"
url: "https://pub.dev"
source: hosted
version: "1.0.1"
web_socket_channel:
dependency: transitive
description:
name: web_socket_channel
sha256: d645757fb0f4773d602444000a8131ff5d48c9e47adfe9772652dd1a4f2d45c8
url: "https://pub.dev"
source: hosted
version: "3.0.3"
win32:
dependency: transitive
description:
name: win32
sha256: d7cb55e04cd34096cd3a79b3330245f54cb96a370a1c27adb3c84b917de8b08e
url: "https://pub.dev"
source: hosted
version: "5.15.0"
xdg_directories:
dependency: transitive
description:
name: xdg_directories
sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15"
url: "https://pub.dev"
source: hosted
version: "1.1.0"
xml:
dependency: transitive
description:
name: xml
sha256: "971043b3a0d3da28727e40ed3e0b5d18b742fa5a68665cca88e74b7876d5e025"
url: "https://pub.dev"
source: hosted
version: "6.6.1"
yaml:
dependency: transitive
description:
name: yaml
sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce
url: "https://pub.dev"
source: hosted
version: "3.1.3"
sdks:
dart: ">=3.10.3 <4.0.0"
flutter: ">=3.38.4"

29
mobile/pubspec.yaml Normal file
View File

@@ -0,0 +1,29 @@
name: twp_softphone
description: TWP Softphone - VoIP client for Twilio WordPress Plugin
publish_to: 'none'
version: 1.0.0+1
environment:
sdk: ^3.5.0
dependencies:
flutter:
sdk: flutter
twilio_voice: ^0.3.0
firebase_core: ^3.0.0
firebase_messaging: ^15.0.0
dio: ^5.4.0
flutter_secure_storage: ^10.0.0
provider: ^6.1.0
flutter_local_notifications: ^17.0.0
json_annotation: ^4.8.0
dev_dependencies:
flutter_test:
sdk: flutter
flutter_lints: ^4.0.0
build_runner: ^2.4.0
json_serializable: ^6.7.0
flutter:
uses-material-design: true

View File

@@ -0,0 +1,7 @@
import 'package:flutter_test/flutter_test.dart';
void main() {
test('placeholder test', () {
expect(1 + 1, 2);
});
}