Compare commits
11 Commits
2026.01.24
...
2026.03.07
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0059c0d766 | ||
|
|
8cc6fa8c3c | ||
|
|
d41b6aa535 | ||
|
|
4da794ed0c | ||
|
|
5adfa694c1 | ||
|
|
826fd3ae39 | ||
|
|
5c6932f1d1 | ||
| 03692608cc | |||
| b95d1dc461 | |||
| 59df695530 | |||
| 03b6e5d70f |
13
.claude/settings.local.json
Normal file
13
.claude/settings.local.json
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"permissions": {
|
||||||
|
"allow": [
|
||||||
|
"Bash(scp:*)",
|
||||||
|
"Bash(grep:*)",
|
||||||
|
"Bash(git add:*)",
|
||||||
|
"Bash(git commit:*)",
|
||||||
|
"Bash(git push)"
|
||||||
|
],
|
||||||
|
"deny": [],
|
||||||
|
"defaultMode": "acceptEdits"
|
||||||
|
}
|
||||||
|
}
|
||||||
97
CLAUDE.md
97
CLAUDE.md
@@ -6,8 +6,26 @@
|
|||||||
- **URL**: `https://phone.cloud-hosting.io/`
|
- **URL**: `https://phone.cloud-hosting.io/`
|
||||||
- **Deployment**: rsync to Docker (remote server only, not local)
|
- **Deployment**: rsync to Docker (remote server only, not local)
|
||||||
- **SDK**: Twilio PHP SDK v8.7.0
|
- **SDK**: Twilio PHP SDK v8.7.0
|
||||||
|
- **PHP**: 8.0+ required
|
||||||
|
- **Optional**: AWS SDK (`aws/aws-sdk-php`) for SNS SMS provider
|
||||||
- **External SDK**: `wp-content/twilio-sdk/` (survives plugin updates)
|
- **External SDK**: `wp-content/twilio-sdk/` (survives plugin updates)
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
- **Install SDK (recommended)**: `./install-twilio-sdk-external.sh` (installs to `wp-content/twilio-sdk/`)
|
||||||
|
- **Install SDK (internal)**: `./install-twilio-sdk.sh` (installs to `vendor/`, lost on plugin update)
|
||||||
|
- **Test SDK**: `php test-sdk.php`
|
||||||
|
- **Composer install SDK**: `composer install-sdk`
|
||||||
|
- **Deploy**: rsync to Docker (remote server, see production path above)
|
||||||
|
- **CI/CD**: Gitea workflows in `.gitea/workflows/` — `release.yml`, `update-version.yml`
|
||||||
|
|
||||||
|
## Directory Structure
|
||||||
|
- `twilio-wp-plugin.php` — Main plugin file, constants, SDK loading
|
||||||
|
- `includes/` — All backend classes (28 class files)
|
||||||
|
- `admin/` — Admin UI class (`TWP_Admin`), mobile app settings page
|
||||||
|
- `assets/js/` — Browser phone JS, service worker
|
||||||
|
- `assets/images/`, `assets/sounds/` — Static assets
|
||||||
|
- `.gitea/workflows/` — CI/CD (release, version update)
|
||||||
|
|
||||||
## Phone Variable Names
|
## Phone Variable Names
|
||||||
**Use**: `incoming_number`, `agent_number`, `customer_number`, `workflow_number`, `queue_number`, `default_number`
|
**Use**: `incoming_number`, `agent_number`, `customer_number`, `workflow_number`, `queue_number`, `default_number`
|
||||||
**Don't use**: `from_number`, `to_number`, `phone_number`, `$agent_phone`
|
**Don't use**: `from_number`, `to_number`, `phone_number`, `$agent_phone`
|
||||||
@@ -18,11 +36,20 @@
|
|||||||
- **TWP_Admin**: Has `find_customer_call_leg()` - CRITICAL for call control
|
- **TWP_Admin**: Has `find_customer_call_leg()` - CRITICAL for call control
|
||||||
- **TWP_TTS_Helper**: ElevenLabs/Alice fallback, 30-day cache
|
- **TWP_TTS_Helper**: ElevenLabs/Alice fallback, 30-day cache
|
||||||
- **TWP_User_Queue_Manager**: Auto-creates queues/extensions (100-9999)
|
- **TWP_User_Queue_Manager**: Auto-creates queues/extensions (100-9999)
|
||||||
- **TWP_Webhooks**: 26 endpoints at `twilio-webhook/v1`
|
- **TWP_Webhooks**: 35 endpoints at `twilio-webhook/v1`
|
||||||
- **TWP_Activator**: Creates 15 DB tables, run `ensure_tables_exist()` if missing
|
- **TWP_Activator**: Creates 16 DB tables, run `ensure_tables_exist()` if missing
|
||||||
|
- **TWP_Core**: Main plugin orchestrator, hooks all classes together
|
||||||
|
- **TWP_SMS_Manager**: SMS abstraction with provider interface
|
||||||
|
- **TWP_SMS_Provider_Twilio** / **TWP_SMS_Provider_SNS**: SMS providers (Twilio default, AWS SNS optional)
|
||||||
|
- **TWP_Mobile_API**: REST API for mobile app
|
||||||
|
- **TWP_Mobile_Auth** / **TWP_Mobile_SSE** / **TWP_FCM**: Mobile auth, server-sent events, push notifications
|
||||||
|
- **TWP_Call_Queue**: Queue operations and management
|
||||||
|
- **TWP_Callback_Manager**: Callback request handling
|
||||||
|
- **TWP_Workflow**: Workflow step execution engine
|
||||||
|
- **TWP_Auto_Updater**: Plugin auto-update from Gitea releases
|
||||||
|
|
||||||
## Database
|
## Database
|
||||||
15 tables with `twp_` prefix. Key notes:
|
16 tables with `twp_` prefix. Key notes:
|
||||||
- `twp_call_queues`: User queues (general/personal/hold)
|
- `twp_call_queues`: User queues (general/personal/hold)
|
||||||
- `twp_agent_status`: Has `auto_busy_at` for 1-min auto-revert
|
- `twp_agent_status`: Has `auto_busy_at` for 1-min auto-revert
|
||||||
- `twp_queued_calls`: Uses `enqueued_at` not `joined_at`
|
- `twp_queued_calls`: Uses `enqueued_at` not `joined_at`
|
||||||
@@ -41,32 +68,6 @@ $api->update_call($customer_call_sid, ['twiml' => $twiml_xml]);
|
|||||||
- Queue: Pass `waitUrl` as option in `enqueue()`
|
- Queue: Pass `waitUrl` as option in `enqueue()`
|
||||||
- TwiML: Use SDK classes, not raw XML
|
- TwiML: Use SDK classes, not raw XML
|
||||||
|
|
||||||
## Recent Changes (v2.3.0)
|
|
||||||
- Browser phone moved to admin-only
|
|
||||||
- Call control uses `find_customer_call_leg()` to prevent disconnections
|
|
||||||
- Auto-creates user queues/extensions when needed
|
|
||||||
- Firefox support added
|
|
||||||
- 1-min agent status auto-revert
|
|
||||||
|
|
||||||
## SDK Installation
|
|
||||||
- **External SDK (Recommended)**: Use `install-twilio-sdk-external.sh` to install SDK to `wp-content/twilio-sdk/`
|
|
||||||
- Survives WordPress plugin updates
|
|
||||||
- SDK location defined by `TWP_EXTERNAL_SDK_DIR` constant
|
|
||||||
- Loading priority: External first, then internal `vendor/` fallback
|
|
||||||
- **Internal SDK (Alternative)**: Use `install-twilio-sdk.sh` to install to `vendor/`
|
|
||||||
- Will be deleted when WordPress updates the plugin
|
|
||||||
- Requires reinstallation after each plugin update
|
|
||||||
- **SDK Loading**: Plugin checks external location first via autoloader, falls back to internal
|
|
||||||
- **Post-Update Detection**: Hook on `upgrader_process_complete` checks SDK status and shows warning
|
|
||||||
|
|
||||||
## Browser Phone Configuration
|
|
||||||
- **Edge Location Setting**: Configurable via Settings → Twilio Edge Location
|
|
||||||
- Default: `roaming` (auto-select closest edge)
|
|
||||||
- Options: ashburn, umatilla, dublin, frankfurt, singapore, sydney, tokyo, sao-paulo
|
|
||||||
- Stored in: `twp_twilio_edge` option
|
|
||||||
- Used by: Browser phone JavaScript for WebRTC connection
|
|
||||||
- Critical: Wrong edge causes immediate call failures (e.g., US calls with Sydney edge)
|
|
||||||
|
|
||||||
## Development Notes
|
## Development Notes
|
||||||
- **API**: E.164 format (+1XXXXXXXXXX)
|
- **API**: E.164 format (+1XXXXXXXXXX)
|
||||||
- **Database**: Use `$wpdb`, prepared statements
|
- **Database**: Use `$wpdb`, prepared statements
|
||||||
@@ -79,30 +80,24 @@ $api->update_call($customer_call_sid, ['twiml' => $twiml_xml]);
|
|||||||
- User-specific queues with extensions
|
- User-specific queues with extensions
|
||||||
- Browser phone at `admin.php?page=twilio-wp-browser-phone`
|
- Browser phone at `admin.php?page=twilio-wp-browser-phone`
|
||||||
- ElevenLabs TTS with Alice fallback
|
- ElevenLabs TTS with Alice fallback
|
||||||
- 68 AJAX actions, 26 REST endpoints
|
- 77 AJAX actions, 35 REST endpoints
|
||||||
|
- Browser phone moved to admin-only (v2.3.0)
|
||||||
|
- Firefox, Chrome, Safari, Edge support
|
||||||
|
- 1-min agent status auto-revert
|
||||||
|
|
||||||
## Recent Technical Changes (v2.8.9)
|
## SDK Loading
|
||||||
|
- **External SDK (Recommended)**: `wp-content/twilio-sdk/` — survives plugin updates
|
||||||
|
- **Internal SDK**: `vendor/` — deleted on plugin update, needs reinstall
|
||||||
|
- Loading priority: External first (`TWP_EXTERNAL_SDK_DIR`), then internal fallback
|
||||||
|
- Post-update hook (`upgrader_process_complete`) warns if SDK missing
|
||||||
|
|
||||||
### SDK Persistence Between Plugin Updates
|
## Browser Phone Configuration
|
||||||
- **Problem**: WordPress plugin updates delete entire plugin folder including `vendor/` SDK
|
- **Edge Location**: `twp_twilio_edge` option, default `roaming`
|
||||||
- **Solution**: External SDK installation at `wp-content/twilio-sdk/` survives updates
|
- Options: roaming, ashburn, umatilla, dublin, frankfurt, singapore, sydney, tokyo, sao-paulo
|
||||||
- **Implementation**:
|
- Wrong edge causes immediate call failures (e.g., US calls with Sydney edge)
|
||||||
- New constant: `TWP_EXTERNAL_SDK_DIR` points to `wp-content/twilio-sdk/`
|
|
||||||
- Loading priority in `twp_check_sdk_installation()`: External first, internal fallback
|
|
||||||
- Classes updated: `TWP_Twilio_API`, `TWP_Webhooks` constructors check external location first
|
|
||||||
- New script: `install-twilio-sdk-external.sh` automates external installation
|
|
||||||
- Post-update hook: `twp_check_sdk_after_update()` detects missing SDK after updates
|
|
||||||
- Admin notices: `twp_sdk_missing_notice()` shows both installation options
|
|
||||||
- Warning system: `twp_show_sdk_update_warning()` via transient after plugin updates
|
|
||||||
|
|
||||||
### US Calls Failing Fix (Browser Phone)
|
## Changelog
|
||||||
- **Problem**: Browser phone had hardcoded `edge: 'sydney'`, causing US calls to fail with immediate HANGUP
|
See `README.md` for detailed version history. Current version: v2.8.9.
|
||||||
- **Solution**: Configurable edge location via WordPress settings
|
|
||||||
- **Implementation**:
|
|
||||||
- New setting: `twp_twilio_edge` with default value `roaming`
|
|
||||||
- Settings UI: Dropdown in admin settings with 8 edge options
|
|
||||||
- Browser phone JS: Uses `get_option('twp_twilio_edge', 'roaming')` instead of hardcoded value
|
|
||||||
- Edge options: roaming, ashburn, umatilla, dublin, frankfurt, singapore, sydney, tokyo, sao-paulo
|
|
||||||
|
|
||||||
---
|
---
|
||||||
*Updated: Jan 2026*
|
*Updated: Mar 2026*
|
||||||
@@ -7016,7 +7016,7 @@ class TWP_Admin {
|
|||||||
<div class="phone-interface">
|
<div class="phone-interface">
|
||||||
<div class="phone-display">
|
<div class="phone-display">
|
||||||
<div id="phone-status">Ready</div>
|
<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="phone-number-display"></div>
|
||||||
<div id="call-timer" style="display: none;">00:00</div>
|
<div id="call-timer" style="display: none;">00:00</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -7445,6 +7445,11 @@ class TWP_Admin {
|
|||||||
}
|
}
|
||||||
</style>
|
</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 -->
|
<!-- Twilio Voice SDK v2 from unpkg CDN -->
|
||||||
<script src="https://unpkg.com/@twilio/voice-sdk@2.11.0/dist/twilio.min.js"></script>
|
<script src="https://unpkg.com/@twilio/voice-sdk@2.11.0/dist/twilio.min.js"></script>
|
||||||
<script>
|
<script>
|
||||||
@@ -8233,16 +8238,40 @@ class TWP_Admin {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Check if SDK loaded and initialize
|
// 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() {
|
$(window).on('load', function() {
|
||||||
setTimeout(function() {
|
if (typeof Twilio !== 'undefined' && !device) {
|
||||||
if (typeof Twilio === 'undefined') {
|
initializeBrowserPhone();
|
||||||
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);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Clean up on page unload
|
// Clean up on page unload
|
||||||
|
|||||||
@@ -36,7 +36,19 @@ if (isset($_POST['twp_test_notification']) && check_admin_referer('twp_mobile_se
|
|||||||
|
|
||||||
// Save settings
|
// Save settings
|
||||||
if (isset($_POST['twp_save_mobile_settings']) && check_admin_referer('twp_mobile_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_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_repo', sanitize_text_field($_POST['twp_gitea_repo']));
|
||||||
update_option('twp_gitea_token', sanitize_text_field($_POST['twp_gitea_token']));
|
update_option('twp_gitea_token', sanitize_text_field($_POST['twp_gitea_token']));
|
||||||
@@ -45,7 +57,9 @@ if (isset($_POST['twp_save_mobile_settings']) && check_admin_referer('twp_mobile
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Get current settings
|
// 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';
|
$auto_update_enabled = get_option('twp_auto_update_enabled', '1') === '1';
|
||||||
$gitea_repo = get_option('twp_gitea_repo', 'wp-plugins/twilio-wp-plugin');
|
$gitea_repo = get_option('twp_gitea_repo', 'wp-plugins/twilio-wp-plugin');
|
||||||
$gitea_token = get_option('twp_gitea_token', '');
|
$gitea_token = get_option('twp_gitea_token', '');
|
||||||
@@ -84,6 +98,12 @@ $total_sessions = $wpdb->get_var("SELECT COUNT(*) FROM $sessions_table");
|
|||||||
</div>
|
</div>
|
||||||
<?php endif; ?>
|
<?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">
|
<div class="twp-mobile-settings">
|
||||||
<!-- Mobile App Overview -->
|
<!-- Mobile App Overview -->
|
||||||
<div class="card" style="max-width: 100%; margin-bottom: 20px;">
|
<div class="card" style="max-width: 100%; margin-bottom: 20px;">
|
||||||
@@ -112,29 +132,48 @@ $total_sessions = $wpdb->get_var("SELECT COUNT(*) FROM $sessions_table");
|
|||||||
|
|
||||||
<!-- FCM Configuration -->
|
<!-- FCM Configuration -->
|
||||||
<div class="card" style="max-width: 100%; margin-bottom: 20px;">
|
<div class="card" style="max-width: 100%; margin-bottom: 20px;">
|
||||||
<h2>Firebase Cloud Messaging (FCM)</h2>
|
<h2>Firebase Cloud Messaging (FCM) — HTTP v2 API</h2>
|
||||||
<p>Configure FCM to enable push notifications for the mobile app.</p>
|
<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">
|
<table class="form-table">
|
||||||
<tr>
|
<tr>
|
||||||
<th scope="row">
|
<th scope="row">
|
||||||
<label for="twp_fcm_server_key">FCM Server Key</label>
|
<label for="twp_fcm_project_id">Firebase Project ID</label>
|
||||||
</th>
|
</th>
|
||||||
<td>
|
<td>
|
||||||
<input type="text"
|
<input type="text"
|
||||||
id="twp_fcm_server_key"
|
id="twp_fcm_project_id"
|
||||||
name="twp_fcm_server_key"
|
name="twp_fcm_project_id"
|
||||||
value="<?php echo esc_attr($fcm_server_key); ?>"
|
value="<?php echo esc_attr($fcm_project_id); ?>"
|
||||||
class="regular-text"
|
class="regular-text"
|
||||||
placeholder="AAAA...">
|
placeholder="my-project-12345">
|
||||||
<p class="description">
|
<p class="description">
|
||||||
Get your server key from Firebase Console > Project Settings > Cloud Messaging > Server Key
|
Found in Firebase Console > Project Settings > General > Project ID
|
||||||
</p>
|
</p>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</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 > Project Settings > Service Accounts > 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;">✓ Service account configured</p>
|
||||||
|
<?php endif; ?>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
<?php if (!empty($fcm_server_key)): ?>
|
<?php if ($fcm_sa_configured): ?>
|
||||||
<p>
|
<p>
|
||||||
<button type="submit" name="twp_test_notification" class="button">
|
<button type="submit" name="twp_test_notification" class="button">
|
||||||
Send Test Notification
|
Send Test Notification
|
||||||
@@ -273,6 +312,11 @@ $total_sessions = $wpdb->get_var("SELECT COUNT(*) FROM $sessions_table");
|
|||||||
<td>GET</td>
|
<td>GET</td>
|
||||||
<td>Server-Sent Events stream for real-time updates</td>
|
<td>Server-Sent Events stream for real-time updates</td>
|
||||||
</tr>
|
</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>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
|
|||||||
@@ -618,6 +618,13 @@ class TWP_Call_Queue {
|
|||||||
require_once dirname(__FILE__) . '/class-twp-agent-groups.php';
|
require_once dirname(__FILE__) . '/class-twp-agent-groups.php';
|
||||||
$members = TWP_Agent_Groups::get_group_members($queue->agent_group_id);
|
$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)) {
|
if (empty($members)) {
|
||||||
error_log("TWP: No members found in agent group {$queue->agent_group_id} for queue {$queue_id}");
|
error_log("TWP: No members found in agent group {$queue->agent_group_id} for queue {$queue_id}");
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -1,27 +1,33 @@
|
|||||||
<?php
|
<?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 {
|
class TWP_FCM {
|
||||||
|
|
||||||
private $server_key;
|
private $project_id;
|
||||||
private $fcm_url = 'https://fcm.googleapis.com/fcm/send';
|
private $service_account;
|
||||||
|
private $fcm_url_template = 'https://fcm.googleapis.com/v1/projects/%s/messages:send';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Constructor
|
* Constructor
|
||||||
*/
|
*/
|
||||||
public function __construct() {
|
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
|
* Send push notification to user's devices
|
||||||
*/
|
*/
|
||||||
public function send_notification($user_id, $title, $body, $data = array()) {
|
public function send_notification($user_id, $title, $body, $data = array(), $data_only = false) {
|
||||||
if (empty($this->server_key)) {
|
if (empty($this->project_id) || empty($this->service_account)) {
|
||||||
error_log('TWP FCM: Server key not configured');
|
error_log('TWP FCM: Project ID or service account not configured');
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -37,7 +43,7 @@ class TWP_FCM {
|
|||||||
$failed_tokens = array();
|
$failed_tokens = array();
|
||||||
|
|
||||||
foreach ($tokens as $token) {
|
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']) {
|
if ($result['success']) {
|
||||||
$success_count++;
|
$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()) {
|
private function send_to_token($token, $title, $body, $data = array(), $data_only = false) {
|
||||||
$notification = array(
|
$access_token = $this->get_access_token();
|
||||||
'title' => $title,
|
if (!$access_token) {
|
||||||
'body' => $body,
|
return array('success' => false, 'error' => 'auth_failed');
|
||||||
'sound' => 'default',
|
}
|
||||||
'priority' => 'high',
|
|
||||||
'click_action' => 'FLUTTER_NOTIFICATION_CLICK'
|
// 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(
|
if (!$data_only) {
|
||||||
'to' => $token,
|
$message['notification'] = array(
|
||||||
'notification' => $notification,
|
|
||||||
'data' => array_merge($data, array(
|
|
||||||
'title' => $title,
|
'title' => $title,
|
||||||
'body' => $body,
|
'body' => $body,
|
||||||
'timestamp' => time()
|
);
|
||||||
)),
|
$message['android']['notification'] = array(
|
||||||
'priority' => 'high'
|
'sound' => 'default',
|
||||||
);
|
'click_action' => 'FLUTTER_NOTIFICATION_CLICK',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$payload = array('message' => $message);
|
||||||
|
|
||||||
|
$url = sprintf($this->fcm_url_template, $this->project_id);
|
||||||
|
|
||||||
$headers = array(
|
$headers = array(
|
||||||
'Authorization: key=' . $this->server_key,
|
'Authorization: Bearer ' . $access_token,
|
||||||
'Content-Type: application/json'
|
'Content-Type: application/json'
|
||||||
);
|
);
|
||||||
|
|
||||||
$ch = curl_init();
|
$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_POST, true);
|
||||||
curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
|
curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
|
||||||
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
||||||
@@ -99,10 +124,14 @@ class TWP_FCM {
|
|||||||
if ($http_code !== 200) {
|
if ($http_code !== 200) {
|
||||||
error_log("TWP FCM: Failed to send notification. HTTP $http_code: $response");
|
error_log("TWP FCM: Failed to send notification. HTTP $http_code: $response");
|
||||||
|
|
||||||
// Check if token is invalid
|
|
||||||
$response_data = json_decode($response, true);
|
$response_data = json_decode($response, true);
|
||||||
if (isset($response_data['results'][0]['error']) &&
|
$error_code = isset($response_data['error']['details'][0]['errorCode'])
|
||||||
in_array($response_data['results'][0]['error'], array('InvalidRegistration', 'NotRegistered'))) {
|
? $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');
|
return array('success' => false, 'error' => 'invalid_token');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -112,6 +141,107 @@ class TWP_FCM {
|
|||||||
return array('success' => true);
|
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
|
* Get all active FCM tokens for a user
|
||||||
*/
|
*/
|
||||||
@@ -162,7 +292,7 @@ class TWP_FCM {
|
|||||||
'queue_name' => $queue_name
|
'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(
|
$data = array(
|
||||||
'type' => 'test',
|
'type' => 'test',
|
||||||
'test' => true
|
'test' => 'true'
|
||||||
);
|
);
|
||||||
|
|
||||||
return $this->send_notification($user_id, $title, $body, $data);
|
return $this->send_notification($user_id, $title, $body, $data);
|
||||||
|
|||||||
@@ -99,6 +99,20 @@ class TWP_Mobile_API {
|
|||||||
'callback' => array($this, 'update_agent_phone'),
|
'callback' => array($this, 'update_agent_phone'),
|
||||||
'permission_callback' => array($this->auth, 'verify_token')
|
'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')
|
||||||
|
));
|
||||||
|
|
||||||
|
// Phone numbers for caller ID
|
||||||
|
register_rest_route('twilio-mobile/v1', '/phone-numbers', array(
|
||||||
|
'methods' => 'GET',
|
||||||
|
'callback' => array($this, 'get_phone_numbers'),
|
||||||
|
'permission_callback' => array($this->auth, 'verify_token')
|
||||||
|
));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -155,39 +169,16 @@ class TWP_Mobile_API {
|
|||||||
return new WP_Error('invalid_status', 'Status must be available, busy, or offline', array('status' => 400));
|
return new WP_Error('invalid_status', 'Status must be available, busy, or offline', array('status' => 400));
|
||||||
}
|
}
|
||||||
|
|
||||||
global $wpdb;
|
require_once plugin_dir_path(__FILE__) . 'class-twp-agent-manager.php';
|
||||||
$table = $wpdb->prefix . 'twp_agent_status';
|
require_once plugin_dir_path(__FILE__) . 'class-twp-user-queue-manager.php';
|
||||||
|
|
||||||
// Check if status exists
|
|
||||||
$exists = $wpdb->get_var($wpdb->prepare(
|
|
||||||
"SELECT COUNT(*) FROM $table WHERE user_id = %d",
|
|
||||||
$user_id
|
|
||||||
));
|
|
||||||
|
|
||||||
$data = array(
|
|
||||||
'status' => $new_status,
|
|
||||||
'last_activity' => current_time('mysql')
|
|
||||||
);
|
|
||||||
|
|
||||||
|
// Handle login status change first (matches browser phone behavior)
|
||||||
if ($is_logged_in !== null) {
|
if ($is_logged_in !== null) {
|
||||||
$data['is_logged_in'] = $is_logged_in ? 1 : 0;
|
TWP_Agent_Manager::set_agent_login_status($user_id, (bool)$is_logged_in);
|
||||||
if ($is_logged_in) {
|
|
||||||
$data['logged_in_at'] = current_time('mysql');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($exists) {
|
// Set agent status (handles auto_busy_at and all status fields)
|
||||||
$wpdb->update(
|
TWP_Agent_Manager::set_agent_status($user_id, $new_status);
|
||||||
$table,
|
|
||||||
$data,
|
|
||||||
array('user_id' => $user_id),
|
|
||||||
array('%s', '%s'),
|
|
||||||
array('%d')
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
$data['user_id'] = $user_id;
|
|
||||||
$wpdb->insert($table, $data);
|
|
||||||
}
|
|
||||||
|
|
||||||
return new WP_REST_Response(array(
|
return new WP_REST_Response(array(
|
||||||
'success' => true,
|
'success' => true,
|
||||||
@@ -204,44 +195,42 @@ class TWP_Mobile_API {
|
|||||||
global $wpdb;
|
global $wpdb;
|
||||||
$queues_table = $wpdb->prefix . 'twp_call_queues';
|
$queues_table = $wpdb->prefix . 'twp_call_queues';
|
||||||
$calls_table = $wpdb->prefix . 'twp_queued_calls';
|
$calls_table = $wpdb->prefix . 'twp_queued_calls';
|
||||||
$assignments_table = $wpdb->prefix . 'twp_queue_assignments';
|
$groups_table = $wpdb->prefix . 'twp_group_members';
|
||||||
|
|
||||||
// Get queues assigned to this user
|
// Auto-create personal queues if they don't exist
|
||||||
$queue_ids = $wpdb->get_col($wpdb->prepare(
|
$extensions_table = $wpdb->prefix . 'twp_user_extensions';
|
||||||
"SELECT queue_id FROM $assignments_table WHERE user_id = %d",
|
$existing_extension = $wpdb->get_row($wpdb->prepare(
|
||||||
|
"SELECT extension FROM $extensions_table WHERE user_id = %d",
|
||||||
$user_id
|
$user_id
|
||||||
));
|
));
|
||||||
|
|
||||||
// Also include personal queues
|
if (!$existing_extension) {
|
||||||
$personal_queue_ids = $wpdb->get_col($wpdb->prepare(
|
require_once plugin_dir_path(__FILE__) . 'class-twp-user-queue-manager.php';
|
||||||
"SELECT id FROM $queues_table WHERE user_id = %d",
|
TWP_User_Queue_Manager::create_user_queues($user_id);
|
||||||
$user_id
|
|
||||||
));
|
|
||||||
|
|
||||||
$all_queue_ids = array_unique(array_merge($queue_ids, $personal_queue_ids));
|
|
||||||
|
|
||||||
if (empty($all_queue_ids)) {
|
|
||||||
return new WP_REST_Response(array(
|
|
||||||
'success' => true,
|
|
||||||
'queues' => array()
|
|
||||||
), 200);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$queue_ids_str = implode(',', array_map('intval', $all_queue_ids));
|
// Get queues where user is a member of the assigned agent group OR personal/hold queues
|
||||||
|
$queues = $wpdb->get_results($wpdb->prepare("
|
||||||
// Get queue information with call counts
|
SELECT DISTINCT
|
||||||
$queues = $wpdb->get_results("
|
|
||||||
SELECT
|
|
||||||
q.id,
|
q.id,
|
||||||
q.queue_name,
|
q.queue_name,
|
||||||
q.queue_type,
|
q.queue_type,
|
||||||
q.extension,
|
q.extension,
|
||||||
COUNT(c.id) as waiting_count
|
COUNT(c.id) as waiting_count
|
||||||
FROM $queues_table q
|
FROM $queues_table q
|
||||||
|
LEFT JOIN $groups_table gm ON gm.group_id = q.agent_group_id
|
||||||
LEFT JOIN $calls_table c ON q.id = c.queue_id AND c.status = 'waiting'
|
LEFT JOIN $calls_table c ON q.id = c.queue_id AND c.status = 'waiting'
|
||||||
WHERE q.id IN ($queue_ids_str)
|
WHERE (gm.user_id = %d AND gm.is_active = 1)
|
||||||
|
OR (q.user_id = %d AND q.queue_type IN ('personal', 'hold'))
|
||||||
GROUP BY q.id
|
GROUP BY q.id
|
||||||
");
|
ORDER BY
|
||||||
|
CASE
|
||||||
|
WHEN q.queue_type = 'personal' THEN 1
|
||||||
|
WHEN q.queue_type = 'hold' THEN 2
|
||||||
|
ELSE 3
|
||||||
|
END,
|
||||||
|
q.queue_name ASC
|
||||||
|
", $user_id, $user_id));
|
||||||
|
|
||||||
$result = array();
|
$result = array();
|
||||||
foreach ($queues as $queue) {
|
foreach ($queues as $queue) {
|
||||||
@@ -507,11 +496,46 @@ class TWP_Mobile_API {
|
|||||||
* Unhold a call (resume from hold queue)
|
* Unhold a call (resume from hold queue)
|
||||||
*/
|
*/
|
||||||
public function unhold_call($request) {
|
public function unhold_call($request) {
|
||||||
// Implementation would retrieve from hold queue and reconnect
|
$user_id = $this->auth->get_current_user_id();
|
||||||
return new WP_REST_Response(array(
|
$call_sid = $request['call_sid'];
|
||||||
'success' => true,
|
|
||||||
'message' => 'Unhold functionality - to be implemented with queue retrieval'
|
try {
|
||||||
), 501);
|
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 +665,80 @@ class TWP_Mobile_API {
|
|||||||
), 200);
|
), 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);
|
||||||
|
$clean_name = preg_replace('/[^a-zA-Z0-9]/', '', $user->user_login);
|
||||||
|
if (empty($clean_name)) {
|
||||||
|
$clean_name = 'user';
|
||||||
|
}
|
||||||
|
$identity = 'agent' . $user_id . $clean_name;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Ensure Twilio SDK autoloader is loaded
|
||||||
|
require_once plugin_dir_path(dirname(__FILE__)) . 'includes/class-twp-twilio-api.php';
|
||||||
|
new TWP_Twilio_API();
|
||||||
|
|
||||||
|
$account_sid = get_option('twp_twilio_account_sid');
|
||||||
|
$auth_token = get_option('twp_twilio_auth_token');
|
||||||
|
$twiml_app_sid = get_option('twp_twiml_app_sid');
|
||||||
|
|
||||||
|
if (empty($account_sid) || empty($auth_token) || empty($twiml_app_sid)) {
|
||||||
|
return new WP_Error('token_error', 'Twilio credentials not configured', array('status' => 500));
|
||||||
|
}
|
||||||
|
|
||||||
|
// AccessToken for mobile Voice SDK (not ClientToken which is browser-only)
|
||||||
|
$token = new \Twilio\Jwt\AccessToken($account_sid, $account_sid, $auth_token, 3600, $identity);
|
||||||
|
$voiceGrant = new \Twilio\Jwt\Grants\VoiceGrant();
|
||||||
|
$voiceGrant->setOutgoingApplicationSid($twiml_app_sid);
|
||||||
|
$voiceGrant->setIncomingAllow(true);
|
||||||
|
$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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get available Twilio phone numbers for caller ID
|
||||||
|
*/
|
||||||
|
public function get_phone_numbers($request) {
|
||||||
|
try {
|
||||||
|
require_once plugin_dir_path(dirname(__FILE__)) . 'includes/class-twp-twilio-api.php';
|
||||||
|
$twilio = new TWP_Twilio_API();
|
||||||
|
$result = $twilio->get_phone_numbers();
|
||||||
|
|
||||||
|
if (!$result['success']) {
|
||||||
|
return new WP_Error('twilio_error', $result['error'], array('status' => 500));
|
||||||
|
}
|
||||||
|
|
||||||
|
$phone_numbers = array();
|
||||||
|
foreach ($result['data']['incoming_phone_numbers'] as $number) {
|
||||||
|
$phone_numbers[] = array(
|
||||||
|
'phone_number' => $number['phone_number'],
|
||||||
|
'friendly_name' => $number['friendly_name'],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new WP_REST_Response(array(
|
||||||
|
'success' => true,
|
||||||
|
'phone_numbers' => $phone_numbers
|
||||||
|
), 200);
|
||||||
|
|
||||||
|
} catch (Exception $e) {
|
||||||
|
return new WP_Error('twilio_error', $e->getMessage(), array('status' => 500));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if user has access to a queue
|
* Check if user has access to a queue
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -142,38 +142,40 @@ class TWP_Mobile_SSE {
|
|||||||
global $wpdb;
|
global $wpdb;
|
||||||
$queues_table = $wpdb->prefix . 'twp_call_queues';
|
$queues_table = $wpdb->prefix . 'twp_call_queues';
|
||||||
$calls_table = $wpdb->prefix . 'twp_queued_calls';
|
$calls_table = $wpdb->prefix . 'twp_queued_calls';
|
||||||
$assignments_table = $wpdb->prefix . 'twp_queue_assignments';
|
$groups_table = $wpdb->prefix . 'twp_group_members';
|
||||||
|
|
||||||
// Get queue IDs
|
// Auto-create personal queues if they don't exist
|
||||||
$queue_ids = $wpdb->get_col($wpdb->prepare(
|
$extensions_table = $wpdb->prefix . 'twp_user_extensions';
|
||||||
"SELECT queue_id FROM $assignments_table WHERE user_id = %d",
|
$existing_extension = $wpdb->get_row($wpdb->prepare(
|
||||||
|
"SELECT extension FROM $extensions_table WHERE user_id = %d",
|
||||||
$user_id
|
$user_id
|
||||||
));
|
));
|
||||||
|
|
||||||
$personal_queue_ids = $wpdb->get_col($wpdb->prepare(
|
if (!$existing_extension) {
|
||||||
"SELECT id FROM $queues_table WHERE user_id = %d",
|
TWP_User_Queue_Manager::create_user_queues($user_id);
|
||||||
$user_id
|
|
||||||
));
|
|
||||||
|
|
||||||
$all_queue_ids = array_unique(array_merge($queue_ids, $personal_queue_ids));
|
|
||||||
|
|
||||||
if (empty($all_queue_ids)) {
|
|
||||||
return array();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$queue_ids_str = implode(',', array_map('intval', $all_queue_ids));
|
// Get queues where user is a member of the assigned agent group OR personal/hold queues
|
||||||
|
$queues = $wpdb->get_results($wpdb->prepare("
|
||||||
$queues = $wpdb->get_results("
|
SELECT DISTINCT
|
||||||
SELECT
|
|
||||||
q.id,
|
q.id,
|
||||||
q.queue_name,
|
q.queue_name,
|
||||||
COUNT(c.id) as waiting_count,
|
COUNT(c.id) as waiting_count,
|
||||||
MIN(c.enqueued_at) as oldest_call_time
|
MIN(c.enqueued_at) as oldest_call_time
|
||||||
FROM $queues_table q
|
FROM $queues_table q
|
||||||
|
LEFT JOIN $groups_table gm ON gm.group_id = q.agent_group_id
|
||||||
LEFT JOIN $calls_table c ON q.id = c.queue_id AND c.status = 'waiting'
|
LEFT JOIN $calls_table c ON q.id = c.queue_id AND c.status = 'waiting'
|
||||||
WHERE q.id IN ($queue_ids_str)
|
WHERE (gm.user_id = %d AND gm.is_active = 1)
|
||||||
|
OR (q.user_id = %d AND q.queue_type IN ('personal', 'hold'))
|
||||||
GROUP BY q.id
|
GROUP BY q.id
|
||||||
");
|
ORDER BY
|
||||||
|
CASE
|
||||||
|
WHEN q.queue_type = 'personal' THEN 1
|
||||||
|
WHEN q.queue_type = 'hold' THEN 2
|
||||||
|
ELSE 3
|
||||||
|
END,
|
||||||
|
q.queue_name ASC
|
||||||
|
", $user_id, $user_id));
|
||||||
|
|
||||||
$result = array();
|
$result = array();
|
||||||
foreach ($queues as $queue) {
|
foreach ($queues as $queue) {
|
||||||
|
|||||||
@@ -371,7 +371,13 @@ class TWP_Webhooks {
|
|||||||
|
|
||||||
if (isset($params['To']) && !empty($params['To'])) {
|
if (isset($params['To']) && !empty($params['To'])) {
|
||||||
$to_number = $params['To'];
|
$to_number = $params['To'];
|
||||||
$from_number = isset($params['From']) ? $params['From'] : '';
|
// Mobile SDK sends CallerId via extraOptions; browser sends From as phone number
|
||||||
|
$from_number = '';
|
||||||
|
if (!empty($params['CallerId']) && strpos($params['CallerId'], 'client:') !== 0) {
|
||||||
|
$from_number = $params['CallerId'];
|
||||||
|
} elseif (!empty($params['From']) && strpos($params['From'], 'client:') !== 0) {
|
||||||
|
$from_number = $params['From'];
|
||||||
|
}
|
||||||
|
|
||||||
// If it's an outgoing call to a phone number
|
// If it's an outgoing call to a phone number
|
||||||
if (strpos($to_number, 'client:') !== 0) {
|
if (strpos($to_number, 'client:') !== 0) {
|
||||||
@@ -477,6 +483,13 @@ class TWP_Webhooks {
|
|||||||
$twilio_api->send_sms($agent_phone, $message, $twilio_number);
|
$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', '');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -704,6 +717,13 @@ class TWP_Webhooks {
|
|||||||
$twilio_api->send_sms($agent_phone, $message, $twilio_number);
|
$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', '');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
45
mobile/.gitignore
vendored
Normal file
45
mobile/.gitignore
vendored
Normal 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
45
mobile/.metadata
Normal 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'
|
||||||
7
mobile/analysis_options.yaml
Normal file
7
mobile/analysis_options.yaml
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
include: package:flutter_lints/flutter.yaml
|
||||||
|
|
||||||
|
linter:
|
||||||
|
rules:
|
||||||
|
prefer_const_constructors: true
|
||||||
|
prefer_const_declarations: true
|
||||||
|
avoid_print: true
|
||||||
49
mobile/android/app/build.gradle
Normal file
49
mobile/android/app/build.gradle
Normal 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'
|
||||||
|
}
|
||||||
29
mobile/android/app/google-services.json
Normal file
29
mobile/android/app/google-services.json
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
{
|
||||||
|
"project_info": {
|
||||||
|
"project_number": "187457540438",
|
||||||
|
"project_id": "twp-softphone",
|
||||||
|
"storage_bucket": "twp-softphone.firebasestorage.app"
|
||||||
|
},
|
||||||
|
"client": [
|
||||||
|
{
|
||||||
|
"client_info": {
|
||||||
|
"mobilesdk_app_id": "1:187457540438:android:d6d777270c23f6660946f7",
|
||||||
|
"android_client_info": {
|
||||||
|
"package_name": "io.cloudhosting.twp.twp_softphone"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"oauth_client": [],
|
||||||
|
"api_key": [
|
||||||
|
{
|
||||||
|
"current_key": "AIzaSyAdGJrWPy9b9arqHnlY5G_hsGiDcm6cchA"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"services": {
|
||||||
|
"appinvite_service": {
|
||||||
|
"other_platform_oauth_client": []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"configuration_version": "1"
|
||||||
|
}
|
||||||
9
mobile/android/app/proguard-rules.pro
vendored
Normal file
9
mobile/android/app/proguard-rules.pro
vendored
Normal 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.** { *; }
|
||||||
7
mobile/android/app/src/debug/AndroidManifest.xml
Normal file
7
mobile/android/app/src/debug/AndroidManifest.xml
Normal 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>
|
||||||
65
mobile/android/app/src/main/AndroidManifest.xml
Normal file
65
mobile/android/app/src/main/AndroidManifest.xml
Normal 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>
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
package io.cloudhosting.twp.twp_softphone
|
||||||
|
|
||||||
|
import io.flutter.embedding.android.FlutterActivity
|
||||||
|
|
||||||
|
class MainActivity: FlutterActivity()
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
9
mobile/android/app/src/main/res/values/styles.xml
Normal file
9
mobile/android/app/src/main/res/values/styles.xml
Normal 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>
|
||||||
7
mobile/android/app/src/profile/AndroidManifest.xml
Normal file
7
mobile/android/app/src/profile/AndroidManifest.xml
Normal 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>
|
||||||
18
mobile/android/build.gradle
Normal file
18
mobile/android/build.gradle
Normal 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
|
||||||
|
}
|
||||||
3
mobile/android/gradle.properties
Normal file
3
mobile/android/gradle.properties
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
org.gradle.jvmargs=-Xmx4G -XX:MaxMetaspaceSize=2G -XX:+HeapDumpOnOutOfMemoryError
|
||||||
|
android.useAndroidX=true
|
||||||
|
android.enableJetifier=true
|
||||||
BIN
mobile/android/gradle/wrapper/gradle-wrapper.jar
vendored
Normal file
BIN
mobile/android/gradle/wrapper/gradle-wrapper.jar
vendored
Normal file
Binary file not shown.
5
mobile/android/gradle/wrapper/gradle-wrapper.properties
vendored
Normal file
5
mobile/android/gradle/wrapper/gradle-wrapper.properties
vendored
Normal 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
|
||||||
2
mobile/android/local.properties
Normal file
2
mobile/android/local.properties
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
flutter.sdk=/opt/flutter
|
||||||
|
sdk.dir=/opt/android-sdk
|
||||||
26
mobile/android/settings.gradle
Normal file
26
mobile/android/settings.gradle
Normal 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
65
mobile/lib/app.dart
Normal 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();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
8
mobile/lib/config/app_config.dart
Normal file
8
mobile/lib/config/app_config.dart
Normal 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
9
mobile/lib/main.dart
Normal 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());
|
||||||
|
}
|
||||||
38
mobile/lib/models/agent_status.dart
Normal file
38
mobile/lib/models/agent_status.dart
Normal 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'] ?? 'offline') as String),
|
||||||
|
isLoggedIn: json['is_logged_in'] == true || json['is_logged_in'] == 1 || json['is_logged_in'] == '1',
|
||||||
|
currentCallSid: json['current_call_sid'] as String?,
|
||||||
|
lastActivity: json['last_activity'] as String?,
|
||||||
|
availableForQueues: json['available_for_queues'] != false && json['available_for_queues'] != 0 && json['available_for_queues'] != '0',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
static AgentStatusValue _parseStatus(String s) {
|
||||||
|
switch (s) {
|
||||||
|
case 'available':
|
||||||
|
return AgentStatusValue.available;
|
||||||
|
case 'busy':
|
||||||
|
return AgentStatusValue.busy;
|
||||||
|
default:
|
||||||
|
return AgentStatusValue.offline;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
46
mobile/lib/models/call_info.dart
Normal file
46
mobile/lib/models/call_info.dart
Normal 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;
|
||||||
|
}
|
||||||
66
mobile/lib/models/queue_state.dart
Normal file
66
mobile/lib/models/queue_state.dart
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
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: _toInt(json['id']),
|
||||||
|
name: (json['name'] ?? '') as String,
|
||||||
|
type: (json['type'] ?? '') as String,
|
||||||
|
extension: json['extension'] as String?,
|
||||||
|
waitingCount: _toInt(json['waiting_count']),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
static int _toInt(dynamic value) {
|
||||||
|
if (value is int) return value;
|
||||||
|
if (value is String) return int.tryParse(value) ?? 0;
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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: _toInt(json['position']),
|
||||||
|
status: (json['status'] ?? '') as String,
|
||||||
|
waitTime: _toInt(json['wait_time']),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
static int _toInt(dynamic value) {
|
||||||
|
if (value is int) return value;
|
||||||
|
if (value is String) return int.tryParse(value) ?? 0;
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
28
mobile/lib/models/user.dart
Normal file
28
mobile/lib/models/user.dart
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
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: _toInt(json['user_id']),
|
||||||
|
login: (json['user_login'] ?? '') as String,
|
||||||
|
displayName: (json['display_name'] ?? '') as String,
|
||||||
|
email: json['email'] as String?,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
static int _toInt(dynamic value) {
|
||||||
|
if (value is int) return value;
|
||||||
|
if (value is String) return int.tryParse(value) ?? 0;
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
113
mobile/lib/providers/agent_provider.dart
Normal file
113
mobile/lib/providers/agent_provider.dart
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
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 PhoneNumber {
|
||||||
|
final String phoneNumber;
|
||||||
|
final String friendlyName;
|
||||||
|
PhoneNumber({required this.phoneNumber, required this.friendlyName});
|
||||||
|
factory PhoneNumber.fromJson(Map<String, dynamic> json) => PhoneNumber(
|
||||||
|
phoneNumber: json['phone_number'] as String,
|
||||||
|
friendlyName: json['friendly_name'] as String,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
class AgentProvider extends ChangeNotifier {
|
||||||
|
final ApiClient _api;
|
||||||
|
final SseService _sse;
|
||||||
|
|
||||||
|
AgentStatus? _status;
|
||||||
|
List<QueueInfo> _queues = [];
|
||||||
|
bool _sseConnected = false;
|
||||||
|
List<PhoneNumber> _phoneNumbers = [];
|
||||||
|
StreamSubscription? _sseSub;
|
||||||
|
StreamSubscription? _connSub;
|
||||||
|
|
||||||
|
AgentStatus? get status => _status;
|
||||||
|
List<QueueInfo> get queues => _queues;
|
||||||
|
bool get sseConnected => _sseConnected;
|
||||||
|
List<PhoneNumber> get phoneNumbers => _phoneNumbers;
|
||||||
|
|
||||||
|
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 (e) { debugPrint('AgentProvider.fetchStatus error: $e'); }
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (e) { debugPrint('AgentProvider.updateStatus error: $e'); }
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (e) { debugPrint('AgentProvider.fetchQueues error: $e'); }
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> fetchPhoneNumbers() async {
|
||||||
|
try {
|
||||||
|
final response = await _api.dio.get('/phone-numbers');
|
||||||
|
final data = response.data;
|
||||||
|
_phoneNumbers = (data['phone_numbers'] as List)
|
||||||
|
.map((p) => PhoneNumber.fromJson(p as Map<String, dynamic>))
|
||||||
|
.toList();
|
||||||
|
notifyListeners();
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('AgentProvider.fetchPhoneNumbers error: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> refresh() async {
|
||||||
|
await Future.wait([fetchStatus(), fetchQueues(), fetchPhoneNumbers()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
113
mobile/lib/providers/auth_provider.dart
Normal file
113
mobile/lib/providers/auth_provider.dart
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
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 (e) {
|
||||||
|
debugPrint('AuthProvider: push service init error: $e');
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await _voiceService.initialize();
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('AuthProvider: voice service init error: $e');
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await _sseService.connect();
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('AuthProvider: SSE connect error: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
143
mobile/lib/providers/call_provider.dart
Normal file
143
mobile/lib/providers/call_provider.dart
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
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> makeCall(String number, {String? callerId}) async {
|
||||||
|
_callInfo = _callInfo.copyWith(
|
||||||
|
state: CallState.connecting,
|
||||||
|
callerNumber: number,
|
||||||
|
);
|
||||||
|
notifyListeners();
|
||||||
|
await _voiceService.makeCall(number, callerId: callerId);
|
||||||
|
}
|
||||||
|
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
137
mobile/lib/screens/active_call_screen.dart
Normal file
137
mobile/lib/screens/active_call_screen.dart
Normal 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'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
210
mobile/lib/screens/dashboard_screen.dart
Normal file
210
mobile/lib/screens/dashboard_screen.dart
Normal file
@@ -0,0 +1,210 @@
|
|||||||
|
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/dialpad.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();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _showDialer(BuildContext context) {
|
||||||
|
final numberController = TextEditingController();
|
||||||
|
String? selectedCallerId;
|
||||||
|
|
||||||
|
showModalBottomSheet(
|
||||||
|
context: context,
|
||||||
|
isScrollControlled: true,
|
||||||
|
shape: const RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
|
||||||
|
),
|
||||||
|
builder: (ctx) {
|
||||||
|
final phoneNumbers = context.read<AgentProvider>().phoneNumbers;
|
||||||
|
return StatefulBuilder(
|
||||||
|
builder: (ctx, setSheetState) {
|
||||||
|
return Padding(
|
||||||
|
padding: EdgeInsets.only(
|
||||||
|
bottom: MediaQuery.of(ctx).viewInsets.bottom,
|
||||||
|
top: 16,
|
||||||
|
left: 16,
|
||||||
|
right: 16,
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
// Number display
|
||||||
|
TextField(
|
||||||
|
controller: numberController,
|
||||||
|
keyboardType: TextInputType.phone,
|
||||||
|
autofillHints: const [AutofillHints.telephoneNumber],
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: Theme.of(ctx).textTheme.headlineSmall,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
hintText: 'Enter phone number',
|
||||||
|
suffixIcon: IconButton(
|
||||||
|
icon: const Icon(Icons.backspace_outlined),
|
||||||
|
onPressed: () {
|
||||||
|
final text = numberController.text;
|
||||||
|
if (text.isNotEmpty) {
|
||||||
|
numberController.text =
|
||||||
|
text.substring(0, text.length - 1);
|
||||||
|
numberController.selection = TextSelection.fromPosition(
|
||||||
|
TextPosition(offset: numberController.text.length),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
// Caller ID selector
|
||||||
|
if (phoneNumbers.isNotEmpty) ...[
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
DropdownButtonFormField<String>(
|
||||||
|
value: selectedCallerId,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Caller ID',
|
||||||
|
isDense: true,
|
||||||
|
contentPadding: EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||||
|
),
|
||||||
|
items: [
|
||||||
|
const DropdownMenuItem<String>(
|
||||||
|
value: null,
|
||||||
|
child: Text('Default'),
|
||||||
|
),
|
||||||
|
...phoneNumbers.map((p) => DropdownMenuItem<String>(
|
||||||
|
value: p.phoneNumber,
|
||||||
|
child: Text('${p.friendlyName} (${p.phoneNumber})'),
|
||||||
|
)),
|
||||||
|
],
|
||||||
|
onChanged: (value) {
|
||||||
|
setSheetState(() {
|
||||||
|
selectedCallerId = value;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
// Dialpad
|
||||||
|
Dialpad(
|
||||||
|
onDigit: (digit) {
|
||||||
|
numberController.text += digit;
|
||||||
|
numberController.selection = TextSelection.fromPosition(
|
||||||
|
TextPosition(offset: numberController.text.length),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
onClose: () => Navigator.pop(ctx),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
// Call button
|
||||||
|
ElevatedButton.icon(
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
backgroundColor: Colors.green,
|
||||||
|
foregroundColor: Colors.white,
|
||||||
|
minimumSize: const Size(double.infinity, 48),
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(24),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
icon: const Icon(Icons.call),
|
||||||
|
label: const Text('Call'),
|
||||||
|
onPressed: () {
|
||||||
|
final number = numberController.text.trim();
|
||||||
|
if (number.isNotEmpty) {
|
||||||
|
context.read<CallProvider>().makeCall(number, callerId: selectedCallerId);
|
||||||
|
Navigator.pop(ctx);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@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())),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
floatingActionButton: FloatingActionButton(
|
||||||
|
onPressed: () => _showDialer(context),
|
||||||
|
child: const Icon(Icons.phone),
|
||||||
|
),
|
||||||
|
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),
|
||||||
|
)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
165
mobile/lib/screens/login_screen.dart
Normal file
165
mobile/lib/screens/login_screen.dart
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/services.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';
|
||||||
|
}
|
||||||
|
|
||||||
|
TextInput.finishAutofillContext();
|
||||||
|
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: AutofillGroup(
|
||||||
|
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,
|
||||||
|
autofillHints: const [AutofillHints.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(),
|
||||||
|
),
|
||||||
|
autofillHints: const [AutofillHints.username],
|
||||||
|
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,
|
||||||
|
autofillHints: const [AutofillHints.password],
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
68
mobile/lib/screens/settings_screen.dart
Normal file
68
mobile/lib/screens/settings_screen.dart
Normal 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);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
85
mobile/lib/services/api_client.dart
Normal file
85
mobile/lib/services/api_client.dart
Normal 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();
|
||||||
95
mobile/lib/services/auth_service.dart
Normal file
95
mobile/lib/services/auth_service.dart
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
78
mobile/lib/services/push_notification_service.dart
Normal file
78
mobile/lib/services/push_notification_service.dart
Normal 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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
119
mobile/lib/services/sse_service.dart
Normal file
119
mobile/lib/services/sse_service.dart
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
103
mobile/lib/services/voice_service.dart
Normal file
103
mobile/lib/services/voice_service.dart
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
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) {
|
||||||
|
debugPrint('VoiceService._fetchAndRegisterToken error: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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<bool> makeCall(String to, {String? callerId}) async {
|
||||||
|
try {
|
||||||
|
final extraOptions = <String, dynamic>{};
|
||||||
|
if (callerId != null && callerId.isNotEmpty) {
|
||||||
|
extraOptions['CallerId'] = callerId;
|
||||||
|
}
|
||||||
|
return await TwilioVoice.instance.call.place(
|
||||||
|
to: to,
|
||||||
|
from: _identity ?? '',
|
||||||
|
extraOptions: extraOptions,
|
||||||
|
) ?? false;
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('VoiceService.makeCall error: $e');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
51
mobile/lib/widgets/agent_status_toggle.dart
Normal file
51
mobile/lib/widgets/agent_status_toggle.dart
Normal 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);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
118
mobile/lib/widgets/call_controls.dart
Normal file
118
mobile/lib/widgets/call_controls.dart
Normal 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),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
55
mobile/lib/widgets/dialpad.dart
Normal file
55
mobile/lib/widgets/dialpad.dart
Normal 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'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
37
mobile/lib/widgets/queue_card.dart
Normal file
37
mobile/lib/widgets/queue_card.dart
Normal 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
890
mobile/pubspec.lock
Normal 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
29
mobile/pubspec.yaml
Normal 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
|
||||||
7
mobile/test/widget_test.dart
Normal file
7
mobile/test/widget_test.dart
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
test('placeholder test', () {
|
||||||
|
expect(1 + 1, 2);
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user