Compare commits
19 Commits
2026.01.24
...
feature/we
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
46fc27f9bf | ||
|
|
a2ea99bb09 | ||
|
|
d00a906d07 | ||
|
|
621b0890a9 | ||
|
|
4af4be94a4 | ||
|
|
78e6c5a4ee | ||
|
|
eedb7bdb8f | ||
|
|
f8c9c23077 | ||
|
|
5d3035a62c | ||
|
|
7df6090554 | ||
|
|
8cc6fa8c3c | ||
|
|
d41b6aa535 | ||
|
|
4da794ed0c | ||
|
|
5adfa694c1 | ||
|
|
826fd3ae39 | ||
|
|
5c6932f1d1 | ||
| 03692608cc | |||
| b95d1dc461 | |||
| 59df695530 |
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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
name: Update Plugin Version
|
|
||||||
|
|
||||||
on:
|
|
||||||
release:
|
|
||||||
types: [created, edited]
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
update-version:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- name: Checkout code
|
|
||||||
uses: actions/checkout@v3
|
|
||||||
with:
|
|
||||||
fetch-depth: 0
|
|
||||||
|
|
||||||
- name: Get release tag
|
|
||||||
id: get_tag
|
|
||||||
run: echo "TAG=${GITEA_REF#refs/tags/}" >> $GITEA_ENV
|
|
||||||
|
|
||||||
- name: Update version in plugin file
|
|
||||||
run: |
|
|
||||||
# Replace version in main plugin file (both header and constant)
|
|
||||||
sed -i "s/Version: {auto_update_value_on_deploy}/Version: ${{ env.TAG }}/" twilio-wp-plugin.php
|
|
||||||
sed -i "s/TWP_VERSION', '{auto_update_value_on_deploy}/TWP_VERSION', '${{ env.TAG }}/" twilio-wp-plugin.php
|
|
||||||
|
|
||||||
# Verify changes
|
|
||||||
grep "Version:" twilio-wp-plugin.php
|
|
||||||
grep "TWP_VERSION" twilio-wp-plugin.php
|
|
||||||
|
|
||||||
- name: Commit changes
|
|
||||||
run: |
|
|
||||||
git config --local user.email "action@gitea.com"
|
|
||||||
git config --local user.name "Gitea Action"
|
|
||||||
git add twilio-wp-plugin.php
|
|
||||||
git commit -m "Update version to ${{ env.TAG }}" || echo "No changes to commit"
|
|
||||||
git push || echo "Nothing to push"
|
|
||||||
|
|
||||||
- name: Create plugin zip
|
|
||||||
run: |
|
|
||||||
mkdir -p /tmp/twilio-wp-plugin
|
|
||||||
rsync -av --exclude=".git" --exclude=".gitea" --exclude="build" . /tmp/twilio-wp-plugin/
|
|
||||||
cd /tmp
|
|
||||||
zip -r $GITEA_WORK_DIR/twilio-wp-plugin.zip twilio-wp-plugin
|
|
||||||
|
|
||||||
- name: Upload zip to release
|
|
||||||
uses: actions/upload-release-asset@v1
|
|
||||||
env:
|
|
||||||
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
|
||||||
with:
|
|
||||||
upload_url: ${{ gitea.event.release.upload_url }}
|
|
||||||
asset_path: twilio-wp-plugin.zip
|
|
||||||
asset_name: twilio-wp-plugin.zip
|
|
||||||
asset_content_type: application/zip
|
|
||||||
22
.gitignore
vendored
Normal file
22
.gitignore
vendored
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
# Dependencies
|
||||||
|
vendor/
|
||||||
|
node_modules/
|
||||||
|
|
||||||
|
# Build artifacts
|
||||||
|
mobile/android/.gradle/
|
||||||
|
mobile/android/build/
|
||||||
|
mobile/android/app/build/
|
||||||
|
mobile/build/
|
||||||
|
mobile/.dart_tool/
|
||||||
|
|
||||||
|
# Local config (machine-specific paths)
|
||||||
|
mobile/android/local.properties
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.idea/
|
||||||
|
*.iml
|
||||||
|
.vscode/
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
124
CLAUDE.md
124
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,51 @@ $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)
|
## Mobile App SSE (Server-Sent Events)
|
||||||
- **Problem**: Browser phone had hardcoded `edge: 'sydney'`, causing US calls to fail with immediate HANGUP
|
The mobile app uses SSE for real-time updates (queue changes, agent status). If SSE doesn't work (green dot stays red), the app automatically falls back to 5-second polling.
|
||||||
- **Solution**: Configurable edge location via WordPress settings
|
|
||||||
- **Implementation**:
|
### Apache + PHP-FPM Buffering Fix
|
||||||
- New setting: `twp_twilio_edge` with default value `roaming`
|
`mod_proxy_fcgi` buffers PHP output by default, which breaks SSE streaming. Fix by adding a config file on the server:
|
||||||
- Settings UI: Dropdown in admin settings with 8 edge options
|
|
||||||
- Browser phone JS: Uses `get_option('twp_twilio_edge', 'roaming')` instead of hardcoded value
|
```bash
|
||||||
- Edge options: roaming, ashburn, umatilla, dublin, frankfurt, singapore, sydney, tokyo, sao-paulo
|
echo 'ProxyPassMatch "^/wp-json/twilio-mobile/v1/stream/events$" "unix:/run/php-fpm/www.sock|fcgi://localhost/home/shadowdao/public_html/index.php" flushpackets=on' > /etc/httpd/conf.d/twp-sse.conf
|
||||||
|
httpd -t && systemctl restart httpd
|
||||||
|
```
|
||||||
|
|
||||||
|
- **`flushpackets=on`** is the key — tells Apache to flush PHP-FPM output immediately
|
||||||
|
- This is a `ProxyPassMatch` directive — **cannot** go in `.htaccess`, must be server config
|
||||||
|
- The PHP-FPM socket path (`/run/php-fpm/www.sock`) must match `/etc/httpd/conf.d/php.conf`
|
||||||
|
- If the server uses nginx instead of Apache, add `X-Accel-Buffering: no` header (already in PHP code)
|
||||||
|
- If behind HAProxy with HTTP/2, the issue is Apache→client buffering, not HTTP/2 framing
|
||||||
|
|
||||||
|
### Diagnosis
|
||||||
|
```bash
|
||||||
|
# Check PHP-FPM proxy config
|
||||||
|
grep -r "fcgi\|php-fpm" /etc/httpd/conf.d/
|
||||||
|
# Check if flushpackets is configured
|
||||||
|
grep -r "flushpackets" /etc/httpd/conf.d/
|
||||||
|
# Test SSE endpoint (should stream data, not hang)
|
||||||
|
curl -N -H "Authorization: Bearer TOKEN" https://phone.cloud-hosting.io/wp-json/twilio-mobile/v1/stream/events
|
||||||
|
```
|
||||||
|
|
||||||
|
## Changelog
|
||||||
|
See `README.md` for detailed version history. Current version: v2.8.9.
|
||||||
|
|
||||||
---
|
---
|
||||||
*Updated: Jan 2026*
|
*Updated: Mar 2026*
|
||||||
@@ -1580,6 +1580,123 @@ class TWP_Admin {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<hr>
|
||||||
|
|
||||||
|
<h2>Automatic Updates</h2>
|
||||||
|
<?php
|
||||||
|
require_once TWP_PLUGIN_DIR . 'includes/class-twp-auto-updater.php';
|
||||||
|
$updater = new TWP_Auto_Updater();
|
||||||
|
|
||||||
|
// Handle manual update check
|
||||||
|
if (isset($_POST['twp_check_updates']) && check_admin_referer('twp_update_settings')) {
|
||||||
|
$update_result = $updater->manual_check_for_updates();
|
||||||
|
if (isset($update_result)) {
|
||||||
|
echo '<div class="notice notice-' . ($update_result['update_available'] ? 'warning' : 'success') . ' is-dismissible">';
|
||||||
|
echo '<p><strong>' . esc_html($update_result['message']) . '</strong></p></div>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle save update settings
|
||||||
|
if (isset($_POST['twp_save_update_settings']) && check_admin_referer('twp_update_settings')) {
|
||||||
|
update_option('twp_auto_update_enabled', isset($_POST['twp_auto_update_enabled']) ? '1' : '0');
|
||||||
|
update_option('twp_gitea_repo', sanitize_text_field($_POST['twp_gitea_repo']));
|
||||||
|
update_option('twp_gitea_token', sanitize_text_field($_POST['twp_gitea_token']));
|
||||||
|
echo '<div class="notice notice-success is-dismissible"><p><strong>Update settings saved.</strong></p></div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
$update_status = $updater->get_update_status();
|
||||||
|
$auto_update_enabled = get_option('twp_auto_update_enabled', '1') === '1';
|
||||||
|
$gitea_repo = get_option('twp_gitea_repo', 'wp-plugins/twilio-wp-plugin');
|
||||||
|
$gitea_token = get_option('twp_gitea_token', '');
|
||||||
|
?>
|
||||||
|
<form method="post" action="">
|
||||||
|
<?php wp_nonce_field('twp_update_settings'); ?>
|
||||||
|
<div class="card">
|
||||||
|
<table class="form-table">
|
||||||
|
<tr>
|
||||||
|
<th scope="row">Current Version</th>
|
||||||
|
<td>
|
||||||
|
<strong><?php echo esc_html($update_status['current_version']); ?></strong>
|
||||||
|
<?php if ($update_status['update_available']): ?>
|
||||||
|
<span style="color: #d63638; margin-left: 10px;">
|
||||||
|
Update available: <?php echo esc_html($update_status['latest_version']); ?>
|
||||||
|
</span>
|
||||||
|
<?php else: ?>
|
||||||
|
<span style="color: #00a32a; margin-left: 10px;">Up to date</span>
|
||||||
|
<?php endif; ?>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th scope="row">
|
||||||
|
<label for="twp_auto_update_enabled">Enable Auto-Updates</label>
|
||||||
|
</th>
|
||||||
|
<td>
|
||||||
|
<label>
|
||||||
|
<input type="checkbox"
|
||||||
|
id="twp_auto_update_enabled"
|
||||||
|
name="twp_auto_update_enabled"
|
||||||
|
value="1"
|
||||||
|
<?php checked($auto_update_enabled); ?>>
|
||||||
|
Automatically check for updates every 12 hours
|
||||||
|
</label>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th scope="row">
|
||||||
|
<label for="twp_gitea_repo">Gitea Repository</label>
|
||||||
|
</th>
|
||||||
|
<td>
|
||||||
|
<input type="text"
|
||||||
|
id="twp_gitea_repo"
|
||||||
|
name="twp_gitea_repo"
|
||||||
|
value="<?php echo esc_attr($gitea_repo); ?>"
|
||||||
|
class="regular-text"
|
||||||
|
placeholder="org/repo-name">
|
||||||
|
<p class="description">
|
||||||
|
Format: organization/repository (e.g., wp-plugins/twilio-wp-plugin)
|
||||||
|
</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th scope="row">
|
||||||
|
<label for="twp_gitea_token">Gitea Access Token</label>
|
||||||
|
</th>
|
||||||
|
<td>
|
||||||
|
<input type="password"
|
||||||
|
id="twp_gitea_token"
|
||||||
|
name="twp_gitea_token"
|
||||||
|
value="<?php echo esc_attr($gitea_token); ?>"
|
||||||
|
class="regular-text">
|
||||||
|
<p class="description">
|
||||||
|
Optional. Required only for private repositories.
|
||||||
|
</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th scope="row">Last Update Check</th>
|
||||||
|
<td>
|
||||||
|
<?php
|
||||||
|
$last_check = $update_status['last_check'];
|
||||||
|
if ($last_check > 0) {
|
||||||
|
echo esc_html(human_time_diff($last_check, current_time('timestamp')) . ' ago');
|
||||||
|
} else {
|
||||||
|
echo 'Never';
|
||||||
|
}
|
||||||
|
?>
|
||||||
|
<button type="submit" name="twp_check_updates" class="button" style="margin-left: 15px;">
|
||||||
|
Check Now
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
<p>
|
||||||
|
<button type="submit" name="twp_save_update_settings" class="button button-primary">
|
||||||
|
Save Update Settings
|
||||||
|
</button>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
</div>
|
</div>
|
||||||
<?php
|
<?php
|
||||||
}
|
}
|
||||||
@@ -7017,7 +7134,6 @@ class TWP_Admin {
|
|||||||
<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;">Loading...</div>
|
<div id="device-connection-status" style="font-size: 12px; color: #999; margin-top: 5px;">Loading...</div>
|
||||||
<div id="twp-debug-info" style="font-size: 10px; color: #666; margin-top: 3px;"></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>
|
||||||
@@ -7446,6 +7562,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>
|
||||||
@@ -7675,7 +7796,6 @@ class TWP_Admin {
|
|||||||
|
|
||||||
// Initialize the browser phone
|
// Initialize the browser phone
|
||||||
function initializeBrowserPhone() {
|
function initializeBrowserPhone() {
|
||||||
debugLog('initializeBrowserPhone called');
|
|
||||||
$('#phone-status').text('Initializing...');
|
$('#phone-status').text('Initializing...');
|
||||||
updateConnectionStatus('connecting');
|
updateConnectionStatus('connecting');
|
||||||
|
|
||||||
@@ -7753,16 +7873,13 @@ class TWP_Admin {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function setupTwilioDevice(token) {
|
async function setupTwilioDevice(token) {
|
||||||
debugLog('setupTwilioDevice called');
|
|
||||||
try {
|
try {
|
||||||
// Check if Twilio SDK is available
|
// Check if Twilio SDK is available
|
||||||
debugLog('Twilio check: ' + (typeof Twilio) + ', Device: ' + (typeof Twilio !== 'undefined' ? typeof Twilio.Device : 'N/A'));
|
|
||||||
if (typeof Twilio === 'undefined' || !Twilio.Device) {
|
if (typeof Twilio === 'undefined' || !Twilio.Device) {
|
||||||
throw new Error('Twilio Voice SDK not loaded');
|
throw new Error('Twilio Voice SDK not loaded');
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('Setting up Twilio Device...');
|
console.log('Setting up Twilio Device...');
|
||||||
debugLog('Creating Twilio.Device...');
|
|
||||||
updateConnectionStatus('connecting');
|
updateConnectionStatus('connecting');
|
||||||
|
|
||||||
// Request media permissions before setting up device
|
// Request media permissions before setting up device
|
||||||
@@ -7812,13 +7929,11 @@ class TWP_Admin {
|
|||||||
});
|
});
|
||||||
|
|
||||||
console.log('Twilio Device created with audio constraints:', audioConstraints);
|
console.log('Twilio Device created with audio constraints:', audioConstraints);
|
||||||
debugLog('Device created, setting up handlers...');
|
|
||||||
|
|
||||||
// Set up event handlers BEFORE registering
|
// Set up event handlers BEFORE registering
|
||||||
// Device registered and ready
|
// Device registered and ready
|
||||||
device.on('registered', function() {
|
device.on('registered', function() {
|
||||||
console.log('Device registered successfully');
|
console.log('Device registered successfully');
|
||||||
debugLog('Device REGISTERED!');
|
|
||||||
$('#phone-status').text('Ready').css('color', '#4CAF50');
|
$('#phone-status').text('Ready').css('color', '#4CAF50');
|
||||||
$('#call-btn').prop('disabled', false);
|
$('#call-btn').prop('disabled', false);
|
||||||
updateConnectionStatus('connected');
|
updateConnectionStatus('connected');
|
||||||
@@ -7900,13 +8015,10 @@ class TWP_Admin {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Register device AFTER setting up event handlers
|
// Register device AFTER setting up event handlers
|
||||||
debugLog('Calling device.register()...');
|
|
||||||
await device.register();
|
await device.register();
|
||||||
debugLog('device.register() completed');
|
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error setting up Twilio Device:', error);
|
console.error('Error setting up Twilio Device:', error);
|
||||||
debugLog('ERROR: ' + error.message);
|
|
||||||
showError('Failed to setup device: ' + error.message);
|
showError('Failed to setup device: ' + error.message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -8242,31 +8354,41 @@ class TWP_Admin {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Debug helper
|
// Check if SDK loaded and initialize
|
||||||
function debugLog(msg) {
|
// Poll for Twilio SDK availability (window.load may not fire on mobile)
|
||||||
console.log('TWP Debug: ' + msg);
|
var sdkCheckAttempts = 0;
|
||||||
var debugEl = $('#twp-debug-info');
|
var maxSdkCheckAttempts = 100; // 5 seconds max (100 * 50ms)
|
||||||
if (debugEl.length) {
|
|
||||||
debugEl.append(msg + '<br>');
|
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 if SDK loaded and initialize
|
// Check immediately - SDK script is synchronous so should be loaded
|
||||||
debugLog('jQuery ready');
|
// 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() {
|
||||||
debugLog('Window loaded');
|
if (typeof Twilio !== 'undefined' && !device) {
|
||||||
setTimeout(function() {
|
initializeBrowserPhone();
|
||||||
debugLog('Checking Twilio: ' + (typeof Twilio));
|
}
|
||||||
if (typeof Twilio === 'undefined') {
|
|
||||||
showError('Twilio Voice SDK failed to load. Please check your internet connection and try refreshing the page.');
|
|
||||||
console.error('Twilio SDK not found. Script may be blocked or failed to load.');
|
|
||||||
debugLog('SDK FAILED');
|
|
||||||
} else {
|
|
||||||
console.log('Twilio SDK loaded successfully');
|
|
||||||
debugLog('SDK OK, initializing...');
|
|
||||||
initializeBrowserPhone();
|
|
||||||
}
|
|
||||||
}, 1000);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Clean up on page unload
|
// Clean up on page unload
|
||||||
|
|||||||
@@ -13,13 +13,6 @@ if (!current_user_can('manage_options')) {
|
|||||||
wp_die(__('You do not have sufficient permissions to access this page.'));
|
wp_die(__('You do not have sufficient permissions to access this page.'));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle manual update check
|
|
||||||
if (isset($_POST['twp_check_updates']) && check_admin_referer('twp_mobile_settings')) {
|
|
||||||
require_once TWP_PLUGIN_DIR . 'includes/class-twp-auto-updater.php';
|
|
||||||
$updater = new TWP_Auto_Updater();
|
|
||||||
$update_result = $updater->manual_check_for_updates();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle test notification
|
// Handle test notification
|
||||||
if (isset($_POST['twp_test_notification']) && check_admin_referer('twp_mobile_settings')) {
|
if (isset($_POST['twp_test_notification']) && check_admin_referer('twp_mobile_settings')) {
|
||||||
require_once TWP_PLUGIN_DIR . 'includes/class-twp-fcm.php';
|
require_once TWP_PLUGIN_DIR . 'includes/class-twp-fcm.php';
|
||||||
@@ -36,25 +29,26 @@ 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']));
|
||||||
update_option('twp_auto_update_enabled', isset($_POST['twp_auto_update_enabled']) ? '1' : '0');
|
// Service account JSON — validate it parses as JSON before saving
|
||||||
update_option('twp_gitea_repo', sanitize_text_field($_POST['twp_gitea_repo']));
|
$sa_json_raw = isset($_POST['twp_fcm_service_account_json']) ? wp_unslash($_POST['twp_fcm_service_account_json']) : '';
|
||||||
update_option('twp_gitea_token', sanitize_text_field($_POST['twp_gitea_token']));
|
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', '');
|
||||||
|
}
|
||||||
$settings_saved = true;
|
$settings_saved = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get current settings
|
// Get current settings
|
||||||
$fcm_server_key = get_option('twp_fcm_server_key', '');
|
$fcm_project_id = get_option('twp_fcm_project_id', '');
|
||||||
$auto_update_enabled = get_option('twp_auto_update_enabled', '1') === '1';
|
$fcm_service_account_json = get_option('twp_fcm_service_account_json', '');
|
||||||
$gitea_repo = get_option('twp_gitea_repo', 'wp-plugins/twilio-wp-plugin');
|
$fcm_sa_configured = !empty($fcm_service_account_json) && !empty($fcm_project_id);
|
||||||
$gitea_token = get_option('twp_gitea_token', '');
|
|
||||||
|
|
||||||
// Get update status
|
|
||||||
require_once TWP_PLUGIN_DIR . 'includes/class-twp-auto-updater.php';
|
|
||||||
$updater = new TWP_Auto_Updater();
|
|
||||||
$update_status = $updater->get_update_status();
|
|
||||||
|
|
||||||
// Get mobile app statistics
|
// Get mobile app statistics
|
||||||
global $wpdb;
|
global $wpdb;
|
||||||
$sessions_table = $wpdb->prefix . 'twp_mobile_sessions';
|
$sessions_table = $wpdb->prefix . 'twp_mobile_sessions';
|
||||||
@@ -72,18 +66,18 @@ $total_sessions = $wpdb->get_var("SELECT COUNT(*) FROM $sessions_table");
|
|||||||
</div>
|
</div>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
|
|
||||||
<?php if (isset($update_result)): ?>
|
|
||||||
<div class="notice notice-<?php echo $update_result['update_available'] ? 'warning' : 'success'; ?> is-dismissible">
|
|
||||||
<p><strong><?php echo esc_html($update_result['message']); ?></strong></p>
|
|
||||||
</div>
|
|
||||||
<?php endif; ?>
|
|
||||||
|
|
||||||
<?php if (isset($notification_result)): ?>
|
<?php if (isset($notification_result)): ?>
|
||||||
<div class="notice notice-<?php echo $notification_result['success'] ? 'success' : 'error'; ?> is-dismissible">
|
<div class="notice notice-<?php echo $notification_result['success'] ? 'success' : 'error'; ?> is-dismissible">
|
||||||
<p><strong><?php echo esc_html($notification_result['message']); ?></strong></p>
|
<p><strong><?php echo esc_html($notification_result['message']); ?></strong></p>
|
||||||
</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 +106,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
|
||||||
@@ -144,91 +157,6 @@ $total_sessions = $wpdb->get_var("SELECT COUNT(*) FROM $sessions_table");
|
|||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Auto-Update Settings -->
|
|
||||||
<div class="card" style="max-width: 100%; margin-bottom: 20px;">
|
|
||||||
<h2>Automatic Updates</h2>
|
|
||||||
|
|
||||||
<table class="form-table">
|
|
||||||
<tr>
|
|
||||||
<th scope="row">Current Version</th>
|
|
||||||
<td>
|
|
||||||
<strong><?php echo esc_html($update_status['current_version']); ?></strong>
|
|
||||||
<?php if ($update_status['update_available']): ?>
|
|
||||||
<span style="color: #d63638; margin-left: 10px;">
|
|
||||||
⚠ Update available: <?php echo esc_html($update_status['latest_version']); ?>
|
|
||||||
</span>
|
|
||||||
<?php else: ?>
|
|
||||||
<span style="color: #00a32a; margin-left: 10px;">✓ Up to date</span>
|
|
||||||
<?php endif; ?>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<th scope="row">
|
|
||||||
<label for="twp_auto_update_enabled">Enable Auto-Updates</label>
|
|
||||||
</th>
|
|
||||||
<td>
|
|
||||||
<label>
|
|
||||||
<input type="checkbox"
|
|
||||||
id="twp_auto_update_enabled"
|
|
||||||
name="twp_auto_update_enabled"
|
|
||||||
value="1"
|
|
||||||
<?php checked($auto_update_enabled); ?>>
|
|
||||||
Automatically check for updates every 12 hours
|
|
||||||
</label>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<th scope="row">
|
|
||||||
<label for="twp_gitea_repo">Gitea Repository</label>
|
|
||||||
</th>
|
|
||||||
<td>
|
|
||||||
<input type="text"
|
|
||||||
id="twp_gitea_repo"
|
|
||||||
name="twp_gitea_repo"
|
|
||||||
value="<?php echo esc_attr($gitea_repo); ?>"
|
|
||||||
class="regular-text"
|
|
||||||
placeholder="org/repo-name">
|
|
||||||
<p class="description">
|
|
||||||
Format: organization/repository (e.g., wp-plugins/twilio-wp-plugin)
|
|
||||||
</p>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<th scope="row">
|
|
||||||
<label for="twp_gitea_token">Gitea Access Token</label>
|
|
||||||
</th>
|
|
||||||
<td>
|
|
||||||
<input type="password"
|
|
||||||
id="twp_gitea_token"
|
|
||||||
name="twp_gitea_token"
|
|
||||||
value="<?php echo esc_attr($gitea_token); ?>"
|
|
||||||
class="regular-text"
|
|
||||||
placeholder="">
|
|
||||||
<p class="description">
|
|
||||||
Optional. Required only for private repositories. Create token at:
|
|
||||||
<a href="https://repo.anhonesthost.net/user/settings/applications" target="_blank">Gitea Settings > Applications</a>
|
|
||||||
</p>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<th scope="row">Last Update Check</th>
|
|
||||||
<td>
|
|
||||||
<?php
|
|
||||||
$last_check = $update_status['last_check'];
|
|
||||||
if ($last_check > 0) {
|
|
||||||
echo esc_html(human_time_diff($last_check, current_time('timestamp')) . ' ago');
|
|
||||||
} else {
|
|
||||||
echo 'Never';
|
|
||||||
}
|
|
||||||
?>
|
|
||||||
<button type="submit" name="twp_check_updates" class="button" style="margin-left: 15px;">
|
|
||||||
Check Now
|
|
||||||
</button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- API Documentation -->
|
<!-- API Documentation -->
|
||||||
<div class="card" style="max-width: 100%; margin-bottom: 20px;">
|
<div class="card" style="max-width: 100%; margin-bottom: 20px;">
|
||||||
<h2>API Endpoints</h2>
|
<h2>API Endpoints</h2>
|
||||||
@@ -273,6 +201,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>
|
||||||
|
|
||||||
|
|||||||
267
assets/mobile/phone-template.php
Normal file
267
assets/mobile/phone-template.php
Normal file
@@ -0,0 +1,267 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Mobile Phone Page Template
|
||||||
|
*
|
||||||
|
* This template is require'd from TWP_Mobile_Phone_Page::render_page().
|
||||||
|
* All PHP variables ($extension_data, $is_logged_in, $agent_status, etc.)
|
||||||
|
* are in scope from the calling method.
|
||||||
|
*
|
||||||
|
* @package Twilio_WP_Plugin
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Prevent direct access.
|
||||||
|
if (!defined('ABSPATH')) {
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
?><!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover">
|
||||||
|
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||||
|
<meta name="mobile-web-app-capable" content="yes">
|
||||||
|
<meta name="theme-color" content="#1a1a2e">
|
||||||
|
<title>Phone - <?php echo esc_html(get_bloginfo('name')); ?></title>
|
||||||
|
|
||||||
|
<!-- jQuery (WordPress bundled) -->
|
||||||
|
<script src="<?php echo includes_url('js/jquery/jquery.min.js'); ?>"></script>
|
||||||
|
|
||||||
|
<!-- Preload Twilio SDK -->
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<!-- Stylesheet -->
|
||||||
|
<link rel="stylesheet" href="<?php echo plugins_url('assets/mobile/phone.css', $plugin_file); ?>">
|
||||||
|
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="twp-app">
|
||||||
|
|
||||||
|
<!-- Agent Status Bar -->
|
||||||
|
<div class="agent-status-bar">
|
||||||
|
<div class="status-info">
|
||||||
|
<span class="extension-badge"><?php echo $extension_data ? esc_html($extension_data->extension) : '—'; ?></span>
|
||||||
|
|
||||||
|
<button id="login-toggle-btn" class="<?php echo $is_logged_in ? 'logged-in' : ''; ?>" onclick="toggleAgentLogin()">
|
||||||
|
<?php echo $is_logged_in ? 'Log Out' : 'Log In'; ?>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<select id="agent-status-select" onchange="updateAgentStatus(this.value)" <?php echo !$is_logged_in ? 'disabled' : ''; ?>>
|
||||||
|
<option value="available" <?php selected($agent_status->status ?? '', 'available'); ?>>Available</option>
|
||||||
|
<option value="busy" <?php selected($agent_status->status ?? '', 'busy'); ?>>Busy</option>
|
||||||
|
<option value="offline" <?php selected($agent_status->status ?? 'offline', 'offline'); ?>>Offline</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="agent-stats">
|
||||||
|
<span>Today: <strong><?php echo esc_html($agent_stats['calls_today']); ?></strong></span>
|
||||||
|
<span>Total: <strong><?php echo esc_html($agent_stats['total_calls']); ?></strong></span>
|
||||||
|
<span>Avg: <strong><?php echo round($agent_stats['avg_duration'] ?? 0); ?>s</strong></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Tab Navigation -->
|
||||||
|
<div class="tab-nav">
|
||||||
|
<button class="tab-btn active" data-tab="phone">Phone</button>
|
||||||
|
<button class="tab-btn" data-tab="recent">Recent</button>
|
||||||
|
<button class="tab-btn" data-tab="queues">Queues</button>
|
||||||
|
<button class="tab-btn" data-tab="settings">Settings</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Notices container -->
|
||||||
|
<div id="twp-notices"></div>
|
||||||
|
|
||||||
|
<!-- Error display -->
|
||||||
|
<div id="browser-phone-error" style="display:none;"></div>
|
||||||
|
|
||||||
|
<!-- Tab Content -->
|
||||||
|
<div class="tab-content">
|
||||||
|
|
||||||
|
<!-- Phone Tab -->
|
||||||
|
<div class="tab-pane active" id="tab-phone">
|
||||||
|
<div class="phone-interface">
|
||||||
|
<div class="phone-display">
|
||||||
|
<div id="phone-status">Ready</div>
|
||||||
|
<div id="device-connection-status">Loading...</div>
|
||||||
|
<div id="phone-number-display"></div>
|
||||||
|
<div id="call-timer" style="display:none;">00:00</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<input type="tel" id="phone-number-input" placeholder="Enter phone number" />
|
||||||
|
|
||||||
|
<div class="dialpad-grid">
|
||||||
|
<button class="dialpad-btn" data-digit="1">1</button>
|
||||||
|
<button class="dialpad-btn" data-digit="2">2<span>ABC</span></button>
|
||||||
|
<button class="dialpad-btn" data-digit="3">3<span>DEF</span></button>
|
||||||
|
<button class="dialpad-btn" data-digit="4">4<span>GHI</span></button>
|
||||||
|
<button class="dialpad-btn" data-digit="5">5<span>JKL</span></button>
|
||||||
|
<button class="dialpad-btn" data-digit="6">6<span>MNO</span></button>
|
||||||
|
<button class="dialpad-btn" data-digit="7">7<span>PQRS</span></button>
|
||||||
|
<button class="dialpad-btn" data-digit="8">8<span>TUV</span></button>
|
||||||
|
<button class="dialpad-btn" data-digit="9">9<span>WXYZ</span></button>
|
||||||
|
<button class="dialpad-btn" data-digit="*">*</button>
|
||||||
|
<button class="dialpad-btn" data-digit="0">0<span>+</span></button>
|
||||||
|
<button class="dialpad-btn" data-digit="#">#</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="phone-controls">
|
||||||
|
<button id="call-btn" class="btn-phone btn-call">
|
||||||
|
📞 Call
|
||||||
|
</button>
|
||||||
|
<button id="hangup-btn" class="btn-phone btn-hangup" style="display:none;">
|
||||||
|
❌ Hang Up
|
||||||
|
</button>
|
||||||
|
<button id="answer-btn" class="btn-phone btn-answer" style="display:none;">
|
||||||
|
📞 Answer
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="admin-call-controls-panel" style="display:none;">
|
||||||
|
<div class="call-controls-grid">
|
||||||
|
<button id="admin-hold-btn" class="btn-ctrl" title="Hold">
|
||||||
|
⏸ Hold
|
||||||
|
</button>
|
||||||
|
<button id="admin-transfer-btn" class="btn-ctrl" title="Transfer">
|
||||||
|
↪ Transfer
|
||||||
|
</button>
|
||||||
|
<button id="admin-requeue-btn" class="btn-ctrl" title="Requeue">
|
||||||
|
🔄 Requeue
|
||||||
|
</button>
|
||||||
|
<button id="admin-record-btn" class="btn-ctrl" title="Record">
|
||||||
|
⏺ Record
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Recent Tab -->
|
||||||
|
<div class="tab-pane" id="tab-recent">
|
||||||
|
<div class="recent-panel">
|
||||||
|
<div class="recent-header">
|
||||||
|
<h4>Recent Calls</h4>
|
||||||
|
<button type="button" id="clear-history-btn" class="btn-sm">Clear</button>
|
||||||
|
</div>
|
||||||
|
<div id="recent-call-list">
|
||||||
|
<div class="recent-empty">No calls yet this session.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Queues Tab -->
|
||||||
|
<div class="tab-pane" id="tab-queues">
|
||||||
|
<div class="queue-panel">
|
||||||
|
<div class="queue-header">
|
||||||
|
<h4>Your Queues</h4>
|
||||||
|
<?php if ($extension_data): ?>
|
||||||
|
<div class="user-extension-admin">Ext: <strong><?php echo esc_html($extension_data->extension); ?></strong></div>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
<div id="admin-queue-list">
|
||||||
|
<div class="queue-loading">Loading your queues...</div>
|
||||||
|
</div>
|
||||||
|
<div class="queue-actions">
|
||||||
|
<button type="button" id="admin-refresh-queues" class="btn-refresh">Refresh Queues</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Settings Tab -->
|
||||||
|
<div class="tab-pane" id="tab-settings">
|
||||||
|
<div class="settings-panel">
|
||||||
|
<!-- Caller ID -->
|
||||||
|
<div class="settings-section">
|
||||||
|
<h4>Outbound Caller ID</h4>
|
||||||
|
<select id="caller-id-select">
|
||||||
|
<option value="">Loading numbers...</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Auto-answer -->
|
||||||
|
<div class="settings-section">
|
||||||
|
<label><input type="checkbox" id="auto-answer" /> Auto-answer incoming calls</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Dark Mode -->
|
||||||
|
<div class="settings-section">
|
||||||
|
<h4>Appearance</h4>
|
||||||
|
<div class="dark-mode-options">
|
||||||
|
<button type="button" class="dark-mode-opt" data-theme="system">System</button>
|
||||||
|
<button type="button" class="dark-mode-opt" data-theme="light">Light</button>
|
||||||
|
<button type="button" class="dark-mode-opt" data-theme="dark">Dark</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Call Mode -->
|
||||||
|
<div class="settings-section">
|
||||||
|
<h4>Call Reception Mode</h4>
|
||||||
|
<div class="mode-selection">
|
||||||
|
<label class="mode-option <?php echo $current_mode === 'browser' ? 'active' : ''; ?>">
|
||||||
|
<input type="radio" name="call_mode" value="browser" <?php checked($current_mode, 'browser'); ?>>
|
||||||
|
<div class="mode-icon">💻</div>
|
||||||
|
<div class="mode-details">
|
||||||
|
<strong>Browser Phone</strong>
|
||||||
|
<small>Calls ring in this browser</small>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
<label class="mode-option <?php echo $current_mode === 'cell' ? 'active' : ''; ?>">
|
||||||
|
<input type="radio" name="call_mode" value="cell" <?php checked($current_mode, 'cell'); ?>>
|
||||||
|
<div class="mode-icon">📱</div>
|
||||||
|
<div class="mode-details">
|
||||||
|
<strong>Cell Phone</strong>
|
||||||
|
<small>Forward to your mobile</small>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="mode-status">
|
||||||
|
<div id="current-mode-display">
|
||||||
|
<strong>Current:</strong>
|
||||||
|
<span id="mode-text"><?php echo $current_mode === 'browser' ? 'Browser Phone' : 'Cell Phone'; ?></span>
|
||||||
|
</div>
|
||||||
|
<button type="button" id="save-mode-btn" style="display:none;">Save</button>
|
||||||
|
</div>
|
||||||
|
<div class="mode-info">
|
||||||
|
<div class="browser-mode-info" style="display:<?php echo $current_mode === 'browser' ? 'block' : 'none'; ?>;">
|
||||||
|
<p>Keep this page open to receive calls.</p>
|
||||||
|
</div>
|
||||||
|
<div class="cell-mode-info" style="display:<?php echo $current_mode === 'cell' ? 'block' : 'none'; ?>;">
|
||||||
|
<p>Calls forwarded to: <?php echo $user_phone ? esc_html($user_phone) : '<em>Not configured</em>'; ?></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<?php if (!$smart_routing_configured && current_user_can('manage_options')): ?>
|
||||||
|
<div class="setup-info">
|
||||||
|
<h4>Setup Required</h4>
|
||||||
|
<p>Update your phone number webhook to:</p>
|
||||||
|
<code><?php echo esc_html($smart_routing_webhook); ?></code>
|
||||||
|
<button type="button" class="btn-copy" onclick="copyToClipboard('<?php echo esc_js($smart_routing_webhook); ?>')">Copy</button>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div><!-- .tab-content -->
|
||||||
|
</div><!-- .twp-app -->
|
||||||
|
|
||||||
|
<!-- Configuration for JavaScript -->
|
||||||
|
<script>
|
||||||
|
window.twpConfig = {
|
||||||
|
ajaxUrl: <?php echo wp_json_encode($ajax_url); ?>,
|
||||||
|
nonce: <?php echo wp_json_encode($nonce); ?>,
|
||||||
|
ringtoneUrl: <?php echo wp_json_encode($ringtone_url); ?>,
|
||||||
|
phoneIconUrl: <?php echo wp_json_encode($phone_icon_url); ?>,
|
||||||
|
swUrl: <?php echo wp_json_encode($sw_url); ?>,
|
||||||
|
twilioEdge: <?php echo wp_json_encode($twilio_edge); ?>
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<!-- Twilio Voice SDK v2.11.0 -->
|
||||||
|
<script src="https://unpkg.com/@twilio/voice-sdk@2.11.0/dist/twilio.min.js"></script>
|
||||||
|
|
||||||
|
<!-- Phone JavaScript -->
|
||||||
|
<script src="<?php echo plugins_url('assets/mobile/phone.js', $plugin_file); ?>"></script>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
848
assets/mobile/phone.css
Normal file
848
assets/mobile/phone.css
Normal file
@@ -0,0 +1,848 @@
|
|||||||
|
/* ===================================================================
|
||||||
|
CSS Reset & Base
|
||||||
|
=================================================================== */
|
||||||
|
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--bg-primary: #f5f6fa;
|
||||||
|
--bg-secondary: #ffffff;
|
||||||
|
--bg-phone: #1a1a2e;
|
||||||
|
--bg-display: #16213e;
|
||||||
|
--text-primary: #2c3e50;
|
||||||
|
--text-secondary: #7f8c8d;
|
||||||
|
--text-light: #ffffff;
|
||||||
|
--accent: #2196F3;
|
||||||
|
--accent-dark: #1976D2;
|
||||||
|
--success: #4CAF50;
|
||||||
|
--warning: #FF9800;
|
||||||
|
--danger: #f44336;
|
||||||
|
--border: #e0e0e0;
|
||||||
|
--shadow: 0 2px 8px rgba(0,0,0,0.1);
|
||||||
|
--radius: 12px;
|
||||||
|
--safe-top: env(safe-area-inset-top, 0px);
|
||||||
|
--safe-bottom: env(safe-area-inset-bottom, 0px);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
:root:not(.light-mode) {
|
||||||
|
--bg-primary: #0f0f23;
|
||||||
|
--bg-secondary: #1a1a2e;
|
||||||
|
--bg-phone: #16213e;
|
||||||
|
--bg-display: #0a0a1a;
|
||||||
|
--text-primary: #ecf0f1;
|
||||||
|
--text-secondary: #95a5a6;
|
||||||
|
--border: #2c3e50;
|
||||||
|
--shadow: 0 2px 8px rgba(0,0,0,0.4);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Manual dark mode override via class on <html> */
|
||||||
|
:root.dark-mode {
|
||||||
|
--bg-primary: #0f0f23;
|
||||||
|
--bg-secondary: #1a1a2e;
|
||||||
|
--bg-phone: #16213e;
|
||||||
|
--bg-display: #0a0a1a;
|
||||||
|
--text-primary: #ecf0f1;
|
||||||
|
--text-secondary: #95a5a6;
|
||||||
|
--border: #2c3e50;
|
||||||
|
--shadow: 0 2px 8px rgba(0,0,0,0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
html, body {
|
||||||
|
height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
-webkit-text-size-adjust: 100%;
|
||||||
|
-webkit-tap-highlight-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===================================================================
|
||||||
|
Layout — full-screen flex column
|
||||||
|
=================================================================== */
|
||||||
|
.twp-app {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
max-width: 500px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding-top: var(--safe-top);
|
||||||
|
padding-bottom: var(--safe-bottom);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===================================================================
|
||||||
|
Agent Status Bar (compact)
|
||||||
|
=================================================================== */
|
||||||
|
.agent-status-bar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 8px 12px;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
flex-shrink: 0;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.status-info {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.extension-badge {
|
||||||
|
background: var(--accent);
|
||||||
|
color: #fff;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 10px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.agent-stats {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
#login-toggle-btn, #agent-status-select {
|
||||||
|
font-size: 12px;
|
||||||
|
padding: 3px 10px;
|
||||||
|
border-radius: 4px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
#login-toggle-btn.logged-in {
|
||||||
|
background: var(--danger);
|
||||||
|
color: #fff;
|
||||||
|
border-color: var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===================================================================
|
||||||
|
Notices
|
||||||
|
=================================================================== */
|
||||||
|
.twp-notice {
|
||||||
|
padding: 8px 12px;
|
||||||
|
margin: 6px 10px;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 13px;
|
||||||
|
animation: fadeIn 0.2s ease;
|
||||||
|
}
|
||||||
|
.twp-notice-success { background: #e8f5e9; color: #2e7d32; }
|
||||||
|
.twp-notice-error { background: #ffebee; color: #c62828; }
|
||||||
|
.twp-notice-info { background: #e3f2fd; color: #1565c0; }
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
:root:not(.light-mode) .twp-notice-success { background: #1b5e20; color: #a5d6a7; }
|
||||||
|
:root:not(.light-mode) .twp-notice-error { background: #b71c1c; color: #ef9a9a; }
|
||||||
|
:root:not(.light-mode) .twp-notice-info { background: #0d47a1; color: #90caf9; }
|
||||||
|
}
|
||||||
|
.dark-mode .twp-notice-success { background: #1b5e20; color: #a5d6a7; }
|
||||||
|
.dark-mode .twp-notice-error { background: #b71c1c; color: #ef9a9a; }
|
||||||
|
.dark-mode .twp-notice-info { background: #0d47a1; color: #90caf9; }
|
||||||
|
@keyframes fadeIn { from { opacity: 0; transform: translateY(-4px); } to { opacity: 1; transform: translateY(0); } }
|
||||||
|
|
||||||
|
/* ===================================================================
|
||||||
|
Tab Navigation
|
||||||
|
=================================================================== */
|
||||||
|
.tab-nav {
|
||||||
|
display: flex;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.tab-btn {
|
||||||
|
flex: 1;
|
||||||
|
padding: 10px 0;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
border-bottom: 3px solid transparent;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: color 0.2s, border-color 0.2s;
|
||||||
|
}
|
||||||
|
.tab-btn.active {
|
||||||
|
color: var(--accent);
|
||||||
|
border-bottom-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===================================================================
|
||||||
|
Tab Content
|
||||||
|
=================================================================== */
|
||||||
|
.tab-content {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
}
|
||||||
|
.tab-pane { display: none; height: 100%; }
|
||||||
|
.tab-pane.active { display: flex; flex-direction: column; }
|
||||||
|
|
||||||
|
/* ===================================================================
|
||||||
|
Phone Interface
|
||||||
|
=================================================================== */
|
||||||
|
.phone-interface {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
padding: 12px;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Display */
|
||||||
|
.phone-display {
|
||||||
|
background: var(--bg-display);
|
||||||
|
color: var(--text-light);
|
||||||
|
padding: 16px;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
#phone-status {
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--success);
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
#device-connection-status {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
#phone-number-display {
|
||||||
|
font-size: 20px;
|
||||||
|
min-height: 28px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
}
|
||||||
|
#call-timer {
|
||||||
|
font-size: 16px;
|
||||||
|
margin-top: 6px;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Input */
|
||||||
|
#phone-number-input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 12px;
|
||||||
|
font-size: 20px;
|
||||||
|
text-align: center;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
#phone-number-input:focus {
|
||||||
|
border-color: var(--accent);
|
||||||
|
box-shadow: 0 0 0 2px rgba(33,150,243,0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dialpad */
|
||||||
|
.dialpad-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.dialpad-btn {
|
||||||
|
padding: 14px 0;
|
||||||
|
font-size: 22px;
|
||||||
|
font-weight: 500;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
cursor: pointer;
|
||||||
|
-webkit-user-select: none;
|
||||||
|
user-select: none;
|
||||||
|
position: relative;
|
||||||
|
transition: background 0.1s;
|
||||||
|
min-height: 54px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
.dialpad-btn:active {
|
||||||
|
background: var(--accent);
|
||||||
|
color: #fff;
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
.dialpad-btn span {
|
||||||
|
display: block;
|
||||||
|
font-size: 9px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin-top: 1px;
|
||||||
|
letter-spacing: 2px;
|
||||||
|
}
|
||||||
|
.dialpad-btn:active span { color: rgba(255,255,255,0.8); }
|
||||||
|
|
||||||
|
/* Phone Controls */
|
||||||
|
.phone-controls {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.phone-controls .btn-phone {
|
||||||
|
flex: 1;
|
||||||
|
height: 50px;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
transition: opacity 0.2s;
|
||||||
|
}
|
||||||
|
.btn-call {
|
||||||
|
background: var(--success);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
.btn-hangup {
|
||||||
|
background: var(--danger);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
.btn-answer {
|
||||||
|
background: var(--success);
|
||||||
|
color: #fff;
|
||||||
|
animation: pulse 1.5s infinite;
|
||||||
|
}
|
||||||
|
@keyframes pulse {
|
||||||
|
0%, 100% { box-shadow: 0 0 0 0 rgba(76,175,80,0.4); }
|
||||||
|
50% { box-shadow: 0 0 0 10px rgba(76,175,80,0); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Call Controls (hold/transfer/requeue/record) */
|
||||||
|
.call-controls-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.call-controls-grid .btn-ctrl {
|
||||||
|
padding: 10px 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 6px;
|
||||||
|
transition: background 0.1s;
|
||||||
|
}
|
||||||
|
.btn-ctrl:active { background: #e3f2fd; }
|
||||||
|
.btn-ctrl.btn-active {
|
||||||
|
background: var(--accent);
|
||||||
|
color: #fff;
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Error display */
|
||||||
|
#browser-phone-error {
|
||||||
|
background: #ffebee;
|
||||||
|
color: #c62828;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
margin: 0 12px;
|
||||||
|
}
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
:root:not(.light-mode) #browser-phone-error { background: #b71c1c; color: #ef9a9a; }
|
||||||
|
}
|
||||||
|
.dark-mode #browser-phone-error { background: #b71c1c; color: #ef9a9a; }
|
||||||
|
|
||||||
|
/* ===================================================================
|
||||||
|
Settings Tab
|
||||||
|
=================================================================== */
|
||||||
|
.settings-panel {
|
||||||
|
padding: 12px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
.settings-section {
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
padding: 14px;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.settings-section h4 {
|
||||||
|
font-size: 14px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
color: var(--accent-dark);
|
||||||
|
}
|
||||||
|
.settings-section label {
|
||||||
|
font-size: 13px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
.settings-section select {
|
||||||
|
width: 100%;
|
||||||
|
padding: 8px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 14px;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
margin-top: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Call mode radio cards */
|
||||||
|
.mode-selection {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
margin: 10px 0;
|
||||||
|
}
|
||||||
|
.mode-option {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 12px;
|
||||||
|
border: 2px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
flex: 1;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
}
|
||||||
|
.mode-option.active {
|
||||||
|
border-color: var(--accent);
|
||||||
|
background: #e3f2fd;
|
||||||
|
}
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
:root:not(.light-mode) .mode-option.active { background: #0d47a1; }
|
||||||
|
}
|
||||||
|
.dark-mode .mode-option.active { background: #0d47a1; }
|
||||||
|
.mode-option input[type="radio"] { margin: 0 8px 0 0; }
|
||||||
|
.mode-icon { font-size: 20px; margin-right: 8px; }
|
||||||
|
.mode-details strong { display: block; font-size: 13px; }
|
||||||
|
.mode-details small { font-size: 11px; color: var(--text-secondary); }
|
||||||
|
|
||||||
|
.mode-status {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 8px;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.mode-info { margin-top: 8px; font-size: 12px; color: var(--text-secondary); }
|
||||||
|
|
||||||
|
#save-mode-btn {
|
||||||
|
padding: 6px 16px;
|
||||||
|
border: none;
|
||||||
|
background: var(--accent);
|
||||||
|
color: #fff;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 13px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Setup info box */
|
||||||
|
.setup-info {
|
||||||
|
background: #fff3cd;
|
||||||
|
padding: 12px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border-left: 4px solid #ffc107;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
:root:not(.light-mode) .setup-info { background: #4a3800; color: #ffe082; }
|
||||||
|
}
|
||||||
|
.dark-mode .setup-info { background: #4a3800; color: #ffe082; }
|
||||||
|
.setup-info h4 { margin-bottom: 6px; color: #856404; font-size: 13px; }
|
||||||
|
.setup-info code {
|
||||||
|
display: block;
|
||||||
|
padding: 6px 8px;
|
||||||
|
background: rgba(0,0,0,0.05);
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 11px;
|
||||||
|
word-break: break-all;
|
||||||
|
margin: 6px 0;
|
||||||
|
}
|
||||||
|
.btn-copy {
|
||||||
|
font-size: 11px;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===================================================================
|
||||||
|
Queue Tab
|
||||||
|
=================================================================== */
|
||||||
|
.queue-panel {
|
||||||
|
padding: 12px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
.queue-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.queue-header h4 { font-size: 15px; }
|
||||||
|
.user-extension-admin {
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
padding: 4px 10px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--accent-dark);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.queue-item {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 12px;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
.queue-item.queue-type-personal { border-left: 4px solid var(--success); }
|
||||||
|
.queue-item.queue-type-hold { border-left: 4px solid var(--warning); }
|
||||||
|
.queue-item.queue-type-general { border-left: 4px solid var(--accent); }
|
||||||
|
.queue-item.has-calls { background: #fff3cd; }
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
:root:not(.light-mode) .queue-item.has-calls { background: #4a3800; }
|
||||||
|
}
|
||||||
|
.dark-mode .queue-item.has-calls { background: #4a3800; }
|
||||||
|
.queue-info { flex: 1; }
|
||||||
|
.queue-name {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
.queue-details {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin-top: 3px;
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
.queue-waiting.has-calls {
|
||||||
|
color: var(--danger);
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
.queue-loading {
|
||||||
|
text-align: center;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-style: italic;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
.queue-actions { text-align: center; }
|
||||||
|
|
||||||
|
.btn-sm {
|
||||||
|
font-size: 12px;
|
||||||
|
padding: 6px 12px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.btn-sm:active { background: #e3f2fd; }
|
||||||
|
|
||||||
|
.btn-refresh {
|
||||||
|
padding: 8px 16px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===================================================================
|
||||||
|
Recent Tab
|
||||||
|
=================================================================== */
|
||||||
|
.recent-panel {
|
||||||
|
padding: 12px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
overflow-y: auto;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
.recent-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.recent-header h4 { font-size: 15px; }
|
||||||
|
.recent-empty {
|
||||||
|
text-align: center;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-style: italic;
|
||||||
|
padding: 40px 20px;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
.recent-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 12px;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
gap: 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.1s;
|
||||||
|
}
|
||||||
|
.recent-item:active { background: var(--bg-primary); }
|
||||||
|
.recent-direction {
|
||||||
|
font-size: 18px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 28px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.recent-info { flex: 1; min-width: 0; }
|
||||||
|
.recent-number {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--accent);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
.recent-meta {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin-top: 2px;
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
.recent-callback {
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding: 6px 12px;
|
||||||
|
background: var(--success);
|
||||||
|
color: #fff;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===================================================================
|
||||||
|
Dialog / Overlay (transfer, requeue)
|
||||||
|
=================================================================== */
|
||||||
|
.twp-overlay {
|
||||||
|
position: fixed;
|
||||||
|
top: 0; left: 0; right: 0; bottom: 0;
|
||||||
|
background: rgba(0,0,0,0.5);
|
||||||
|
z-index: 9999;
|
||||||
|
}
|
||||||
|
.twp-dialog {
|
||||||
|
position: fixed;
|
||||||
|
top: 50%; left: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
padding: 20px;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
box-shadow: 0 4px 20px rgba(0,0,0,0.3);
|
||||||
|
z-index: 10000;
|
||||||
|
width: 90%;
|
||||||
|
max-width: 420px;
|
||||||
|
max-height: 80vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
.twp-dialog h3 { margin: 0 0 12px 0; font-size: 16px; }
|
||||||
|
.twp-dialog input[type="text"],
|
||||||
|
.twp-dialog input[type="tel"] {
|
||||||
|
width: 100%;
|
||||||
|
padding: 10px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 14px;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
margin: 8px 0;
|
||||||
|
}
|
||||||
|
.twp-dialog .dialog-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 14px;
|
||||||
|
}
|
||||||
|
.btn-primary {
|
||||||
|
padding: 8px 18px;
|
||||||
|
border: none;
|
||||||
|
background: var(--accent);
|
||||||
|
color: #fff;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 13px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.btn-primary:disabled { opacity: 0.5; cursor: default; }
|
||||||
|
.btn-secondary {
|
||||||
|
padding: 8px 18px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 13px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-option, .queue-option {
|
||||||
|
padding: 10px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 6px;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.agent-option.selected, .queue-option.selected {
|
||||||
|
background: #e3f2fd;
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
:root:not(.light-mode) .agent-option.selected, :root:not(.light-mode) .queue-option.selected { background: #0d47a1; }
|
||||||
|
}
|
||||||
|
.dark-mode .agent-option.selected, .dark-mode .queue-option.selected { background: #0d47a1; }
|
||||||
|
|
||||||
|
/* ===================================================================
|
||||||
|
Z-Index Layering & Overlap Fixes
|
||||||
|
=================================================================== */
|
||||||
|
.twp-app { position: relative; z-index: 1; }
|
||||||
|
.agent-status-bar { position: relative; z-index: 10; }
|
||||||
|
.tab-nav { position: relative; z-index: 10; }
|
||||||
|
.tab-content { position: relative; z-index: 1; }
|
||||||
|
.phone-interface { position: relative; z-index: 1; overflow-y: auto; }
|
||||||
|
.phone-controls { position: relative; z-index: 2; }
|
||||||
|
#admin-call-controls-panel { position: relative; z-index: 2; margin-top: 4px; }
|
||||||
|
.call-controls-grid { position: relative; z-index: 2; }
|
||||||
|
.twp-overlay { z-index: 9999; }
|
||||||
|
.twp-dialog { z-index: 10000; }
|
||||||
|
|
||||||
|
/* Ensure tab panes scroll properly */
|
||||||
|
.tab-pane.active { overflow-y: auto; -webkit-overflow-scrolling: touch; }
|
||||||
|
#tab-phone.active { overflow: hidden; }
|
||||||
|
.phone-interface { flex: 1; overflow-y: auto; min-height: 0; }
|
||||||
|
|
||||||
|
/* Prevent call controls from overlapping dialpad */
|
||||||
|
.dialpad-grid { position: relative; z-index: 1; flex-shrink: 0; }
|
||||||
|
|
||||||
|
/* Dark mode for btn-ctrl active state */
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
:root:not(.light-mode) .btn-ctrl:active { background: #0d47a1; color: #fff; }
|
||||||
|
:root:not(.light-mode) .btn-sm:active { background: #0d47a1; color: #fff; }
|
||||||
|
}
|
||||||
|
.dark-mode .btn-ctrl:active { background: #0d47a1; color: #fff; }
|
||||||
|
.dark-mode .btn-sm:active { background: #0d47a1; color: #fff; }
|
||||||
|
|
||||||
|
/* ===================================================================
|
||||||
|
Comprehensive Dark Mode Enhancements
|
||||||
|
=================================================================== */
|
||||||
|
/* Shared dark mode rules — applied by media query or manual toggle */
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
:root:not(.light-mode) input[type="tel"],
|
||||||
|
:root:not(.light-mode) input[type="text"],
|
||||||
|
:root:not(.light-mode) select {
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
border-color: var(--border);
|
||||||
|
}
|
||||||
|
:root:not(.light-mode) .settings-section h4 { color: #64b5f6; }
|
||||||
|
:root:not(.light-mode) .setup-info h4 { color: #ffe082; }
|
||||||
|
:root:not(.light-mode) .setup-info code { background: rgba(255,255,255,0.08); color: #ffe082; }
|
||||||
|
:root:not(.light-mode) .btn-copy { background: var(--bg-secondary); color: var(--text-primary); border-color: var(--border); }
|
||||||
|
:root:not(.light-mode) .btn-refresh { background: var(--bg-secondary); color: var(--text-primary); }
|
||||||
|
:root:not(.light-mode) .btn-secondary { background: var(--bg-secondary); color: var(--text-primary); }
|
||||||
|
}
|
||||||
|
.dark-mode input[type="tel"],
|
||||||
|
.dark-mode input[type="text"],
|
||||||
|
.dark-mode select {
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
border-color: var(--border);
|
||||||
|
}
|
||||||
|
.dark-mode .settings-section h4 { color: #64b5f6; }
|
||||||
|
.dark-mode .setup-info h4 { color: #ffe082; }
|
||||||
|
.dark-mode .setup-info code { background: rgba(255,255,255,0.08); color: #ffe082; }
|
||||||
|
.dark-mode .btn-copy { background: var(--bg-secondary); color: var(--text-primary); border-color: var(--border); }
|
||||||
|
.dark-mode .btn-refresh { background: var(--bg-secondary); color: var(--text-primary); }
|
||||||
|
.dark-mode .btn-secondary { background: var(--bg-secondary); color: var(--text-primary); }
|
||||||
|
|
||||||
|
/* Dark mode toggle switch styling */
|
||||||
|
.dark-mode-toggle {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 4px 0;
|
||||||
|
}
|
||||||
|
.dark-mode-toggle .toggle-label {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
.toggle-switch {
|
||||||
|
position: relative;
|
||||||
|
width: 48px;
|
||||||
|
height: 26px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.toggle-switch input { opacity: 0; width: 0; height: 0; }
|
||||||
|
.toggle-slider {
|
||||||
|
position: absolute;
|
||||||
|
top: 0; left: 0; right: 0; bottom: 0;
|
||||||
|
background: var(--border);
|
||||||
|
border-radius: 26px;
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
.toggle-slider::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
left: 3px;
|
||||||
|
bottom: 3px;
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 50%;
|
||||||
|
transition: transform 0.2s;
|
||||||
|
}
|
||||||
|
.toggle-switch input:checked + .toggle-slider {
|
||||||
|
background: var(--accent);
|
||||||
|
}
|
||||||
|
.toggle-switch input:checked + .toggle-slider::before {
|
||||||
|
transform: translateX(22px);
|
||||||
|
}
|
||||||
|
.dark-mode-options {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
.dark-mode-opt {
|
||||||
|
flex: 1;
|
||||||
|
padding: 8px 4px;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: var(--bg-primary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
.dark-mode-opt.active {
|
||||||
|
border-color: var(--accent);
|
||||||
|
background: var(--accent);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
1065
assets/mobile/phone.js
Normal file
1065
assets/mobile/phone.js
Normal file
File diff suppressed because it is too large
Load Diff
@@ -480,6 +480,12 @@ class TWP_Activator {
|
|||||||
$wpdb->query("ALTER TABLE $table_agent_status ADD COLUMN auto_busy_at datetime DEFAULT NULL AFTER logged_in_at");
|
$wpdb->query("ALTER TABLE $table_agent_status ADD COLUMN auto_busy_at datetime DEFAULT NULL AFTER logged_in_at");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Add pre_call_status column to store status before a call set agent to busy
|
||||||
|
$pre_call_exists = $wpdb->get_results("SHOW COLUMNS FROM $table_agent_status LIKE 'pre_call_status'");
|
||||||
|
if (empty($pre_call_exists)) {
|
||||||
|
$wpdb->query("ALTER TABLE $table_agent_status ADD COLUMN pre_call_status varchar(20) DEFAULT NULL AFTER auto_busy_at");
|
||||||
|
}
|
||||||
|
|
||||||
$table_schedules = $wpdb->prefix . 'twp_phone_schedules';
|
$table_schedules = $wpdb->prefix . 'twp_phone_schedules';
|
||||||
|
|
||||||
// Check if holiday_dates column exists
|
// Check if holiday_dates column exists
|
||||||
|
|||||||
@@ -619,17 +619,17 @@ class TWP_Agent_Manager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check and revert agents from auto-busy to available after 1 minute
|
* Check and revert agents from auto-busy to their previous status after 30 seconds
|
||||||
*/
|
*/
|
||||||
public static function revert_auto_busy_agents() {
|
public static function revert_auto_busy_agents() {
|
||||||
global $wpdb;
|
global $wpdb;
|
||||||
$table_name = $wpdb->prefix . 'twp_agent_status';
|
$table_name = $wpdb->prefix . 'twp_agent_status';
|
||||||
|
|
||||||
// Find agents who have been auto-busy for more than 1 minute and are still logged in
|
// Find agents who have been auto-busy for more than 30 seconds and are still logged in
|
||||||
$cutoff_time = date('Y-m-d H:i:s', strtotime('-1 minute'));
|
$cutoff_time = date('Y-m-d H:i:s', strtotime('-30 seconds'));
|
||||||
|
|
||||||
$auto_busy_agents = $wpdb->get_results($wpdb->prepare(
|
$auto_busy_agents = $wpdb->get_results($wpdb->prepare(
|
||||||
"SELECT user_id, current_call_sid FROM $table_name
|
"SELECT user_id, current_call_sid, pre_call_status FROM $table_name
|
||||||
WHERE status = 'busy'
|
WHERE status = 'busy'
|
||||||
AND auto_busy_at IS NOT NULL
|
AND auto_busy_at IS NOT NULL
|
||||||
AND auto_busy_at < %s
|
AND auto_busy_at < %s
|
||||||
@@ -655,14 +655,22 @@ class TWP_Agent_Manager {
|
|||||||
}
|
}
|
||||||
} catch (Exception $e) {
|
} catch (Exception $e) {
|
||||||
error_log("TWP Auto-Revert: Could not check call status for {$call_sid}: " . $e->getMessage());
|
error_log("TWP Auto-Revert: Could not check call status for {$call_sid}: " . $e->getMessage());
|
||||||
// If we can't check call status, assume it's finished and proceed with revert
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only revert if call is not active
|
// Only revert if call is not active
|
||||||
if (!$call_active) {
|
if (!$call_active) {
|
||||||
error_log("TWP Auto-Revert: Reverting user {$agent->user_id} from auto-busy to available");
|
$revert_to = !empty($agent->pre_call_status) ? $agent->pre_call_status : 'available';
|
||||||
self::set_agent_status($agent->user_id, 'available', null, false);
|
error_log("TWP Auto-Revert: Reverting user {$agent->user_id} from busy to {$revert_to}");
|
||||||
|
self::set_agent_status($agent->user_id, $revert_to, null, false);
|
||||||
|
// Clear pre_call_status
|
||||||
|
$wpdb->update(
|
||||||
|
$table_name,
|
||||||
|
array('pre_call_status' => null),
|
||||||
|
array('user_id' => $agent->user_id),
|
||||||
|
array('%s'),
|
||||||
|
array('%d')
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ class TWP_Auto_Updater {
|
|||||||
public function __construct() {
|
public function __construct() {
|
||||||
$this->plugin_basename = plugin_basename(dirname(dirname(__FILE__)) . '/twilio-wp-plugin.php');
|
$this->plugin_basename = plugin_basename(dirname(dirname(__FILE__)) . '/twilio-wp-plugin.php');
|
||||||
$this->current_version = defined('TWP_VERSION') ? TWP_VERSION : '0.0.0';
|
$this->current_version = defined('TWP_VERSION') ? TWP_VERSION : '0.0.0';
|
||||||
$this->gitea_api_url = $this->gitea_base_url . '/api/v1/repos/' . $this->gitea_repo . '/releases/latest';
|
$this->gitea_api_url = $this->gitea_base_url . '/api/v1/repos/' . $this->gitea_repo . '/releases?limit=1&draft=false';
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -74,7 +74,7 @@ class TWP_Auto_Updater {
|
|||||||
$custom_repo = get_option('twp_gitea_repo', '');
|
$custom_repo = get_option('twp_gitea_repo', '');
|
||||||
if (!empty($custom_repo)) {
|
if (!empty($custom_repo)) {
|
||||||
$this->gitea_repo = $custom_repo;
|
$this->gitea_repo = $custom_repo;
|
||||||
$this->gitea_api_url = $this->gitea_base_url . '/api/v1/repos/' . $this->gitea_repo . '/releases/latest';
|
$this->gitea_api_url = $this->gitea_base_url . '/api/v1/repos/' . $this->gitea_repo . '/releases?limit=1&draft=false';
|
||||||
}
|
}
|
||||||
|
|
||||||
$update_info = $this->get_latest_release();
|
$update_info = $this->get_latest_release();
|
||||||
@@ -184,9 +184,16 @@ class TWP_Auto_Updater {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
$release = json_decode($response);
|
$releases = json_decode($response);
|
||||||
|
|
||||||
if (!$release || !isset($release->tag_name)) {
|
if (!$releases || !is_array($releases) || empty($releases)) {
|
||||||
|
error_log('TWP Auto-Updater: No releases found from Gitea');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$release = $releases[0];
|
||||||
|
|
||||||
|
if (!isset($release->tag_name)) {
|
||||||
error_log('TWP Auto-Updater: Invalid release data from Gitea');
|
error_log('TWP Auto-Updater: Invalid release data from Gitea');
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -210,6 +217,12 @@ class TWP_Auto_Updater {
|
|||||||
$download_url = $release->zipball_url;
|
$download_url = $release->zipball_url;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Append auth token to download URL for private repos
|
||||||
|
if (!empty($gitea_token) && $download_url) {
|
||||||
|
$separator = (strpos($download_url, '?') !== false) ? '&' : '?';
|
||||||
|
$download_url .= $separator . 'token=' . urlencode($gitea_token);
|
||||||
|
}
|
||||||
|
|
||||||
// Format changelog
|
// Format changelog
|
||||||
$changelog = !empty($release->body) ? $release->body : 'No changelog provided for this release.';
|
$changelog = !empty($release->body) ? $release->body : 'No changelog provided for this release.';
|
||||||
|
|
||||||
|
|||||||
@@ -33,8 +33,8 @@ class TWP_Call_Queue {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if ($result !== false) {
|
if ($result !== false) {
|
||||||
// Notify agents via SMS when a new call enters the queue
|
// Notify agents via SMS and FCM when a new call enters the queue
|
||||||
self::notify_agents_for_queue($queue_id, $call_data['from_number']);
|
self::notify_agents_for_queue($queue_id, $call_data['from_number'], $call_data['call_sid']);
|
||||||
return $position;
|
return $position;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -577,10 +577,73 @@ class TWP_Call_Queue {
|
|||||||
return $status;
|
return $status;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cron callback: re-send FCM queue alerts every minute for calls still waiting.
|
||||||
|
* Only alerts for calls that have been waiting > 60 seconds (initial alert
|
||||||
|
* already sent on entry). Skips re-alerting for the same call within 55 seconds
|
||||||
|
* using a short transient to avoid overlap with the 60-second cron.
|
||||||
|
*/
|
||||||
|
public static function send_queue_reminders() {
|
||||||
|
global $wpdb;
|
||||||
|
$calls_table = $wpdb->prefix . 'twp_queued_calls';
|
||||||
|
$queue_table = $wpdb->prefix . 'twp_call_queues';
|
||||||
|
|
||||||
|
// Find calls waiting longer than 60 seconds
|
||||||
|
$waiting_calls = $wpdb->get_results(
|
||||||
|
"SELECT c.*, q.queue_name, q.user_id AS queue_owner_id, q.agent_group_id
|
||||||
|
FROM $calls_table c
|
||||||
|
JOIN $queue_table q ON q.id = c.queue_id
|
||||||
|
WHERE c.status = 'waiting'
|
||||||
|
AND c.joined_at <= DATE_SUB(NOW(), INTERVAL 60 SECOND)"
|
||||||
|
);
|
||||||
|
|
||||||
|
if (empty($waiting_calls)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
require_once dirname(__FILE__) . '/class-twp-fcm.php';
|
||||||
|
$fcm = new TWP_FCM();
|
||||||
|
|
||||||
|
foreach ($waiting_calls as $call) {
|
||||||
|
// Throttle: skip if we reminded for this call within the last 55 seconds
|
||||||
|
$transient_key = 'twp_queue_remind_' . $call->call_sid;
|
||||||
|
if (get_transient($transient_key)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
set_transient($transient_key, 1, 55);
|
||||||
|
|
||||||
|
$waiting_minutes = max(1, round((time() - strtotime($call->joined_at)) / 60));
|
||||||
|
$title = 'Call Still Waiting';
|
||||||
|
$body = "Call from {$call->from_number} waiting {$waiting_minutes}m in {$call->queue_name}";
|
||||||
|
|
||||||
|
$notified_users = array();
|
||||||
|
|
||||||
|
// Notify queue owner
|
||||||
|
if (!empty($call->queue_owner_id)) {
|
||||||
|
$fcm->notify_queue_alert($call->queue_owner_id, $call->from_number, $call->queue_name, $call->call_sid);
|
||||||
|
$notified_users[] = $call->queue_owner_id;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Notify agent group members
|
||||||
|
if (!empty($call->agent_group_id)) {
|
||||||
|
require_once dirname(__FILE__) . '/class-twp-agent-groups.php';
|
||||||
|
$members = TWP_Agent_Groups::get_group_members($call->agent_group_id);
|
||||||
|
foreach ($members as $member) {
|
||||||
|
if (!in_array($member->user_id, $notified_users)) {
|
||||||
|
$fcm->notify_queue_alert($member->user_id, $call->from_number, $call->queue_name, $call->call_sid);
|
||||||
|
$notified_users[] = $member->user_id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
error_log("TWP Queue Reminder: Re-alerted " . count($notified_users) . " user(s) for call {$call->call_sid} waiting {$waiting_minutes}m");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Notify agents via SMS when a call enters the queue
|
* Notify agents via SMS when a call enters the queue
|
||||||
*/
|
*/
|
||||||
private static function notify_agents_for_queue($queue_id, $caller_number) {
|
private static function notify_agents_for_queue($queue_id, $caller_number, $call_sid = '') {
|
||||||
global $wpdb;
|
global $wpdb;
|
||||||
|
|
||||||
error_log("TWP: notify_agents_for_queue called for queue {$queue_id}, caller {$caller_number}");
|
error_log("TWP: notify_agents_for_queue called for queue {$queue_id}, caller {$caller_number}");
|
||||||
@@ -597,16 +660,8 @@ class TWP_Call_Queue {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!$queue->agent_group_id) {
|
|
||||||
error_log("TWP: No agent group assigned to queue {$queue_id}, skipping SMS notifications");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
error_log("TWP: Found queue '{$queue->queue_name}' with agent group {$queue->agent_group_id}");
|
|
||||||
|
|
||||||
// Send Discord/Slack notification for incoming call
|
// Send Discord/Slack notification for incoming call
|
||||||
require_once dirname(__FILE__) . '/class-twp-notifications.php';
|
require_once dirname(__FILE__) . '/class-twp-notifications.php';
|
||||||
error_log("TWP: Triggering Discord/Slack notification for incoming call");
|
|
||||||
TWP_Notifications::send_call_notification('incoming_call', array(
|
TWP_Notifications::send_call_notification('incoming_call', array(
|
||||||
'type' => 'incoming_call',
|
'type' => 'incoming_call',
|
||||||
'caller' => $caller_number,
|
'caller' => $caller_number,
|
||||||
@@ -614,10 +669,36 @@ class TWP_Call_Queue {
|
|||||||
'queue_id' => $queue_id
|
'queue_id' => $queue_id
|
||||||
));
|
));
|
||||||
|
|
||||||
|
// Send FCM push notifications to agents' mobile devices
|
||||||
|
require_once dirname(__FILE__) . '/class-twp-fcm.php';
|
||||||
|
$fcm = new TWP_FCM();
|
||||||
|
$notified_users = array();
|
||||||
|
|
||||||
|
// Always notify personal queue owner
|
||||||
|
if (!empty($queue->user_id)) {
|
||||||
|
$fcm->notify_queue_alert($queue->user_id, $caller_number, $queue->queue_name, $call_sid);
|
||||||
|
$notified_users[] = $queue->user_id;
|
||||||
|
error_log("TWP: FCM queue alert sent to queue owner user {$queue->user_id}");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$queue->agent_group_id) {
|
||||||
|
error_log("TWP: No agent group assigned to queue {$queue_id}, skipping SMS notifications");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
error_log("TWP: Found queue '{$queue->queue_name}' with agent group {$queue->agent_group_id}");
|
||||||
|
|
||||||
// Get members of the assigned agent group
|
// Get members of the assigned agent group
|
||||||
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);
|
||||||
|
|
||||||
|
foreach ($members as $member) {
|
||||||
|
if (!in_array($member->user_id, $notified_users)) {
|
||||||
|
$fcm->notify_queue_alert($member->user_id, $caller_number, $queue->queue_name, $call_sid);
|
||||||
|
$notified_users[] = $member->user_id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
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;
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ class TWP_Core {
|
|||||||
require_once TWP_PLUGIN_DIR . 'includes/class-twp-mobile-api.php';
|
require_once TWP_PLUGIN_DIR . 'includes/class-twp-mobile-api.php';
|
||||||
require_once TWP_PLUGIN_DIR . 'includes/class-twp-mobile-sse.php';
|
require_once TWP_PLUGIN_DIR . 'includes/class-twp-mobile-sse.php';
|
||||||
require_once TWP_PLUGIN_DIR . 'includes/class-twp-fcm.php';
|
require_once TWP_PLUGIN_DIR . 'includes/class-twp-fcm.php';
|
||||||
|
require_once TWP_PLUGIN_DIR . 'includes/class-twp-mobile-phone-page.php';
|
||||||
require_once TWP_PLUGIN_DIR . 'includes/class-twp-auto-updater.php';
|
require_once TWP_PLUGIN_DIR . 'includes/class-twp-auto-updater.php';
|
||||||
|
|
||||||
// Feature classes
|
// Feature classes
|
||||||
@@ -255,6 +256,9 @@ class TWP_Core {
|
|||||||
// Initialize Shortcodes
|
// Initialize Shortcodes
|
||||||
TWP_Shortcodes::init();
|
TWP_Shortcodes::init();
|
||||||
|
|
||||||
|
// Initialize standalone mobile phone page (/twp-phone/)
|
||||||
|
new TWP_Mobile_Phone_Page();
|
||||||
|
|
||||||
// Scheduled events
|
// Scheduled events
|
||||||
$scheduler = new TWP_Scheduler();
|
$scheduler = new TWP_Scheduler();
|
||||||
$this->loader->add_action('twp_check_schedules', $scheduler, 'check_active_schedules');
|
$this->loader->add_action('twp_check_schedules', $scheduler, 'check_active_schedules');
|
||||||
@@ -262,6 +266,9 @@ class TWP_Core {
|
|||||||
$queue = new TWP_Call_Queue();
|
$queue = new TWP_Call_Queue();
|
||||||
$this->loader->add_action('twp_process_queue', $queue, 'process_waiting_calls');
|
$this->loader->add_action('twp_process_queue', $queue, 'process_waiting_calls');
|
||||||
|
|
||||||
|
// Queue reminder alerts (re-send FCM every minute for waiting calls)
|
||||||
|
add_action('twp_queue_reminders', array('TWP_Call_Queue', 'send_queue_reminders'));
|
||||||
|
|
||||||
// Callback processing
|
// Callback processing
|
||||||
$this->loader->add_action('twp_process_callbacks', 'TWP_Callback_Manager', 'process_callbacks');
|
$this->loader->add_action('twp_process_callbacks', 'TWP_Callback_Manager', 'process_callbacks');
|
||||||
|
|
||||||
@@ -277,6 +284,10 @@ class TWP_Core {
|
|||||||
wp_schedule_event(time(), 'twp_every_30_seconds', 'twp_process_queue');
|
wp_schedule_event(time(), 'twp_every_30_seconds', 'twp_process_queue');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!wp_next_scheduled('twp_queue_reminders')) {
|
||||||
|
wp_schedule_event(time(), 'twp_every_minute', 'twp_queue_reminders');
|
||||||
|
}
|
||||||
|
|
||||||
if (!wp_next_scheduled('twp_process_callbacks')) {
|
if (!wp_next_scheduled('twp_process_callbacks')) {
|
||||||
wp_schedule_event(time(), 'twp_every_minute', 'twp_process_callbacks');
|
wp_schedule_event(time(), 'twp_every_minute', 'twp_process_callbacks');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ class TWP_Deactivator {
|
|||||||
// Clear scheduled events
|
// Clear scheduled events
|
||||||
wp_clear_scheduled_hook('twp_check_schedules');
|
wp_clear_scheduled_hook('twp_check_schedules');
|
||||||
wp_clear_scheduled_hook('twp_process_queue');
|
wp_clear_scheduled_hook('twp_process_queue');
|
||||||
|
wp_clear_scheduled_hook('twp_queue_reminders');
|
||||||
wp_clear_scheduled_hook('twp_auto_revert_agents');
|
wp_clear_scheduled_hook('twp_auto_revert_agents');
|
||||||
|
|
||||||
// Flush rewrite rules
|
// Flush rewrite rules
|
||||||
|
|||||||
@@ -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
|
||||||
*/
|
*/
|
||||||
@@ -149,20 +279,66 @@ class TWP_FCM {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Send incoming call notification
|
* Send queue alert notification (call entered queue).
|
||||||
|
* Uses data-only message so it works in background/killed state.
|
||||||
*/
|
*/
|
||||||
public function notify_incoming_call($user_id, $from_number, $queue_name, $call_sid) {
|
public function notify_queue_alert($user_id, $from_number, $queue_name, $call_sid) {
|
||||||
$title = 'Incoming Call';
|
$title = 'Call Waiting';
|
||||||
$body = "Call from $from_number in $queue_name queue";
|
$body = "Call from $from_number in $queue_name";
|
||||||
|
|
||||||
$data = array(
|
$data = array(
|
||||||
'type' => 'incoming_call',
|
'type' => 'queue_alert',
|
||||||
'call_sid' => $call_sid,
|
'call_sid' => $call_sid,
|
||||||
'from_number' => $from_number,
|
'from_number' => $from_number,
|
||||||
'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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cancel queue alert notification (call answered or caller disconnected).
|
||||||
|
*/
|
||||||
|
public function notify_queue_alert_cancel($user_id, $call_sid) {
|
||||||
|
$data = array(
|
||||||
|
'type' => 'queue_alert_cancel',
|
||||||
|
'call_sid' => $call_sid,
|
||||||
|
);
|
||||||
|
|
||||||
|
return $this->send_notification($user_id, '', '', $data, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send queue alert cancel to all agents assigned to a queue.
|
||||||
|
*/
|
||||||
|
public function cancel_queue_alert_for_queue($queue_id, $call_sid) {
|
||||||
|
global $wpdb;
|
||||||
|
$queue_table = $wpdb->prefix . 'twp_call_queues';
|
||||||
|
|
||||||
|
$queue = $wpdb->get_row($wpdb->prepare(
|
||||||
|
"SELECT * FROM $queue_table WHERE id = %d", $queue_id
|
||||||
|
));
|
||||||
|
if (!$queue) return;
|
||||||
|
|
||||||
|
$notified_users = array();
|
||||||
|
|
||||||
|
// Notify personal queue owner
|
||||||
|
if (!empty($queue->user_id)) {
|
||||||
|
$this->notify_queue_alert_cancel($queue->user_id, $call_sid);
|
||||||
|
$notified_users[] = $queue->user_id;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Notify agent group members
|
||||||
|
if (!empty($queue->agent_group_id)) {
|
||||||
|
require_once dirname(__FILE__) . '/class-twp-agent-groups.php';
|
||||||
|
$members = TWP_Agent_Groups::get_group_members($queue->agent_group_id);
|
||||||
|
foreach ($members as $member) {
|
||||||
|
if (!in_array($member->user_id, $notified_users)) {
|
||||||
|
$this->notify_queue_alert_cancel($member->user_id, $call_sid);
|
||||||
|
$notified_users[] = $member->user_id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -206,7 +382,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,34 @@ 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')
|
||||||
|
));
|
||||||
|
|
||||||
|
// Outbound call (click-to-call via server)
|
||||||
|
register_rest_route('twilio-mobile/v1', '/calls/outbound', array(
|
||||||
|
'methods' => 'POST',
|
||||||
|
'callback' => array($this, 'initiate_outbound_call'),
|
||||||
|
'permission_callback' => array($this->auth, 'verify_token')
|
||||||
|
));
|
||||||
|
|
||||||
|
// FCM push credential setup (admin only)
|
||||||
|
register_rest_route('twilio-mobile/v1', '/admin/push-credential', array(
|
||||||
|
'methods' => 'POST',
|
||||||
|
'callback' => array($this, 'setup_push_credential'),
|
||||||
|
'permission_callback' => array($this->auth, 'verify_token')
|
||||||
|
));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -155,39 +183,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 +209,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) {
|
||||||
@@ -308,12 +311,9 @@ class TWP_Mobile_API {
|
|||||||
$user_id = $this->auth->get_current_user_id();
|
$user_id = $this->auth->get_current_user_id();
|
||||||
$call_sid = $request['call_sid'];
|
$call_sid = $request['call_sid'];
|
||||||
|
|
||||||
// Get agent phone number
|
// Check for WebRTC client_identity parameter
|
||||||
$agent_number = get_user_meta($user_id, 'twp_agent_phone', true);
|
$body = $request->get_json_params();
|
||||||
|
$client_identity = isset($body['client_identity']) ? sanitize_text_field($body['client_identity']) : null;
|
||||||
if (empty($agent_number)) {
|
|
||||||
return new WP_Error('no_phone', 'No phone number configured for agent', array('status' => 400));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Initialize Twilio API
|
// Initialize Twilio API
|
||||||
require_once plugin_dir_path(dirname(__FILE__)) . 'includes/class-twp-twilio-api.php';
|
require_once plugin_dir_path(dirname(__FILE__)) . 'includes/class-twp-twilio-api.php';
|
||||||
@@ -338,46 +338,120 @@ class TWP_Mobile_API {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Connect agent to call
|
if (!empty($client_identity)) {
|
||||||
$agent_call = $twilio->create_call(
|
// WebRTC path: redirect the queued call to the Twilio Client device
|
||||||
$agent_number,
|
// Use the original caller's number as caller ID so it shows on the agent's device
|
||||||
$call->to_number,
|
$caller_id = $call->from_number;
|
||||||
array(
|
if (empty($caller_id)) {
|
||||||
'url' => site_url('/wp-json/twilio-webhook/v1/connect-agent'),
|
$caller_id = $call->to_number;
|
||||||
'statusCallback' => site_url('/wp-json/twilio-webhook/v1/agent-call-status'),
|
}
|
||||||
'statusCallbackEvent' => array('completed', 'no-answer', 'busy', 'failed'),
|
if (empty($caller_id)) {
|
||||||
'timeout' => 30
|
$caller_id = get_option('twp_caller_id_number', '');
|
||||||
)
|
}
|
||||||
);
|
|
||||||
|
|
||||||
// Update call record
|
$twiml = '<Response><Dial callerId="' . htmlspecialchars($caller_id) . '"><Client>' . htmlspecialchars($client_identity) . '</Client></Dial></Response>';
|
||||||
$wpdb->update(
|
|
||||||
$calls_table,
|
error_log('TWP accept_call: call_sid=' . $call_sid . ' client=' . $client_identity . ' twiml=' . $twiml);
|
||||||
array(
|
|
||||||
'status' => 'connecting',
|
$result = $twilio->update_call($call_sid, array('twiml' => $twiml));
|
||||||
'agent_phone' => $agent_number,
|
|
||||||
|
error_log('TWP accept_call result: ' . json_encode($result));
|
||||||
|
if (!$result['success']) {
|
||||||
|
return new WP_Error('twilio_error', $result['error'] ?? 'Failed to update call', array('status' => 500));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update call record
|
||||||
|
$wpdb->update(
|
||||||
|
$calls_table,
|
||||||
|
array(
|
||||||
|
'status' => 'connecting',
|
||||||
|
'agent_phone' => 'client:' . $client_identity,
|
||||||
|
),
|
||||||
|
array('call_sid' => $call_sid),
|
||||||
|
array('%s', '%s'),
|
||||||
|
array('%s')
|
||||||
|
);
|
||||||
|
|
||||||
|
// Save current status before setting busy, so we can revert after call ends
|
||||||
|
$status_table = $wpdb->prefix . 'twp_agent_status';
|
||||||
|
$current = $wpdb->get_row($wpdb->prepare(
|
||||||
|
"SELECT status FROM $status_table WHERE user_id = %d", $user_id
|
||||||
|
));
|
||||||
|
$pre_call_status = ($current && $current->status !== 'busy') ? $current->status : null;
|
||||||
|
|
||||||
|
$wpdb->update(
|
||||||
|
$status_table,
|
||||||
|
array(
|
||||||
|
'status' => 'busy',
|
||||||
|
'current_call_sid' => $call_sid,
|
||||||
|
'pre_call_status' => $pre_call_status,
|
||||||
|
'auto_busy_at' => null,
|
||||||
|
),
|
||||||
|
array('user_id' => $user_id),
|
||||||
|
array('%s', '%s', '%s', '%s'),
|
||||||
|
array('%d')
|
||||||
|
);
|
||||||
|
|
||||||
|
// Cancel queue alert notifications on all agents' devices
|
||||||
|
require_once plugin_dir_path(dirname(__FILE__)) . 'includes/class-twp-fcm.php';
|
||||||
|
$fcm = new TWP_FCM();
|
||||||
|
$fcm->cancel_queue_alert_for_queue($call->queue_id, $call_sid);
|
||||||
|
|
||||||
|
return new WP_REST_Response(array(
|
||||||
|
'success' => true,
|
||||||
|
'message' => 'Call accepted via WebRTC client',
|
||||||
|
'call_sid' => $call_sid
|
||||||
|
), 200);
|
||||||
|
|
||||||
|
} else {
|
||||||
|
// Phone-based path (original flow): dial the agent's phone number
|
||||||
|
$agent_number = get_user_meta($user_id, 'twp_agent_phone', true);
|
||||||
|
|
||||||
|
if (empty($agent_number)) {
|
||||||
|
return new WP_Error('no_phone', 'No phone number configured for agent and no client_identity provided', array('status' => 400));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Connect agent to call
|
||||||
|
$agent_call = $twilio->create_call(
|
||||||
|
$agent_number,
|
||||||
|
$call->to_number,
|
||||||
|
array(
|
||||||
|
'url' => site_url('/wp-json/twilio-webhook/v1/connect-agent'),
|
||||||
|
'statusCallback' => site_url('/wp-json/twilio-webhook/v1/agent-call-status'),
|
||||||
|
'statusCallbackEvent' => array('completed', 'no-answer', 'busy', 'failed'),
|
||||||
|
'timeout' => 30
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Update call record
|
||||||
|
$wpdb->update(
|
||||||
|
$calls_table,
|
||||||
|
array(
|
||||||
|
'status' => 'connecting',
|
||||||
|
'agent_phone' => $agent_number,
|
||||||
|
'agent_call_sid' => $agent_call->sid
|
||||||
|
),
|
||||||
|
array('call_sid' => $call_sid),
|
||||||
|
array('%s', '%s', '%s'),
|
||||||
|
array('%s')
|
||||||
|
);
|
||||||
|
|
||||||
|
// Update agent status
|
||||||
|
$status_table = $wpdb->prefix . 'twp_agent_status';
|
||||||
|
$wpdb->update(
|
||||||
|
$status_table,
|
||||||
|
array('status' => 'busy', 'current_call_sid' => $call_sid),
|
||||||
|
array('user_id' => $user_id),
|
||||||
|
array('%s', '%s'),
|
||||||
|
array('%d')
|
||||||
|
);
|
||||||
|
|
||||||
|
return new WP_REST_Response(array(
|
||||||
|
'success' => true,
|
||||||
|
'message' => 'Call accepted, connecting to agent',
|
||||||
'agent_call_sid' => $agent_call->sid
|
'agent_call_sid' => $agent_call->sid
|
||||||
),
|
), 200);
|
||||||
array('call_sid' => $call_sid),
|
}
|
||||||
array('%s', '%s', '%s'),
|
|
||||||
array('%s')
|
|
||||||
);
|
|
||||||
|
|
||||||
// Update agent status
|
|
||||||
$status_table = $wpdb->prefix . 'twp_agent_status';
|
|
||||||
$wpdb->update(
|
|
||||||
$status_table,
|
|
||||||
array('status' => 'busy', 'current_call_sid' => $call_sid),
|
|
||||||
array('user_id' => $user_id),
|
|
||||||
array('%s', '%s'),
|
|
||||||
array('%d')
|
|
||||||
);
|
|
||||||
|
|
||||||
return new WP_REST_Response(array(
|
|
||||||
'success' => true,
|
|
||||||
'message' => 'Call accepted, connecting to agent',
|
|
||||||
'agent_call_sid' => $agent_call->sid
|
|
||||||
), 200);
|
|
||||||
|
|
||||||
} catch (Exception $e) {
|
} catch (Exception $e) {
|
||||||
return new WP_Error('twilio_error', $e->getMessage(), array('status' => 500));
|
return new WP_Error('twilio_error', $e->getMessage(), array('status' => 500));
|
||||||
@@ -507,11 +581,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 +750,104 @@ 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 requires an API Key (not account credentials).
|
||||||
|
// Auto-create and cache one if it doesn't exist yet.
|
||||||
|
$api_key_sid = get_option('twp_twilio_api_key_sid');
|
||||||
|
$api_key_secret = get_option('twp_twilio_api_key_secret');
|
||||||
|
|
||||||
|
if (empty($api_key_sid) || empty($api_key_secret)) {
|
||||||
|
$client = new \Twilio\Rest\Client($account_sid, $auth_token);
|
||||||
|
$newKey = $client->newKeys->create(['friendlyName' => 'TWP Mobile Voice']);
|
||||||
|
$api_key_sid = $newKey->sid;
|
||||||
|
$api_key_secret = $newKey->secret;
|
||||||
|
update_option('twp_twilio_api_key_sid', $api_key_sid);
|
||||||
|
update_option('twp_twilio_api_key_secret', $api_key_secret);
|
||||||
|
}
|
||||||
|
|
||||||
|
$token = new \Twilio\Jwt\AccessToken($account_sid, $api_key_sid, $api_key_secret, 3600, $identity);
|
||||||
|
$voiceGrant = new \Twilio\Jwt\Grants\VoiceGrant();
|
||||||
|
$voiceGrant->setOutgoingApplicationSid($twiml_app_sid);
|
||||||
|
$voiceGrant->setIncomingAllow(true);
|
||||||
|
|
||||||
|
// Include FCM push credential for incoming call notifications.
|
||||||
|
// Auto-create from the stored Firebase service account JSON if not yet created.
|
||||||
|
$push_credential_sid = get_option('twp_twilio_push_credential_sid');
|
||||||
|
if (empty($push_credential_sid)) {
|
||||||
|
$push_credential_sid = $this->ensure_push_credential($account_sid, $auth_token);
|
||||||
|
}
|
||||||
|
if (!empty($push_credential_sid)) {
|
||||||
|
$voiceGrant->setPushCredentialSid($push_credential_sid);
|
||||||
|
}
|
||||||
|
|
||||||
|
$token->addGrant($voiceGrant);
|
||||||
|
|
||||||
|
return new WP_REST_Response(array(
|
||||||
|
'token' => $token->toJWT(),
|
||||||
|
'identity' => $identity,
|
||||||
|
'expires_in' => 3600
|
||||||
|
), 200);
|
||||||
|
|
||||||
|
} catch (Exception $e) {
|
||||||
|
return new WP_Error('token_error', $e->getMessage(), array('status' => 500));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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
|
||||||
*/
|
*/
|
||||||
@@ -668,6 +875,79 @@ class TWP_Mobile_API {
|
|||||||
return (bool)$is_assigned;
|
return (bool)$is_assigned;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Admin endpoint to force re-creation of the Twilio Push Credential.
|
||||||
|
*/
|
||||||
|
public function setup_push_credential($request) {
|
||||||
|
$user_id = $this->auth->get_current_user_id();
|
||||||
|
$user = get_userdata($user_id);
|
||||||
|
if (!user_can($user, 'manage_options')) {
|
||||||
|
return new WP_Error('forbidden', 'Admin access required', array('status' => 403));
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
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');
|
||||||
|
|
||||||
|
// Force re-creation by clearing existing SID
|
||||||
|
delete_option('twp_twilio_push_credential_sid');
|
||||||
|
$sid = $this->ensure_push_credential($account_sid, $auth_token);
|
||||||
|
|
||||||
|
if (empty($sid)) {
|
||||||
|
return new WP_Error('credential_error', 'Failed to create push credential. Check that Firebase service account JSON is configured in Mobile App Settings.', array('status' => 500));
|
||||||
|
}
|
||||||
|
|
||||||
|
return new WP_REST_Response(array(
|
||||||
|
'success' => true,
|
||||||
|
'credential_sid' => $sid,
|
||||||
|
), 200);
|
||||||
|
|
||||||
|
} catch (Exception $e) {
|
||||||
|
error_log('TWP setup_push_credential error: ' . $e->getMessage());
|
||||||
|
return new WP_Error('credential_error', $e->getMessage(), array('status' => 500));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Auto-create Twilio Push Credential from the stored Firebase service account JSON.
|
||||||
|
* Returns the credential SID or empty string on failure.
|
||||||
|
*/
|
||||||
|
private function ensure_push_credential($account_sid, $auth_token) {
|
||||||
|
$sa_json = get_option('twp_fcm_service_account_json', '');
|
||||||
|
if (empty($sa_json)) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
$sa = json_decode($sa_json, true);
|
||||||
|
if (!$sa || empty($sa['project_id']) || empty($sa['private_key'])) {
|
||||||
|
error_log('TWP: Firebase service account JSON is invalid');
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$client = new \Twilio\Rest\Client($account_sid, $auth_token);
|
||||||
|
|
||||||
|
$credential = $client->notify->v1->credentials->create(
|
||||||
|
'fcm',
|
||||||
|
[
|
||||||
|
'friendlyName' => 'TWP Mobile FCM',
|
||||||
|
'secret' => $sa_json,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
update_option('twp_twilio_push_credential_sid', $credential->sid);
|
||||||
|
error_log('TWP: Created Twilio push credential: ' . $credential->sid);
|
||||||
|
return $credential->sid;
|
||||||
|
|
||||||
|
} catch (Exception $e) {
|
||||||
|
error_log('TWP ensure_push_credential error: ' . $e->getMessage());
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Calculate wait time in seconds
|
* Calculate wait time in seconds
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ class TWP_Mobile_Auth {
|
|||||||
private $secret_key;
|
private $secret_key;
|
||||||
private $token_expiry = 86400; // 24 hours in seconds
|
private $token_expiry = 86400; // 24 hours in seconds
|
||||||
private $refresh_expiry = 2592000; // 30 days in seconds
|
private $refresh_expiry = 2592000; // 30 days in seconds
|
||||||
|
private $current_user_id = null;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Constructor
|
* Constructor
|
||||||
@@ -330,7 +331,7 @@ class TWP_Mobile_Auth {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Store user ID for later use
|
// Store user ID for later use
|
||||||
$request->set_param('_twp_user_id', $payload->user_id);
|
$this->current_user_id = $payload->user_id;
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -339,8 +340,7 @@ class TWP_Mobile_Auth {
|
|||||||
* Get current user ID from token
|
* Get current user ID from token
|
||||||
*/
|
*/
|
||||||
public function get_current_user_id() {
|
public function get_current_user_id() {
|
||||||
$request = rest_get_server()->get_request();
|
return $this->current_user_id;
|
||||||
return $request->get_param('_twp_user_id');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -423,13 +423,21 @@ class TWP_Mobile_Auth {
|
|||||||
global $wpdb;
|
global $wpdb;
|
||||||
$table = $wpdb->prefix . 'twp_mobile_sessions';
|
$table = $wpdb->prefix . 'twp_mobile_sessions';
|
||||||
|
|
||||||
$wpdb->update(
|
if (!empty($refresh_token)) {
|
||||||
$table,
|
$wpdb->update(
|
||||||
array('fcm_token' => $fcm_token),
|
$table,
|
||||||
array('user_id' => $user_id, 'refresh_token' => $refresh_token, 'is_active' => 1),
|
array('fcm_token' => $fcm_token),
|
||||||
array('%s'),
|
array('user_id' => $user_id, 'refresh_token' => $refresh_token, 'is_active' => 1),
|
||||||
array('%d', '%s', '%d')
|
array('%s'),
|
||||||
);
|
array('%d', '%s', '%d')
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// No refresh token — update the most recent active session for this user
|
||||||
|
$wpdb->query($wpdb->prepare(
|
||||||
|
"UPDATE $table SET fcm_token = %s WHERE user_id = %d AND is_active = 1 AND expires_at > NOW() ORDER BY created_at DESC LIMIT 1",
|
||||||
|
$fcm_token, $user_id
|
||||||
|
));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
226
includes/class-twp-mobile-phone-page.php
Normal file
226
includes/class-twp-mobile-phone-page.php
Normal file
@@ -0,0 +1,226 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Standalone Mobile Phone Page
|
||||||
|
*
|
||||||
|
* Registers a front-end endpoint at /twp-phone/ that serves the browser phone UI
|
||||||
|
* without any wp-admin chrome. Designed for mobile WebView usage.
|
||||||
|
*
|
||||||
|
* @package Twilio_WP_Plugin
|
||||||
|
*/
|
||||||
|
class TWP_Mobile_Phone_Page {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The endpoint slug.
|
||||||
|
*/
|
||||||
|
const ENDPOINT = 'twp-phone';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Constructor — wire up hooks.
|
||||||
|
*/
|
||||||
|
public function __construct() {
|
||||||
|
add_action('init', array($this, 'register_rewrite'));
|
||||||
|
add_action('template_redirect', array($this, 'handle_request'));
|
||||||
|
add_filter('query_vars', array($this, 'add_query_var'));
|
||||||
|
|
||||||
|
// Extend session cookie for phone agents.
|
||||||
|
add_filter('auth_cookie_expiration', array($this, 'extend_agent_cookie'), 10, 3);
|
||||||
|
|
||||||
|
// AJAX action for FCM token registration (uses WP cookie auth).
|
||||||
|
add_action('wp_ajax_twp_register_fcm_token', array($this, 'ajax_register_fcm_token'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AJAX handler: register FCM token for the current user.
|
||||||
|
*/
|
||||||
|
public function ajax_register_fcm_token() {
|
||||||
|
check_ajax_referer('twp_ajax_nonce', 'nonce');
|
||||||
|
|
||||||
|
$fcm_token = sanitize_text_field($_POST['fcm_token'] ?? '');
|
||||||
|
if (empty($fcm_token)) {
|
||||||
|
wp_send_json_error('Missing FCM token');
|
||||||
|
}
|
||||||
|
|
||||||
|
$user_id = get_current_user_id();
|
||||||
|
if (!$user_id) {
|
||||||
|
wp_send_json_error('Not authenticated');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store FCM token (same as TWP_Mobile_API::register_fcm_token)
|
||||||
|
global $wpdb;
|
||||||
|
$table = $wpdb->prefix . 'twp_mobile_sessions';
|
||||||
|
|
||||||
|
// Update existing session or insert new one
|
||||||
|
$existing = $wpdb->get_row($wpdb->prepare(
|
||||||
|
"SELECT id FROM $table WHERE user_id = %d AND fcm_token = %s AND is_active = 1",
|
||||||
|
$user_id, $fcm_token
|
||||||
|
));
|
||||||
|
|
||||||
|
if ($existing) {
|
||||||
|
// Refresh the expiry on existing session
|
||||||
|
$wpdb->update($table,
|
||||||
|
array('expires_at' => date('Y-m-d H:i:s', time() + 7 * DAY_IN_SECONDS)),
|
||||||
|
array('id' => $existing->id),
|
||||||
|
array('%s'),
|
||||||
|
array('%d')
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
$wpdb->insert($table, array(
|
||||||
|
'user_id' => $user_id,
|
||||||
|
'refresh_token' => 'webview-' . wp_generate_password(32, false),
|
||||||
|
'fcm_token' => $fcm_token,
|
||||||
|
'device_info' => 'WebView Mobile App',
|
||||||
|
'is_active' => 1,
|
||||||
|
'created_at' => current_time('mysql'),
|
||||||
|
'expires_at' => date('Y-m-d H:i:s', time() + 7 * DAY_IN_SECONDS),
|
||||||
|
));
|
||||||
|
|
||||||
|
if ($wpdb->last_error) {
|
||||||
|
error_log('TWP FCM: Failed to insert token: ' . $wpdb->last_error);
|
||||||
|
wp_send_json_error('Failed to store token');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
error_log("TWP FCM: Token registered for user $user_id");
|
||||||
|
wp_send_json_success('FCM token registered');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register custom rewrite rule.
|
||||||
|
*/
|
||||||
|
public function register_rewrite() {
|
||||||
|
add_rewrite_rule(
|
||||||
|
'^' . self::ENDPOINT . '/?$',
|
||||||
|
'index.php?twp_phone_page=1',
|
||||||
|
'top'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Expose query variable.
|
||||||
|
*
|
||||||
|
* @param array $vars Existing query vars.
|
||||||
|
* @return array
|
||||||
|
*/
|
||||||
|
public function add_query_var($vars) {
|
||||||
|
$vars[] = 'twp_phone_page';
|
||||||
|
return $vars;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle the request on template_redirect.
|
||||||
|
*/
|
||||||
|
public function handle_request() {
|
||||||
|
if (!get_query_var('twp_phone_page')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Authentication check — redirect to login if not authenticated.
|
||||||
|
if (!is_user_logged_in()) {
|
||||||
|
$redirect_url = home_url('/' . self::ENDPOINT . '/');
|
||||||
|
wp_redirect(wp_login_url($redirect_url));
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Capability check.
|
||||||
|
if (!current_user_can('twp_access_browser_phone')) {
|
||||||
|
wp_die(
|
||||||
|
'You do not have permission to access the browser phone.',
|
||||||
|
'Access Denied',
|
||||||
|
array('response' => 403)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render the standalone page and exit.
|
||||||
|
$this->render_page();
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extend auth cookie to 7 days for phone agents.
|
||||||
|
*
|
||||||
|
* @param int $expiration Default expiration in seconds.
|
||||||
|
* @param int $user_id User ID.
|
||||||
|
* @param bool $remember Whether "Remember Me" was checked.
|
||||||
|
* @return int
|
||||||
|
*/
|
||||||
|
public function extend_agent_cookie($expiration, $user_id, $remember) {
|
||||||
|
$user = get_userdata($user_id);
|
||||||
|
if ($user && $user->has_cap('twp_access_browser_phone')) {
|
||||||
|
return 7 * DAY_IN_SECONDS;
|
||||||
|
}
|
||||||
|
return $expiration;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
// Rendering
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Output the complete standalone HTML page.
|
||||||
|
*/
|
||||||
|
private function render_page() {
|
||||||
|
// Gather data needed by the template (same as display_browser_phone_page).
|
||||||
|
$current_user_id = get_current_user_id();
|
||||||
|
|
||||||
|
global $wpdb;
|
||||||
|
$extensions_table = $wpdb->prefix . 'twp_user_extensions';
|
||||||
|
$extension_data = $wpdb->get_row($wpdb->prepare(
|
||||||
|
"SELECT extension FROM $extensions_table WHERE user_id = %d",
|
||||||
|
$current_user_id
|
||||||
|
));
|
||||||
|
|
||||||
|
if (!$extension_data) {
|
||||||
|
TWP_User_Queue_Manager::create_user_queues($current_user_id);
|
||||||
|
$extension_data = $wpdb->get_row($wpdb->prepare(
|
||||||
|
"SELECT extension FROM $extensions_table WHERE user_id = %d",
|
||||||
|
$current_user_id
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
$agent_status = TWP_Agent_Manager::get_agent_status($current_user_id);
|
||||||
|
$agent_stats = TWP_Agent_Manager::get_agent_stats($current_user_id);
|
||||||
|
$is_logged_in = TWP_Agent_Manager::is_agent_logged_in($current_user_id);
|
||||||
|
|
||||||
|
$current_mode = get_user_meta($current_user_id, 'twp_call_mode', true);
|
||||||
|
if (empty($current_mode)) {
|
||||||
|
$current_mode = 'cell';
|
||||||
|
}
|
||||||
|
|
||||||
|
$user_phone = get_user_meta($current_user_id, 'twp_phone_number', true);
|
||||||
|
|
||||||
|
// Smart routing check (for admin-only setup notice).
|
||||||
|
$smart_routing_configured = false;
|
||||||
|
try {
|
||||||
|
$twilio = new TWP_Twilio_API();
|
||||||
|
$phone_numbers = $twilio->get_phone_numbers();
|
||||||
|
if ($phone_numbers['success']) {
|
||||||
|
$smart_routing_url = home_url('/wp-json/twilio-webhook/v1/smart-routing');
|
||||||
|
foreach ($phone_numbers['data']['incoming_phone_numbers'] as $number) {
|
||||||
|
if (isset($number['voice_url']) && strpos($number['voice_url'], 'smart-routing') !== false) {
|
||||||
|
$smart_routing_configured = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (Exception $e) {
|
||||||
|
// Silently continue.
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nonce for AJAX.
|
||||||
|
$nonce = wp_create_nonce('twp_ajax_nonce');
|
||||||
|
|
||||||
|
// URLs.
|
||||||
|
$ajax_url = admin_url('admin-ajax.php');
|
||||||
|
$ringtone_url = plugins_url('assets/sounds/ringtone.mp3', dirname(__FILE__));
|
||||||
|
$phone_icon_url = plugins_url('assets/images/phone-icon.png', dirname(__FILE__));
|
||||||
|
$sw_url = plugins_url('assets/js/twp-service-worker.js', dirname(__FILE__));
|
||||||
|
$twilio_edge = esc_js(get_option('twp_twilio_edge', 'roaming'));
|
||||||
|
$smart_routing_webhook = home_url('/wp-json/twilio-webhook/v1/smart-routing');
|
||||||
|
|
||||||
|
// Plugin file reference for plugins_url() in template.
|
||||||
|
$plugin_file = dirname(__FILE__) . '/../twilio-wp-plugin.php';
|
||||||
|
|
||||||
|
// Load the template (all variables above are in scope).
|
||||||
|
require TWP_PLUGIN_DIR . 'assets/mobile/phone-template.php';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -26,14 +26,32 @@ class TWP_Mobile_SSE {
|
|||||||
'callback' => array($this, 'stream_events'),
|
'callback' => array($this, 'stream_events'),
|
||||||
'permission_callback' => array($this->auth, 'verify_token')
|
'permission_callback' => array($this->auth, 'verify_token')
|
||||||
));
|
));
|
||||||
|
register_rest_route('twilio-mobile/v1', '/stream/poll', array(
|
||||||
|
'methods' => 'GET',
|
||||||
|
'callback' => array($this, 'poll_state'),
|
||||||
|
'permission_callback' => array($this->auth, 'verify_token')
|
||||||
|
));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return current state as JSON (polling alternative to SSE)
|
||||||
|
*/
|
||||||
|
public function poll_state($request) {
|
||||||
|
$user_id = $this->auth->get_current_user_id();
|
||||||
|
if (!$user_id) {
|
||||||
|
return new WP_Error('unauthorized', 'Invalid token', array('status' => 401));
|
||||||
|
}
|
||||||
|
return rest_ensure_response($this->get_current_state($user_id));
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Stream events to mobile app
|
* Stream events to mobile app
|
||||||
*/
|
*/
|
||||||
public function stream_events($request) {
|
public function stream_events($request) {
|
||||||
|
error_log('TWP SSE: stream_events called');
|
||||||
$user_id = $this->auth->get_current_user_id();
|
$user_id = $this->auth->get_current_user_id();
|
||||||
|
error_log('TWP SSE: user_id=' . ($user_id ?: 'false'));
|
||||||
|
|
||||||
if (!$user_id) {
|
if (!$user_id) {
|
||||||
return new WP_Error('unauthorized', 'Invalid token', array('status' => 401));
|
return new WP_Error('unauthorized', 'Invalid token', array('status' => 401));
|
||||||
@@ -56,6 +74,15 @@ class TWP_Mobile_SSE {
|
|||||||
ob_end_flush();
|
ob_end_flush();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Flush padding to overcome Apache/HTTP2 frame buffering.
|
||||||
|
// SSE comments (lines starting with ':') are ignored by clients.
|
||||||
|
// We send >4KB to ensure the first HTTP/2 DATA frame is flushed.
|
||||||
|
echo ':' . str_repeat(' ', 4096) . "\n\n";
|
||||||
|
if (ob_get_level() > 0) ob_flush();
|
||||||
|
flush();
|
||||||
|
|
||||||
|
error_log('TWP SSE: padding flushed, sending connected event');
|
||||||
|
|
||||||
// Send initial connection event
|
// Send initial connection event
|
||||||
$this->send_event('connected', array('user_id' => $user_id, 'timestamp' => time()));
|
$this->send_event('connected', array('user_id' => $user_id, 'timestamp' => time()));
|
||||||
|
|
||||||
@@ -142,38 +169,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) {
|
||||||
|
|||||||
@@ -329,6 +329,12 @@ class TWP_Webhooks {
|
|||||||
*/
|
*/
|
||||||
public function handle_browser_voice($request) {
|
public function handle_browser_voice($request) {
|
||||||
$params = $request->get_params();
|
$params = $request->get_params();
|
||||||
|
error_log('TWP browser-voice webhook params: ' . json_encode(array(
|
||||||
|
'From' => $params['From'] ?? '',
|
||||||
|
'To' => $params['To'] ?? '',
|
||||||
|
'CallerId' => $params['CallerId'] ?? '',
|
||||||
|
'CallSid' => $params['CallSid'] ?? '',
|
||||||
|
)));
|
||||||
|
|
||||||
$call_data = array(
|
$call_data = array(
|
||||||
'CallSid' => isset($params['CallSid']) ? $params['CallSid'] : '',
|
'CallSid' => isset($params['CallSid']) ? $params['CallSid'] : '',
|
||||||
@@ -371,14 +377,42 @@ 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 From as identity (e.g. "agent2jknapp"), browser sends From as phone number
|
||||||
|
// Only use CallerId/From if it looks like a phone number (starts with + or is all digits)
|
||||||
|
$from_number = '';
|
||||||
|
if (!empty($params['CallerId']) && preg_match('/^\+?\d+$/', $params['CallerId'])) {
|
||||||
|
$from_number = $params['CallerId'];
|
||||||
|
} elseif (!empty($params['From']) && preg_match('/^\+?\d+$/', $params['From'])) {
|
||||||
|
$from_number = $params['From'];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fall back to default caller ID if no valid one provided
|
||||||
|
if (empty($from_number)) {
|
||||||
|
$from_number = get_option('twp_caller_id_number', '');
|
||||||
|
}
|
||||||
|
if (empty($from_number)) {
|
||||||
|
$from_number = get_option('twp_default_sms_number', '');
|
||||||
|
}
|
||||||
|
// Last resort: fetch first Twilio number from API
|
||||||
|
if (empty($from_number)) {
|
||||||
|
try {
|
||||||
|
require_once plugin_dir_path(dirname(__FILE__)) . 'includes/class-twp-twilio-api.php';
|
||||||
|
$twilio = new TWP_Twilio_API();
|
||||||
|
$numbers = $twilio->get_phone_numbers();
|
||||||
|
if (!empty($numbers['data']['incoming_phone_numbers'][0]['phone_number'])) {
|
||||||
|
$from_number = $numbers['data']['incoming_phone_numbers'][0]['phone_number'];
|
||||||
|
}
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
error_log('TWP browser-voice: failed to fetch default number: ' . $e->getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 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) {
|
||||||
$twiml .= '<Dial timeout="30"';
|
$twiml .= '<Dial timeout="30"';
|
||||||
|
|
||||||
// Add caller ID if provided
|
// Add caller ID (required for outbound calls to phone numbers)
|
||||||
if (!empty($from_number) && strpos($from_number, 'client:') !== 0) {
|
if (!empty($from_number)) {
|
||||||
$twiml .= ' callerId="' . htmlspecialchars($from_number) . '"';
|
$twiml .= ' callerId="' . htmlspecialchars($from_number) . '"';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -397,6 +431,8 @@ class TWP_Webhooks {
|
|||||||
|
|
||||||
$twiml .= '</Response>';
|
$twiml .= '</Response>';
|
||||||
|
|
||||||
|
error_log('TWP browser-voice TwiML: ' . $twiml);
|
||||||
|
|
||||||
return $this->send_twiml_response($twiml);
|
return $this->send_twiml_response($twiml);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -477,6 +513,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 +747,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', '');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -894,11 +944,36 @@ class TWP_Webhooks {
|
|||||||
// Update call status in queue if applicable
|
// Update call status in queue if applicable
|
||||||
// Remove from queue for any terminal call state
|
// Remove from queue for any terminal call state
|
||||||
if (in_array($status_data['CallStatus'], ['completed', 'busy', 'failed', 'canceled', 'no-answer'])) {
|
if (in_array($status_data['CallStatus'], ['completed', 'busy', 'failed', 'canceled', 'no-answer'])) {
|
||||||
|
// Get queue_id before removing so we can send cancel notifications
|
||||||
|
global $wpdb;
|
||||||
|
$calls_table = $wpdb->prefix . 'twp_queued_calls';
|
||||||
|
$queued_call = $wpdb->get_row($wpdb->prepare(
|
||||||
|
"SELECT queue_id FROM $calls_table WHERE call_sid = %s",
|
||||||
|
$status_data['CallSid']
|
||||||
|
));
|
||||||
|
|
||||||
$queue_removed = TWP_Call_Queue::remove_from_queue($status_data['CallSid']);
|
$queue_removed = TWP_Call_Queue::remove_from_queue($status_data['CallSid']);
|
||||||
if ($queue_removed) {
|
if ($queue_removed) {
|
||||||
TWP_Call_Logger::log_action($status_data['CallSid'], 'Call removed from queue due to status: ' . $status_data['CallStatus']);
|
TWP_Call_Logger::log_action($status_data['CallSid'], 'Call removed from queue due to status: ' . $status_data['CallStatus']);
|
||||||
error_log('TWP Status Webhook: Removed call ' . $status_data['CallSid'] . ' from queue (status: ' . $status_data['CallStatus'] . ')');
|
error_log('TWP Status Webhook: Removed call ' . $status_data['CallSid'] . ' from queue (status: ' . $status_data['CallStatus'] . ')');
|
||||||
|
|
||||||
|
// Cancel queue alert notifications on agents' devices
|
||||||
|
if ($queued_call) {
|
||||||
|
require_once plugin_dir_path(__FILE__) . 'class-twp-fcm.php';
|
||||||
|
$fcm = new TWP_FCM();
|
||||||
|
$fcm->cancel_queue_alert_for_queue($queued_call->queue_id, $status_data['CallSid']);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Set auto_busy_at for agents whose call just ended, so they revert after 30s
|
||||||
|
$agent_status_table = $wpdb->prefix . 'twp_agent_status';
|
||||||
|
$wpdb->query($wpdb->prepare(
|
||||||
|
"UPDATE $agent_status_table
|
||||||
|
SET auto_busy_at = %s, current_call_sid = NULL
|
||||||
|
WHERE current_call_sid = %s AND status = 'busy'",
|
||||||
|
current_time('mysql'),
|
||||||
|
$status_data['CallSid']
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Empty response
|
// Empty response
|
||||||
@@ -1201,6 +1276,20 @@ class TWP_Webhooks {
|
|||||||
|
|
||||||
if ($updated) {
|
if ($updated) {
|
||||||
error_log('TWP Queue Action: Updated call status to ' . $status);
|
error_log('TWP Queue Action: Updated call status to ' . $status);
|
||||||
|
|
||||||
|
// Cancel FCM queue alerts when call leaves the queue for any reason
|
||||||
|
if (in_array($status, array('answered', 'hangup', 'transferred', 'timeout', 'completed'))) {
|
||||||
|
$queued_call = $wpdb->get_row($wpdb->prepare(
|
||||||
|
"SELECT queue_id FROM $table_name WHERE call_sid = %s",
|
||||||
|
$call_sid
|
||||||
|
));
|
||||||
|
if ($queued_call) {
|
||||||
|
require_once plugin_dir_path(__FILE__) . 'class-twp-fcm.php';
|
||||||
|
$fcm = new TWP_FCM();
|
||||||
|
$fcm->cancel_queue_alert_for_queue($queued_call->queue_id, $call_sid);
|
||||||
|
error_log('TWP Queue Action: Sent FCM cancel for call ' . $call_sid);
|
||||||
|
}
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
error_log('TWP Queue Action: No call found to update with SID ' . $call_sid);
|
error_log('TWP Queue Action: No call found to update with SID ' . $call_sid);
|
||||||
}
|
}
|
||||||
|
|||||||
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'
|
||||||
176
mobile/README.md
Normal file
176
mobile/README.md
Normal file
@@ -0,0 +1,176 @@
|
|||||||
|
# TWP Softphone — Mobile App
|
||||||
|
|
||||||
|
Flutter-based VoIP softphone client for the Twilio WordPress Plugin. Uses the Twilio Voice SDK (WebRTC) to make and receive calls via the Android Telecom framework.
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
- Flutter 3.29+ (tested with 3.41.4)
|
||||||
|
- Android device/tablet (API 26+)
|
||||||
|
- TWP WordPress plugin installed and configured on server
|
||||||
|
- Twilio account with Voice capability
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd mobile
|
||||||
|
flutter pub get
|
||||||
|
flutter build apk --debug
|
||||||
|
adb install build/app/outputs/flutter-apk/app-debug.apk
|
||||||
|
```
|
||||||
|
|
||||||
|
## Server Setup
|
||||||
|
|
||||||
|
The app connects to your WordPress site running the TWP plugin. The server must have:
|
||||||
|
|
||||||
|
1. **TWP Plugin** installed and activated
|
||||||
|
2. **Twilio credentials** configured (Account SID, Auth Token)
|
||||||
|
3. **At least one Twilio phone number** purchased
|
||||||
|
4. **A WordPress user** with agent permissions
|
||||||
|
|
||||||
|
### SSE (Server-Sent Events) — Apache + PHP-FPM
|
||||||
|
|
||||||
|
The app uses SSE for real-time updates (queue changes, agent status). On Apache with PHP-FPM, `mod_proxy_fcgi` buffers output by default, which breaks SSE streaming.
|
||||||
|
|
||||||
|
**Fix** — Create a config file on the web server:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
echo 'ProxyPassMatch "^/wp-json/twilio-mobile/v1/stream/events$" "unix:/run/php-fpm/www.sock|fcgi://localhost/path/to/wordpress/index.php" flushpackets=on' > /etc/httpd/conf.d/twp-sse.conf
|
||||||
|
httpd -t && systemctl restart httpd
|
||||||
|
```
|
||||||
|
|
||||||
|
> **Adjust the paths:**
|
||||||
|
> - Socket path must match your PHP-FPM config (check `grep fcgi /etc/httpd/conf.d/php.conf`)
|
||||||
|
> - Document root must match your WordPress installation path
|
||||||
|
|
||||||
|
**Diagnosis** — If the green connection dot stays red:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Check current PHP-FPM proxy config
|
||||||
|
grep -r "fcgi\|php-fpm" /etc/httpd/conf.d/
|
||||||
|
|
||||||
|
# Check if flushpackets is configured
|
||||||
|
grep -r "flushpackets" /etc/httpd/conf.d/
|
||||||
|
|
||||||
|
# Test SSE endpoint (should stream data continuously, not hang)
|
||||||
|
curl -N -H "Authorization: Bearer YOUR_TOKEN" \
|
||||||
|
https://your-site.com/wp-json/twilio-mobile/v1/stream/events
|
||||||
|
```
|
||||||
|
|
||||||
|
**Notes:**
|
||||||
|
- `flushpackets=on` is a `ProxyPassMatch` directive — it **cannot** go in `.htaccess`
|
||||||
|
- If using **nginx** instead of Apache, the `X-Accel-Buffering: no` header (already in the PHP code) handles this automatically
|
||||||
|
- The app automatically falls back to 5-second polling if SSE fails, so the app still works without this config — just with higher latency
|
||||||
|
|
||||||
|
## App Setup (Android)
|
||||||
|
|
||||||
|
### First Launch
|
||||||
|
|
||||||
|
1. Open the app and enter your server URL (e.g., `https://phone.cloud-hosting.io`)
|
||||||
|
2. Log in with your WordPress credentials
|
||||||
|
3. Grant permissions when prompted:
|
||||||
|
- Microphone (required for calls)
|
||||||
|
- Phone/Call (required for Android Telecom integration)
|
||||||
|
|
||||||
|
### Phone Account
|
||||||
|
|
||||||
|
Android requires a registered and **enabled** phone account for VoIP apps. The app registers automatically, but enabling must be done manually:
|
||||||
|
|
||||||
|
1. If prompted, tap **"Open Settings"** to go to Android's Phone Account settings
|
||||||
|
2. Find **"TWP Softphone"** in the list and toggle it **ON**
|
||||||
|
3. Return to the app
|
||||||
|
|
||||||
|
If you skipped this step, tap the orange warning card on the dashboard.
|
||||||
|
|
||||||
|
> **Path:** Settings → Apps → Default apps → Phone → Calling accounts → TWP Softphone
|
||||||
|
|
||||||
|
### Making Calls
|
||||||
|
|
||||||
|
1. Tap the phone FAB (bottom right) to open the dialer
|
||||||
|
2. Enter the phone number
|
||||||
|
3. Caller ID is auto-selected from your Twilio numbers
|
||||||
|
4. Tap **Call** — the Android system call screen (InCallUI) handles the active call
|
||||||
|
|
||||||
|
### Receiving Calls
|
||||||
|
|
||||||
|
Incoming calls appear via Android's native call UI. Answer/reject using the standard Android interface.
|
||||||
|
|
||||||
|
> **Note:** FCM push notifications are required for receiving calls when the app is in the background. This requires `google-services.json` in `android/app/`.
|
||||||
|
|
||||||
|
### Queue Management
|
||||||
|
|
||||||
|
- View assigned queues on the dashboard
|
||||||
|
- Tap a queue with waiting calls to see callers
|
||||||
|
- Tap **Accept** to take a call from the queue
|
||||||
|
|
||||||
|
### Agent Status
|
||||||
|
|
||||||
|
Toggle between **Available**, **Busy**, and **Offline** using the status bar at the top of the dashboard.
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
### Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
lib/
|
||||||
|
├── config/ # App configuration
|
||||||
|
├── models/ # Data models (CallInfo, QueueState, AgentStatus, User)
|
||||||
|
├── providers/ # State management (AuthProvider, CallProvider, AgentProvider)
|
||||||
|
├── screens/ # UI screens (Login, Dashboard, Settings, ActiveCall)
|
||||||
|
├── services/ # API/SDK services (VoiceService, SseService, ApiClient, AuthService)
|
||||||
|
├── widgets/ # Reusable widgets (Dialpad, QueueCard, AgentStatusToggle)
|
||||||
|
└── main.dart # App entry point
|
||||||
|
```
|
||||||
|
|
||||||
|
### Running Tests
|
||||||
|
|
||||||
|
```bash
|
||||||
|
flutter test
|
||||||
|
```
|
||||||
|
|
||||||
|
34 tests covering CallInfo, QueueState, and CallProvider.
|
||||||
|
|
||||||
|
### Building
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Debug APK
|
||||||
|
flutter build apk --debug
|
||||||
|
|
||||||
|
# Release APK (requires signing config)
|
||||||
|
flutter build apk --release
|
||||||
|
```
|
||||||
|
|
||||||
|
### ADB Deployment (WiFi)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Connect to device
|
||||||
|
adb connect DEVICE_IP:PORT
|
||||||
|
|
||||||
|
# Install
|
||||||
|
adb install -r build/app/outputs/flutter-apk/app-debug.apk
|
||||||
|
|
||||||
|
# Launch
|
||||||
|
adb shell am start -n io.cloudhosting.twp.twp_softphone/.MainActivity
|
||||||
|
|
||||||
|
# View logs
|
||||||
|
adb logcat -s flutter
|
||||||
|
```
|
||||||
|
|
||||||
|
### Key Dependencies
|
||||||
|
|
||||||
|
| Package | Purpose |
|
||||||
|
|---------|---------|
|
||||||
|
| `twilio_voice` | Twilio Voice SDK (WebRTC calling) |
|
||||||
|
| `provider` | State management |
|
||||||
|
| `dio` | HTTP client (REST API, SSE) |
|
||||||
|
| `firebase_messaging` | FCM push for incoming calls |
|
||||||
|
| `flutter_secure_storage` | Secure token storage |
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
| Problem | Solution |
|
||||||
|
|---------|----------|
|
||||||
|
| Green dot stays red | SSE buffering — see [Server Setup](#sse-server-sent-events--apache--php-fpm) |
|
||||||
|
| "No registered phone account" | Enable phone account in Android Settings (see [Phone Account](#phone-account)) |
|
||||||
|
| Calls fail with "Invalid callerId" | Server webhook needs phone number validation — check `handle_browser_voice` in `class-twp-webhooks.php` |
|
||||||
|
| App hangs on login | Check server is reachable: `curl https://your-site.com/wp-json/twilio-mobile/v1/auth/login` |
|
||||||
|
| No incoming calls | Ensure FCM is configured (`google-services.json`) and phone account is enabled |
|
||||||
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"
|
||||||
|
}
|
||||||
14
mobile/android/app/proguard-rules.pro
vendored
Normal file
14
mobile/android/app/proguard-rules.pro
vendored
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
# Twilio Voice SDK
|
||||||
|
-keep class com.twilio.** { *; }
|
||||||
|
-keep class tvo.webrtc.** { *; }
|
||||||
|
|
||||||
|
# Firebase
|
||||||
|
-keep class com.google.firebase.** { *; }
|
||||||
|
|
||||||
|
# Flutter
|
||||||
|
-keep class io.flutter.** { *; }
|
||||||
|
|
||||||
|
# Play Core (not used but referenced by Flutter engine)
|
||||||
|
-dontwarn com.google.android.play.core.splitcompat.SplitCompatApplication
|
||||||
|
-dontwarn com.google.android.play.core.splitinstall.**
|
||||||
|
-dontwarn com.google.android.play.core.tasks.**
|
||||||
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>
|
||||||
64
mobile/android/app/src/main/AndroidManifest.xml
Normal file
64
mobile/android/app/src/main/AndroidManifest.xml
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
|
||||||
|
<!-- Audio permissions for WebRTC -->
|
||||||
|
<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 -->
|
||||||
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE"/>
|
||||||
|
|
||||||
|
<!-- 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"/>
|
||||||
|
|
||||||
|
<!-- 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="Twilio-WP"
|
||||||
|
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 io.flutter.plugins.webviewflutter.WebViewFlutterPlugin());
|
||||||
|
} catch (Exception e) {
|
||||||
|
Log.e(TAG, "Error registering plugin webview_flutter_android, io.flutter.plugins.webviewflutter.WebViewFlutterPlugin", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
mobile/android/app/src/main/res/mipmap-hdpi/ic_launcher.png
Normal file
BIN
mobile/android/app/src/main/res/mipmap-hdpi/ic_launcher.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 415 B |
BIN
mobile/android/app/src/main/res/mipmap-mdpi/ic_launcher.png
Normal file
BIN
mobile/android/app/src/main/res/mipmap-mdpi/ic_launcher.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 341 B |
BIN
mobile/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png
Normal file
BIN
mobile/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 494 B |
BIN
mobile/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png
Normal file
BIN
mobile/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 619 B |
BIN
mobile/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png
Normal file
BIN
mobile/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 811 B |
BIN
mobile/android/app/src/main/res/raw/queue_alert.ogg
Normal file
BIN
mobile/android/app/src/main/res/raw/queue_alert.ogg
Normal file
Binary file not shown.
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
|
||||||
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"
|
||||||
87
mobile/lib/app.dart
Normal file
87
mobile/lib/app.dart
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||||
|
import 'screens/login_screen.dart';
|
||||||
|
import 'screens/phone_screen.dart';
|
||||||
|
|
||||||
|
class TwpSoftphoneApp extends StatefulWidget {
|
||||||
|
const TwpSoftphoneApp({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<TwpSoftphoneApp> createState() => _TwpSoftphoneAppState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _TwpSoftphoneAppState extends State<TwpSoftphoneApp> {
|
||||||
|
static const _storage = FlutterSecureStorage();
|
||||||
|
String? _serverUrl;
|
||||||
|
bool _loading = true;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_checkSavedSession();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _checkSavedSession() async {
|
||||||
|
final url = await _storage.read(key: 'server_url');
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_serverUrl = url;
|
||||||
|
_loading = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onLoginSuccess(String serverUrl) {
|
||||||
|
setState(() {
|
||||||
|
_serverUrl = serverUrl;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onLogout() async {
|
||||||
|
await _storage.delete(key: 'server_url');
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_serverUrl = null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onSessionExpired() {
|
||||||
|
// Server URL is still saved, but session cookie is gone.
|
||||||
|
// Show login screen but keep the server URL pre-filled.
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_serverUrl = null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return 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: _loading
|
||||||
|
? const Scaffold(
|
||||||
|
body: Center(child: CircularProgressIndicator()),
|
||||||
|
)
|
||||||
|
: _serverUrl != null
|
||||||
|
? PhoneScreen(
|
||||||
|
serverUrl: _serverUrl!,
|
||||||
|
onLogout: _onLogout,
|
||||||
|
onSessionExpired: _onSessionExpired,
|
||||||
|
)
|
||||||
|
: LoginScreen(onLoginSuccess: _onLoginSuccess),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
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());
|
||||||
|
}
|
||||||
195
mobile/lib/screens/login_screen.dart
Normal file
195
mobile/lib/screens/login_screen.dart
Normal file
@@ -0,0 +1,195 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||||
|
import 'package:webview_flutter/webview_flutter.dart';
|
||||||
|
|
||||||
|
/// Login screen that loads wp-login.php in a WebView.
|
||||||
|
///
|
||||||
|
/// When the user successfully logs in, WordPress redirects to /twp-phone/.
|
||||||
|
/// We detect that URL change and report login success to the parent.
|
||||||
|
class LoginScreen extends StatefulWidget {
|
||||||
|
final void Function(String serverUrl) onLoginSuccess;
|
||||||
|
|
||||||
|
const LoginScreen({super.key, required this.onLoginSuccess});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<LoginScreen> createState() => _LoginScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _LoginScreenState extends State<LoginScreen> {
|
||||||
|
static const _storage = FlutterSecureStorage();
|
||||||
|
|
||||||
|
final _formKey = GlobalKey<FormState>();
|
||||||
|
final _serverController = TextEditingController();
|
||||||
|
bool _showWebView = false;
|
||||||
|
bool _webViewLoading = true;
|
||||||
|
String? _error;
|
||||||
|
late WebViewController _webViewController;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_loadSavedServer();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _loadSavedServer() async {
|
||||||
|
final saved = await _storage.read(key: 'server_url');
|
||||||
|
if (saved != null && mounted) {
|
||||||
|
_serverController.text = saved;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _startLogin() {
|
||||||
|
if (!_formKey.currentState!.validate()) return;
|
||||||
|
|
||||||
|
var serverUrl = _serverController.text.trim();
|
||||||
|
if (!serverUrl.startsWith('http')) {
|
||||||
|
serverUrl = 'https://$serverUrl';
|
||||||
|
}
|
||||||
|
// Remove trailing slash
|
||||||
|
serverUrl = serverUrl.replaceAll(RegExp(r'/+$'), '');
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
_showWebView = true;
|
||||||
|
_webViewLoading = true;
|
||||||
|
_error = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
final loginUrl =
|
||||||
|
'$serverUrl/wp-login.php?redirect_to=${Uri.encodeComponent('$serverUrl/twp-phone/')}';
|
||||||
|
|
||||||
|
_webViewController = WebViewController()
|
||||||
|
..setJavaScriptMode(JavaScriptMode.unrestricted)
|
||||||
|
..setNavigationDelegate(
|
||||||
|
NavigationDelegate(
|
||||||
|
onPageStarted: (url) {
|
||||||
|
// Check if we've been redirected to the phone page (login success)
|
||||||
|
if (url.contains('/twp-phone/') || url.endsWith('/twp-phone')) {
|
||||||
|
_onLoginComplete(serverUrl);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onPageFinished: (url) {
|
||||||
|
if (mounted) {
|
||||||
|
setState(() => _webViewLoading = false);
|
||||||
|
}
|
||||||
|
// Also check on page finish in case redirect was instant
|
||||||
|
if (url.contains('/twp-phone/') || url.endsWith('/twp-phone')) {
|
||||||
|
_onLoginComplete(serverUrl);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onWebResourceError: (error) {
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_showWebView = false;
|
||||||
|
_error =
|
||||||
|
'Could not connect to server: ${error.description}';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
..setUserAgent('TWPMobile/2.0 (Android; WebView)')
|
||||||
|
..loadRequest(Uri.parse(loginUrl));
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _onLoginComplete(String serverUrl) async {
|
||||||
|
// Save server URL for next launch
|
||||||
|
await _storage.write(key: 'server_url', value: serverUrl);
|
||||||
|
if (mounted) {
|
||||||
|
widget.onLoginSuccess(serverUrl);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _cancelLogin() {
|
||||||
|
setState(() {
|
||||||
|
_showWebView = false;
|
||||||
|
_error = null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
if (_showWebView) {
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(
|
||||||
|
leading: IconButton(
|
||||||
|
icon: const Icon(Icons.arrow_back),
|
||||||
|
onPressed: _cancelLogin,
|
||||||
|
),
|
||||||
|
title: const Text('Sign In'),
|
||||||
|
),
|
||||||
|
body: Stack(
|
||||||
|
children: [
|
||||||
|
WebViewWidget(controller: _webViewController),
|
||||||
|
if (_webViewLoading)
|
||||||
|
const Center(child: CircularProgressIndicator()),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Scaffold(
|
||||||
|
body: SafeArea(
|
||||||
|
child: Center(
|
||||||
|
child: SingleChildScrollView(
|
||||||
|
padding: const EdgeInsets.all(24),
|
||||||
|
child: Form(
|
||||||
|
key: _formKey,
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.phone_in_talk,
|
||||||
|
size: 64,
|
||||||
|
color: Theme.of(context).colorScheme.primary,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Text(
|
||||||
|
'TWP Softphone',
|
||||||
|
style: Theme.of(context).textTheme.headlineMedium,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 32),
|
||||||
|
TextFormField(
|
||||||
|
controller: _serverController,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Server URL',
|
||||||
|
hintText: 'https://your-site.com',
|
||||||
|
prefixIcon: Icon(Icons.dns),
|
||||||
|
border: OutlineInputBorder(),
|
||||||
|
),
|
||||||
|
keyboardType: TextInputType.url,
|
||||||
|
autofillHints: const [AutofillHints.url],
|
||||||
|
validator: (v) =>
|
||||||
|
v == null || v.trim().isEmpty ? 'Required' : null,
|
||||||
|
),
|
||||||
|
if (_error != null) ...[
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Text(
|
||||||
|
_error!,
|
||||||
|
style: TextStyle(
|
||||||
|
color: Theme.of(context).colorScheme.error),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
SizedBox(
|
||||||
|
width: double.infinity,
|
||||||
|
height: 48,
|
||||||
|
child: FilledButton(
|
||||||
|
onPressed: _startLogin,
|
||||||
|
child: const Text('Connect'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_serverController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
332
mobile/lib/screens/phone_screen.dart
Normal file
332
mobile/lib/screens/phone_screen.dart
Normal file
@@ -0,0 +1,332 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:permission_handler/permission_handler.dart';
|
||||||
|
import 'package:webview_flutter/webview_flutter.dart';
|
||||||
|
import 'package:webview_flutter_android/webview_flutter_android.dart';
|
||||||
|
import '../services/push_notification_service.dart';
|
||||||
|
|
||||||
|
/// Full-screen WebView that loads the TWP phone page.
|
||||||
|
///
|
||||||
|
/// Handles:
|
||||||
|
/// - Microphone permission grants for WebRTC
|
||||||
|
/// - JavaScript bridge (TwpMobile channel) for native communication
|
||||||
|
/// - Session expiry detection (redirect to wp-login.php)
|
||||||
|
/// - Back button confirmation to prevent accidental exit
|
||||||
|
/// - Network error retry UI
|
||||||
|
class PhoneScreen extends StatefulWidget {
|
||||||
|
final String serverUrl;
|
||||||
|
final VoidCallback onLogout;
|
||||||
|
final VoidCallback onSessionExpired;
|
||||||
|
|
||||||
|
const PhoneScreen({
|
||||||
|
super.key,
|
||||||
|
required this.serverUrl,
|
||||||
|
required this.onLogout,
|
||||||
|
required this.onSessionExpired,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<PhoneScreen> createState() => _PhoneScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _PhoneScreenState extends State<PhoneScreen> with WidgetsBindingObserver {
|
||||||
|
late final WebViewController _controller;
|
||||||
|
late final PushNotificationService _pushService;
|
||||||
|
bool _loading = true;
|
||||||
|
bool _hasError = false;
|
||||||
|
String? _errorMessage;
|
||||||
|
bool _sessionExpired = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
WidgetsBinding.instance.addObserver(this);
|
||||||
|
_pushService = PushNotificationService();
|
||||||
|
_requestPermissionsAndInit();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _requestPermissionsAndInit() async {
|
||||||
|
// Request microphone permission before initializing WebView
|
||||||
|
final micStatus = await Permission.microphone.request();
|
||||||
|
if (micStatus.isDenied || micStatus.isPermanentlyDenied) {
|
||||||
|
debugPrint('TWP: Microphone permission denied: $micStatus');
|
||||||
|
}
|
||||||
|
_initWebView();
|
||||||
|
_initPush();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
WidgetsBinding.instance.removeObserver(this);
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _initPush() async {
|
||||||
|
await _pushService.initialize();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _initWebView() {
|
||||||
|
_controller = WebViewController()
|
||||||
|
..setJavaScriptMode(JavaScriptMode.unrestricted)
|
||||||
|
..setUserAgent('TWPMobile/2.0 (Android; WebView)')
|
||||||
|
..setNavigationDelegate(
|
||||||
|
NavigationDelegate(
|
||||||
|
onPageStarted: (url) {
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_loading = true;
|
||||||
|
_hasError = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// Detect session expiry: if we get redirected to wp-login.php
|
||||||
|
if (url.contains('/wp-login.php')) {
|
||||||
|
_sessionExpired = true;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onPageFinished: (url) {
|
||||||
|
if (mounted) {
|
||||||
|
setState(() => _loading = false);
|
||||||
|
}
|
||||||
|
if (_sessionExpired && url.contains('/wp-login.php')) {
|
||||||
|
widget.onSessionExpired();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_sessionExpired = false;
|
||||||
|
|
||||||
|
// Inject the FCM token into the page if available
|
||||||
|
_injectFcmToken();
|
||||||
|
},
|
||||||
|
onWebResourceError: (error) {
|
||||||
|
// Only handle main frame errors
|
||||||
|
if (error.isForMainFrame ?? true) {
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_loading = false;
|
||||||
|
_hasError = true;
|
||||||
|
_errorMessage = error.description;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onNavigationRequest: (request) {
|
||||||
|
// Allow all navigation within our server
|
||||||
|
if (request.url.startsWith(widget.serverUrl)) {
|
||||||
|
return NavigationDecision.navigate;
|
||||||
|
}
|
||||||
|
// Allow blob: and data: URLs (for downloads, etc.)
|
||||||
|
if (request.url.startsWith('blob:') ||
|
||||||
|
request.url.startsWith('data:')) {
|
||||||
|
return NavigationDecision.navigate;
|
||||||
|
}
|
||||||
|
// Block external navigation
|
||||||
|
return NavigationDecision.prevent;
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
..addJavaScriptChannel(
|
||||||
|
'TwpMobile',
|
||||||
|
onMessageReceived: _handleJsMessage,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Configure Android-specific settings
|
||||||
|
final androidController =
|
||||||
|
_controller.platform as AndroidWebViewController;
|
||||||
|
// Auto-grant microphone permission for WebRTC calls
|
||||||
|
androidController.setOnPlatformPermissionRequest(
|
||||||
|
(PlatformWebViewPermissionRequest request) {
|
||||||
|
request.grant();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
// Allow media playback without user gesture (for ringtones)
|
||||||
|
androidController.setMediaPlaybackRequiresUserGesture(false);
|
||||||
|
|
||||||
|
// Load the phone page
|
||||||
|
final phoneUrl = '${widget.serverUrl}/twp-phone/';
|
||||||
|
_controller.loadRequest(Uri.parse(phoneUrl));
|
||||||
|
}
|
||||||
|
|
||||||
|
void _handleJsMessage(JavaScriptMessage message) {
|
||||||
|
final msg = message.message;
|
||||||
|
|
||||||
|
if (msg == 'onSessionExpired') {
|
||||||
|
widget.onSessionExpired();
|
||||||
|
} else if (msg == 'requestFcmToken') {
|
||||||
|
_injectFcmToken();
|
||||||
|
} else if (msg == 'onPageReady') {
|
||||||
|
// Phone page loaded successfully
|
||||||
|
_injectFcmToken();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _injectFcmToken() async {
|
||||||
|
final token = _pushService.fcmToken;
|
||||||
|
if (token != null) {
|
||||||
|
// Send the FCM token to the web page via the TwpMobile bridge
|
||||||
|
await _controller.runJavaScript(
|
||||||
|
'if (window.TwpMobile && window.TwpMobile.setFcmToken) { window.TwpMobile.setFcmToken("$token"); }',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _retry() async {
|
||||||
|
setState(() {
|
||||||
|
_hasError = false;
|
||||||
|
_loading = true;
|
||||||
|
});
|
||||||
|
final phoneUrl = '${widget.serverUrl}/twp-phone/';
|
||||||
|
await _controller.loadRequest(Uri.parse(phoneUrl));
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<bool> _onWillPop() async {
|
||||||
|
// Check if WebView can go back
|
||||||
|
if (await _controller.canGoBack()) {
|
||||||
|
await _controller.goBack();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// Show confirmation dialog
|
||||||
|
if (!mounted) return true;
|
||||||
|
final result = await showDialog<bool>(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => AlertDialog(
|
||||||
|
title: const Text('Exit'),
|
||||||
|
content: const Text('Are you sure you want to exit the phone?'),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.of(context).pop(false),
|
||||||
|
child: const Text('Cancel'),
|
||||||
|
),
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.of(context).pop(true),
|
||||||
|
child: const Text('Exit'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return result ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
|
void _confirmLogout() async {
|
||||||
|
final result = await showDialog<bool>(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => AlertDialog(
|
||||||
|
title: const Text('Logout'),
|
||||||
|
content: const Text(
|
||||||
|
'This will clear your session. You will need to sign in again.'),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.of(context).pop(false),
|
||||||
|
child: const Text('Cancel'),
|
||||||
|
),
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.of(context).pop(true),
|
||||||
|
child: const Text('Logout'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result == true) {
|
||||||
|
// Clear WebView cookies
|
||||||
|
await WebViewCookieManager().clearCookies();
|
||||||
|
widget.onLogout();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
// ignore: deprecated_member_use
|
||||||
|
return WillPopScope(
|
||||||
|
onWillPop: _onWillPop,
|
||||||
|
child: Scaffold(
|
||||||
|
appBar: _hasError
|
||||||
|
? null
|
||||||
|
: AppBar(
|
||||||
|
toolbarHeight: 40,
|
||||||
|
titleSpacing: 12,
|
||||||
|
title: Text(
|
||||||
|
'TWP Softphone',
|
||||||
|
style: Theme.of(context).textTheme.titleSmall,
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.refresh, size: 20),
|
||||||
|
tooltip: 'Reload',
|
||||||
|
visualDensity: VisualDensity.compact,
|
||||||
|
onPressed: () => _controller.reload(),
|
||||||
|
),
|
||||||
|
PopupMenuButton<String>(
|
||||||
|
icon: const Icon(Icons.more_vert, size: 20),
|
||||||
|
tooltip: 'Menu',
|
||||||
|
padding: EdgeInsets.zero,
|
||||||
|
onSelected: (value) {
|
||||||
|
if (value == 'logout') _confirmLogout();
|
||||||
|
},
|
||||||
|
itemBuilder: (context) => [
|
||||||
|
const PopupMenuItem(
|
||||||
|
value: 'logout',
|
||||||
|
child: ListTile(
|
||||||
|
leading: Icon(Icons.logout),
|
||||||
|
title: Text('Logout'),
|
||||||
|
dense: true,
|
||||||
|
contentPadding: EdgeInsets.zero,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
body: SafeArea(
|
||||||
|
top: _hasError, // AppBar already handles top safe area when visible
|
||||||
|
child: Stack(
|
||||||
|
children: [
|
||||||
|
if (!_hasError) WebViewWidget(controller: _controller),
|
||||||
|
if (_hasError) _buildErrorView(),
|
||||||
|
if (_loading && !_hasError)
|
||||||
|
const Center(child: CircularProgressIndicator()),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildErrorView() {
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
return Center(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(24),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Icon(Icons.wifi_off, size: 64, color: colorScheme.onSurfaceVariant),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Text(
|
||||||
|
'Connection Error',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 20,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: colorScheme.onSurface,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
_errorMessage ?? 'Could not load the phone page.',
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: TextStyle(color: colorScheme.onSurfaceVariant),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
FilledButton.icon(
|
||||||
|
onPressed: _retry,
|
||||||
|
icon: const Icon(Icons.refresh),
|
||||||
|
label: const Text('Retry'),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
TextButton(
|
||||||
|
onPressed: widget.onLogout,
|
||||||
|
child: const Text('Change Server'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
143
mobile/lib/services/push_notification_service.dart
Normal file
143
mobile/lib/services/push_notification_service.dart
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
import 'dart:typed_data';
|
||||||
|
import 'package:firebase_core/firebase_core.dart';
|
||||||
|
import 'package:firebase_messaging/firebase_messaging.dart';
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
|
||||||
|
|
||||||
|
/// Notification ID for queue alerts (fixed so we can cancel it).
|
||||||
|
const int _queueAlertNotificationId = 9001;
|
||||||
|
|
||||||
|
/// Background handler -- must be top-level function.
|
||||||
|
@pragma('vm:entry-point')
|
||||||
|
Future<void> _firebaseMessagingBackgroundHandler(RemoteMessage message) async {
|
||||||
|
await Firebase.initializeApp();
|
||||||
|
final data = message.data;
|
||||||
|
final type = data['type'];
|
||||||
|
|
||||||
|
if (type == 'queue_alert') {
|
||||||
|
await _showQueueAlertNotification(data);
|
||||||
|
} else if (type == 'queue_alert_cancel') {
|
||||||
|
final plugin = FlutterLocalNotificationsPlugin();
|
||||||
|
await plugin.cancel(_queueAlertNotificationId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Show an insistent queue alert notification (works from background handler too).
|
||||||
|
Future<void> _showQueueAlertNotification(Map<String, dynamic> data) async {
|
||||||
|
final plugin = FlutterLocalNotificationsPlugin();
|
||||||
|
|
||||||
|
final title = data['title'] ?? 'Call Waiting';
|
||||||
|
final body = data['body'] ?? 'New call in queue';
|
||||||
|
|
||||||
|
final androidDetails = AndroidNotificationDetails(
|
||||||
|
'twp_queue_alerts',
|
||||||
|
'Queue Alerts',
|
||||||
|
channelDescription: 'Alerts when calls are waiting in queue',
|
||||||
|
importance: Importance.max,
|
||||||
|
priority: Priority.max,
|
||||||
|
playSound: true,
|
||||||
|
sound: const RawResourceAndroidNotificationSound('queue_alert'),
|
||||||
|
enableVibration: true,
|
||||||
|
vibrationPattern: Int64List.fromList([0, 500, 200, 500, 200, 500]),
|
||||||
|
ongoing: true,
|
||||||
|
autoCancel: false,
|
||||||
|
category: AndroidNotificationCategory.alarm,
|
||||||
|
additionalFlags: Int32List.fromList([4]), // FLAG_INSISTENT = 4
|
||||||
|
fullScreenIntent: true,
|
||||||
|
visibility: NotificationVisibility.public,
|
||||||
|
);
|
||||||
|
|
||||||
|
await plugin.show(
|
||||||
|
_queueAlertNotificationId,
|
||||||
|
title,
|
||||||
|
body,
|
||||||
|
NotificationDetails(android: androidDetails),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Push notification service for queue alerts and general notifications.
|
||||||
|
///
|
||||||
|
/// FCM token registration is handled via the WebView JavaScript bridge
|
||||||
|
/// instead of a REST API call. The token is exposed via [fcmToken] and
|
||||||
|
/// injected into the web page by [PhoneScreen].
|
||||||
|
class PushNotificationService {
|
||||||
|
final FirebaseMessaging _messaging = FirebaseMessaging.instance;
|
||||||
|
final FlutterLocalNotificationsPlugin _localNotifications =
|
||||||
|
FlutterLocalNotificationsPlugin();
|
||||||
|
String? _fcmToken;
|
||||||
|
|
||||||
|
String? get fcmToken => _fcmToken;
|
||||||
|
|
||||||
|
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 FCM token
|
||||||
|
final token = await _messaging.getToken();
|
||||||
|
debugPrint(
|
||||||
|
'FCM token: ${token != null ? "${token.substring(0, 20)}..." : "NULL"}');
|
||||||
|
if (token != null) {
|
||||||
|
_fcmToken = token;
|
||||||
|
} else {
|
||||||
|
debugPrint(
|
||||||
|
'FCM: Failed to get token - Firebase may not be configured correctly');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Listen for token refresh
|
||||||
|
_messaging.onTokenRefresh.listen((token) {
|
||||||
|
_fcmToken = token;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle foreground messages
|
||||||
|
FirebaseMessaging.onMessage.listen(_handleForegroundMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _handleForegroundMessage(RemoteMessage message) {
|
||||||
|
final data = message.data;
|
||||||
|
final type = data['type'];
|
||||||
|
|
||||||
|
// Queue alert -- show insistent notification
|
||||||
|
if (type == 'queue_alert') {
|
||||||
|
_showQueueAlertNotification(data);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Queue alert cancel -- dismiss notification
|
||||||
|
if (type == 'queue_alert_cancel') {
|
||||||
|
_localNotifications.cancel(_queueAlertNotificationId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show local notification for other types (missed call, etc.)
|
||||||
|
_localNotifications.show(
|
||||||
|
message.hashCode,
|
||||||
|
data['title'] ?? 'TWP Softphone',
|
||||||
|
data['body'] ?? '',
|
||||||
|
const NotificationDetails(
|
||||||
|
android: AndroidNotificationDetails(
|
||||||
|
'twp_general',
|
||||||
|
'General Notifications',
|
||||||
|
importance: Importance.high,
|
||||||
|
priority: Priority.high,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Cancel any active queue alert.
|
||||||
|
void cancelQueueAlert() {
|
||||||
|
_localNotifications.cancel(_queueAlertNotificationId);
|
||||||
|
}
|
||||||
|
}
|
||||||
554
mobile/pubspec.lock
Normal file
554
mobile/pubspec.lock
Normal file
@@ -0,0 +1,554 @@
|
|||||||
|
# Generated by pub
|
||||||
|
# See https://dart.dev/tools/pub/glossary#lockfile
|
||||||
|
packages:
|
||||||
|
_flutterfire_internals:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: _flutterfire_internals
|
||||||
|
sha256: ff0a84a2734d9e1089f8aedd5c0af0061b82fb94e95260d943404e0ef2134b11
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.3.59"
|
||||||
|
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: "947bfcf187f74dbc5e146c9eb9c0f10c9f8b30743e341481c1e2ed3ecc18c20c"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.11.0"
|
||||||
|
boolean_selector:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: boolean_selector
|
||||||
|
sha256: "6cfb5af12253eaf2b368f07bacc5a80d1301a071c73360d746b7f2e32d762c66"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.1.1"
|
||||||
|
characters:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: characters
|
||||||
|
sha256: "04a925763edad70e8443c99234dc3328f442e811f1d8fd1a72f1c8ad0f69a605"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.3.0"
|
||||||
|
clock:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: clock
|
||||||
|
sha256: cb6d7f03e1de671e34607e909a7213e31d7752be4fb66a86d29fe1eb14bfb5cf
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.1.1"
|
||||||
|
collection:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: collection
|
||||||
|
sha256: a1ace0a119f20aabc852d165077c036cd864315bd99b7eaa10a60100341941bf
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.19.0"
|
||||||
|
dbus:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: dbus
|
||||||
|
sha256: d0c98dcd4f5169878b6cf8f6e0a52403a9dff371a3e2f019697accbf6f44a270
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.7.12"
|
||||||
|
fake_async:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: fake_async
|
||||||
|
sha256: "511392330127add0b769b75a987850d136345d9227c6b94c96a04cf4a391bf78"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.3.1"
|
||||||
|
ffi:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: ffi
|
||||||
|
sha256: "16ed7b077ef01ad6170a3d0c57caa4a112a38d7a2ed5602e0aca9ca6f3d98da6"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.1.3"
|
||||||
|
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"
|
||||||
|
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"
|
||||||
|
leak_tracker:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: leak_tracker
|
||||||
|
sha256: "7bb2830ebd849694d1ec25bf1f44582d6ac531a57a365a803a6034ff751d2d06"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "10.0.7"
|
||||||
|
leak_tracker_flutter_testing:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: leak_tracker_flutter_testing
|
||||||
|
sha256: "9491a714cca3667b60b5c420da8217e6de0d1ba7a5ec322fab01758f6998f379"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "3.0.8"
|
||||||
|
leak_tracker_testing:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: leak_tracker_testing
|
||||||
|
sha256: "6ba465d5d76e67ddf503e1161d1f4a6bc42306f9d66ca1e8f079a47290fb06d3"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "3.0.1"
|
||||||
|
lints:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: lints
|
||||||
|
sha256: "976c774dd944a42e83e2467f4cc670daef7eed6295b10b36ae8c85bcbf828235"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "4.0.0"
|
||||||
|
matcher:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: matcher
|
||||||
|
sha256: d2323aa2060500f906aa31a895b4030b6da3ebdcc5619d14ce1aada65cd161cb
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.12.16+1"
|
||||||
|
material_color_utilities:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: material_color_utilities
|
||||||
|
sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.11.1"
|
||||||
|
meta:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: meta
|
||||||
|
sha256: bdb68674043280c3428e9ec998512fb681678676b3c54e773629ffe74419f8c7
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.15.0"
|
||||||
|
path:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: path
|
||||||
|
sha256: "087ce49c3f0dc39180befefc60fdb4acd8f8620e5682fe2476afd0b3688bb4af"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.9.0"
|
||||||
|
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: d0d310befe2c8ab9e7f393288ccbb11b60c019c6b5afc21973eeee4dda2b35e9
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.2.17"
|
||||||
|
path_provider_foundation:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: path_provider_foundation
|
||||||
|
sha256: "4843174df4d288f5e29185bd6e72a6fbdf5a4a4602717eed565497429f179942"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.4.1"
|
||||||
|
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"
|
||||||
|
permission_handler:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: permission_handler
|
||||||
|
sha256: "59adad729136f01ea9e35a48f5d1395e25cba6cea552249ddbe9cf950f5d7849"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "11.4.0"
|
||||||
|
permission_handler_android:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: permission_handler_android
|
||||||
|
sha256: d3971dcdd76182a0c198c096b5db2f0884b0d4196723d21a866fc4cdea057ebc
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "12.1.0"
|
||||||
|
permission_handler_apple:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: permission_handler_apple
|
||||||
|
sha256: f000131e755c54cf4d84a5d8bd6e4149e262cc31c5a8b1d698de1ac85fa41023
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "9.4.7"
|
||||||
|
permission_handler_html:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: permission_handler_html
|
||||||
|
sha256: "38f000e83355abb3392140f6bc3030660cfaef189e1f87824facb76300b4ff24"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.1.3+5"
|
||||||
|
permission_handler_platform_interface:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: permission_handler_platform_interface
|
||||||
|
sha256: eb99b295153abce5d683cac8c02e22faab63e50679b937fa1bf67d58bb282878
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "4.3.0"
|
||||||
|
permission_handler_windows:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: permission_handler_windows
|
||||||
|
sha256: "1a790728016f79a41216d88672dbc5df30e686e811ad4e698bfc51f76ad91f1e"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.2.1"
|
||||||
|
petitparser:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: petitparser
|
||||||
|
sha256: c15605cd28af66339f8eb6fbe0e541bfe2d1b72d5825efc6598f3e0a31b9ad27
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "6.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"
|
||||||
|
sky_engine:
|
||||||
|
dependency: transitive
|
||||||
|
description: flutter
|
||||||
|
source: sdk
|
||||||
|
version: "0.0.0"
|
||||||
|
source_span:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: source_span
|
||||||
|
sha256: "53e943d4206a5e30df338fd4c6e7a077e02254531b138a15aec3bd143c1a8b3c"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.10.0"
|
||||||
|
stack_trace:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: stack_trace
|
||||||
|
sha256: "9f47fd3630d76be3ab26f0ee06d213679aa425996925ff3feffdec504931c377"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.12.0"
|
||||||
|
stream_channel:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: stream_channel
|
||||||
|
sha256: ba2aa5d8cc609d96bbb2899c28934f9e1af5cddbd60a827822ea467161eb54e7
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.1.2"
|
||||||
|
string_scanner:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: string_scanner
|
||||||
|
sha256: "688af5ed3402a4bde5b3a6c15fd768dbf2621a614950b17f04626c431ab3c4c3"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.3.0"
|
||||||
|
term_glyph:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: term_glyph
|
||||||
|
sha256: a29248a84fbb7c79282b40b8c72a1209db169a2e0542bce341da992fe1bc7e84
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.2.1"
|
||||||
|
test_api:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: test_api
|
||||||
|
sha256: "664d3a9a64782fcdeb83ce9c6b39e78fd2971d4e37827b9b06c3aa1edc5e760c"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.7.3"
|
||||||
|
timezone:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: timezone
|
||||||
|
sha256: "2236ec079a174ce07434e89fcd3fcda430025eb7692244139a9cf54fdcf1fc7d"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.9.4"
|
||||||
|
vector_math:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: vector_math
|
||||||
|
sha256: "80b3257d1492ce4d091729e3a67a60407d227c27241d6927be0130c98e741803"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.1.4"
|
||||||
|
vm_service:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: vm_service
|
||||||
|
sha256: f6be3ed8bd01289b34d679c2b62226f63c0e69f9fd2e50a6b3c1c729a961041b
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "14.3.0"
|
||||||
|
web:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: web
|
||||||
|
sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.1.1"
|
||||||
|
webview_flutter:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: webview_flutter
|
||||||
|
sha256: c3e4fe614b1c814950ad07186007eff2f2e5dd2935eba7b9a9a1af8e5885f1ba
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "4.13.0"
|
||||||
|
webview_flutter_android:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: webview_flutter_android
|
||||||
|
sha256: "0a42444056b24ed832bdf3442d65c5194f6416f7e782152384944053c2ecc9a3"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "4.10.0"
|
||||||
|
webview_flutter_platform_interface:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: webview_flutter_platform_interface
|
||||||
|
sha256: "63d26ee3aca7256a83ccb576a50272edd7cfc80573a4305caa98985feb493ee0"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.14.0"
|
||||||
|
webview_flutter_wkwebview:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: webview_flutter_wkwebview
|
||||||
|
sha256: fb46db8216131a3e55bcf44040ca808423539bc6732e7ed34fb6d8044e3d512f
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "3.23.0"
|
||||||
|
win32:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: win32
|
||||||
|
sha256: daf97c9d80197ed7b619040e86c8ab9a9dad285e7671ee7390f9180cc828a51e
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "5.10.1"
|
||||||
|
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: b015a8ad1c488f66851d762d3090a21c600e479dc75e68328c52774040cf9226
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "6.5.0"
|
||||||
|
sdks:
|
||||||
|
dart: ">=3.6.0 <4.0.0"
|
||||||
|
flutter: ">=3.27.0"
|
||||||
26
mobile/pubspec.yaml
Normal file
26
mobile/pubspec.yaml
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
name: twp_softphone
|
||||||
|
description: TWP Softphone - WebView client for Twilio WordPress Plugin
|
||||||
|
publish_to: 'none'
|
||||||
|
version: 2.0.1+7
|
||||||
|
|
||||||
|
environment:
|
||||||
|
sdk: ^3.5.0
|
||||||
|
|
||||||
|
dependencies:
|
||||||
|
flutter:
|
||||||
|
sdk: flutter
|
||||||
|
firebase_core: ^3.0.0
|
||||||
|
firebase_messaging: ^15.0.0
|
||||||
|
flutter_secure_storage: ^10.0.0
|
||||||
|
flutter_local_notifications: ^17.0.0
|
||||||
|
webview_flutter: ^4.10.0
|
||||||
|
webview_flutter_android: ^4.3.0
|
||||||
|
permission_handler: ^11.3.0
|
||||||
|
|
||||||
|
dev_dependencies:
|
||||||
|
flutter_test:
|
||||||
|
sdk: flutter
|
||||||
|
flutter_lints: ^4.0.0
|
||||||
|
|
||||||
|
flutter:
|
||||||
|
uses-material-design: true
|
||||||
129
mobile/test/call_info_test.dart
Normal file
129
mobile/test/call_info_test.dart
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:twp_softphone/models/call_info.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
group('CallInfo', () {
|
||||||
|
test('default state is idle', () {
|
||||||
|
const info = CallInfo();
|
||||||
|
expect(info.state, CallState.idle);
|
||||||
|
expect(info.callSid, isNull);
|
||||||
|
expect(info.callerNumber, isNull);
|
||||||
|
expect(info.duration, Duration.zero);
|
||||||
|
expect(info.isMuted, false);
|
||||||
|
expect(info.isSpeakerOn, false);
|
||||||
|
expect(info.isOnHold, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('isActive returns true for ringing, connecting, connected', () {
|
||||||
|
expect(const CallInfo(state: CallState.ringing).isActive, true);
|
||||||
|
expect(const CallInfo(state: CallState.connecting).isActive, true);
|
||||||
|
expect(const CallInfo(state: CallState.connected).isActive, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('isActive returns false for idle and disconnected', () {
|
||||||
|
expect(const CallInfo(state: CallState.idle).isActive, false);
|
||||||
|
expect(const CallInfo(state: CallState.disconnected).isActive, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('copyWith preserves unmodified fields', () {
|
||||||
|
const original = CallInfo(
|
||||||
|
state: CallState.connected,
|
||||||
|
callSid: 'CA123',
|
||||||
|
callerNumber: '+1234567890',
|
||||||
|
isMuted: true,
|
||||||
|
);
|
||||||
|
|
||||||
|
final modified = original.copyWith(isSpeakerOn: true);
|
||||||
|
expect(modified.state, CallState.connected);
|
||||||
|
expect(modified.callSid, 'CA123');
|
||||||
|
expect(modified.callerNumber, '+1234567890');
|
||||||
|
expect(modified.isMuted, true);
|
||||||
|
expect(modified.isSpeakerOn, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('copyWith can change state', () {
|
||||||
|
const info = CallInfo(state: CallState.connecting);
|
||||||
|
final updated = info.copyWith(state: CallState.connected);
|
||||||
|
expect(updated.state, CallState.connected);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('copyWith with callerNumber preserves it', () {
|
||||||
|
const info = CallInfo(callerNumber: '+19095737372');
|
||||||
|
final updated = info.copyWith(state: CallState.connected);
|
||||||
|
expect(updated.callerNumber, '+19095737372');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('reset to idle clears all fields', () {
|
||||||
|
// Verify a complex state exists
|
||||||
|
const connected = CallInfo(
|
||||||
|
state: CallState.connected,
|
||||||
|
callSid: 'CA123',
|
||||||
|
callerNumber: '+1234567890',
|
||||||
|
isMuted: true,
|
||||||
|
isSpeakerOn: true,
|
||||||
|
isOnHold: true,
|
||||||
|
duration: Duration(seconds: 30),
|
||||||
|
);
|
||||||
|
expect(connected.isActive, true);
|
||||||
|
|
||||||
|
// Simulating what callEnded does
|
||||||
|
const reset = CallInfo();
|
||||||
|
expect(reset.state, CallState.idle);
|
||||||
|
expect(reset.callSid, isNull);
|
||||||
|
expect(reset.callerNumber, isNull);
|
||||||
|
expect(reset.isActive, false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('CallState transitions', () {
|
||||||
|
test('outbound call flow: idle -> connecting -> connected -> idle', () {
|
||||||
|
var info = const CallInfo();
|
||||||
|
expect(info.state, CallState.idle);
|
||||||
|
|
||||||
|
// makeCall sets connecting + callerNumber
|
||||||
|
info = info.copyWith(state: CallState.connecting, callerNumber: '+19095737372');
|
||||||
|
expect(info.state, CallState.connecting);
|
||||||
|
expect(info.callerNumber, '+19095737372');
|
||||||
|
expect(info.isActive, true);
|
||||||
|
|
||||||
|
// SDK fires connected
|
||||||
|
info = info.copyWith(state: CallState.connected);
|
||||||
|
expect(info.state, CallState.connected);
|
||||||
|
expect(info.callerNumber, '+19095737372'); // preserved
|
||||||
|
expect(info.isActive, true);
|
||||||
|
|
||||||
|
// callEnded resets
|
||||||
|
info = const CallInfo();
|
||||||
|
expect(info.state, CallState.idle);
|
||||||
|
expect(info.isActive, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('inbound call flow: idle -> ringing -> connected -> idle', () {
|
||||||
|
var info = const CallInfo();
|
||||||
|
|
||||||
|
info = info.copyWith(state: CallState.ringing);
|
||||||
|
expect(info.isActive, true);
|
||||||
|
|
||||||
|
// callerNumber set from active.from
|
||||||
|
info = info.copyWith(callerNumber: '+18005551234');
|
||||||
|
expect(info.callerNumber, '+18005551234');
|
||||||
|
|
||||||
|
info = info.copyWith(state: CallState.connected);
|
||||||
|
expect(info.state, CallState.connected);
|
||||||
|
|
||||||
|
info = const CallInfo();
|
||||||
|
expect(info.state, CallState.idle);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('outbound callerNumber not overwritten by null copyWith', () {
|
||||||
|
var info = const CallInfo(
|
||||||
|
state: CallState.connecting,
|
||||||
|
callerNumber: '+19095737372',
|
||||||
|
);
|
||||||
|
|
||||||
|
// copyWith without callerNumber should preserve it
|
||||||
|
info = info.copyWith(state: CallState.connected);
|
||||||
|
expect(info.callerNumber, '+19095737372');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
367
mobile/test/call_provider_test.dart
Normal file
367
mobile/test/call_provider_test.dart
Normal file
@@ -0,0 +1,367 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:twilio_voice/twilio_voice.dart';
|
||||||
|
import 'package:twp_softphone/models/call_info.dart';
|
||||||
|
import 'package:twp_softphone/providers/call_provider.dart';
|
||||||
|
import 'package:twp_softphone/services/voice_service.dart';
|
||||||
|
|
||||||
|
/// Minimal mock of VoiceService for testing CallProvider logic.
|
||||||
|
/// Only stubs methods that CallProvider calls directly.
|
||||||
|
class MockVoiceService implements VoiceService {
|
||||||
|
final StreamController<CallEvent> _eventController =
|
||||||
|
StreamController<CallEvent>.broadcast();
|
||||||
|
bool makeCallResult = true;
|
||||||
|
bool acceptQueueCallShouldThrow = false;
|
||||||
|
String? lastCallTo;
|
||||||
|
String? lastCallerId;
|
||||||
|
bool answerCalled = false;
|
||||||
|
bool hangUpCalled = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Stream<CallEvent> get callEvents => _eventController.stream;
|
||||||
|
|
||||||
|
void emitEvent(CallEvent event) => _eventController.add(event);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<bool> makeCall(String to, {String? callerId}) async {
|
||||||
|
lastCallTo = to;
|
||||||
|
lastCallerId = callerId;
|
||||||
|
return makeCallResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> answer() async {
|
||||||
|
answerCalled = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> hangUp() async {
|
||||||
|
hangUpCalled = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> reject() async {}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> toggleMute(bool mute) async {}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> toggleSpeaker(bool speaker) async {}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> sendDigits(String digits) async {}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<List<Map<String, dynamic>>> getQueueCalls(int queueId) async => [];
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> acceptQueueCall(String callSid) async {
|
||||||
|
if (acceptQueueCallShouldThrow) {
|
||||||
|
throw Exception('Network error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> holdCall(String callSid) async {}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> unholdCall(String callSid) async {}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> transferCall(String callSid, String target) async {}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> initialize({String? deviceToken}) async {}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String? get identity => 'agent2testuser';
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_eventController.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unused stubs required by the interface
|
||||||
|
@override
|
||||||
|
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
|
||||||
|
}
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
group('CallProvider.makeCall', () {
|
||||||
|
late MockVoiceService mockVoice;
|
||||||
|
late CallProvider provider;
|
||||||
|
|
||||||
|
setUp(() {
|
||||||
|
mockVoice = MockVoiceService();
|
||||||
|
provider = CallProvider(mockVoice);
|
||||||
|
});
|
||||||
|
|
||||||
|
tearDown(() {
|
||||||
|
provider.dispose();
|
||||||
|
mockVoice.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('sets state to connecting and passes number', () async {
|
||||||
|
mockVoice.makeCallResult = true;
|
||||||
|
await provider.makeCall('+19095737372');
|
||||||
|
|
||||||
|
expect(mockVoice.lastCallTo, '+19095737372');
|
||||||
|
expect(provider.callInfo.state, CallState.connecting);
|
||||||
|
expect(provider.callInfo.callerNumber, '+19095737372');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('passes callerId when provided', () async {
|
||||||
|
mockVoice.makeCallResult = true;
|
||||||
|
await provider.makeCall('+19095737372', callerId: '+19516215107');
|
||||||
|
|
||||||
|
expect(mockVoice.lastCallerId, '+19516215107');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('resets to idle when call.place() returns false', () async {
|
||||||
|
mockVoice.makeCallResult = false;
|
||||||
|
await provider.makeCall('+19095737372');
|
||||||
|
|
||||||
|
expect(provider.callInfo.state, CallState.idle);
|
||||||
|
expect(provider.callInfo.callerNumber, isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('stays connecting when call.place() returns true', () async {
|
||||||
|
mockVoice.makeCallResult = true;
|
||||||
|
await provider.makeCall('+19095737372');
|
||||||
|
|
||||||
|
expect(provider.callInfo.state, CallState.connecting);
|
||||||
|
expect(provider.callInfo.callerNumber, '+19095737372');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('CallProvider.hangUp', () {
|
||||||
|
late MockVoiceService mockVoice;
|
||||||
|
late CallProvider provider;
|
||||||
|
|
||||||
|
setUp(() {
|
||||||
|
mockVoice = MockVoiceService();
|
||||||
|
provider = CallProvider(mockVoice);
|
||||||
|
});
|
||||||
|
|
||||||
|
tearDown(() {
|
||||||
|
provider.dispose();
|
||||||
|
mockVoice.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('resets to idle even if SDK does not fire callEnded', () async {
|
||||||
|
// Simulate a connecting state
|
||||||
|
mockVoice.makeCallResult = true;
|
||||||
|
await provider.makeCall('+19095737372');
|
||||||
|
expect(provider.callInfo.state, CallState.connecting);
|
||||||
|
|
||||||
|
// Hang up without SDK firing callEnded
|
||||||
|
await provider.hangUp();
|
||||||
|
|
||||||
|
expect(provider.callInfo.state, CallState.idle);
|
||||||
|
expect(provider.callInfo.callerNumber, isNull);
|
||||||
|
expect(mockVoice.hangUpCalled, true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('CallProvider state transitions', () {
|
||||||
|
test('outbound: connecting preserves callerNumber through state changes', () {
|
||||||
|
// Simulating what CallProvider does internally
|
||||||
|
var info = const CallInfo();
|
||||||
|
|
||||||
|
// makeCall sets connecting + callerNumber
|
||||||
|
info = info.copyWith(state: CallState.connecting, callerNumber: '+19095737372');
|
||||||
|
expect(info.state, CallState.connecting);
|
||||||
|
expect(info.callerNumber, '+19095737372');
|
||||||
|
|
||||||
|
// SDK fires connected — callerNumber preserved
|
||||||
|
info = info.copyWith(state: CallState.connected);
|
||||||
|
expect(info.state, CallState.connected);
|
||||||
|
expect(info.callerNumber, '+19095737372');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('makeCall failure resets cleanly to idle', () {
|
||||||
|
var info = const CallInfo();
|
||||||
|
|
||||||
|
// makeCall sets connecting
|
||||||
|
info = info.copyWith(state: CallState.connecting, callerNumber: '+19095737372');
|
||||||
|
expect(info.state, CallState.connecting);
|
||||||
|
|
||||||
|
// call.place() returns false -> reset
|
||||||
|
info = const CallInfo();
|
||||||
|
expect(info.state, CallState.idle);
|
||||||
|
expect(info.callerNumber, isNull);
|
||||||
|
expect(info.isActive, false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('CallProvider.acceptQueueCall', () {
|
||||||
|
late MockVoiceService mockVoice;
|
||||||
|
late CallProvider provider;
|
||||||
|
|
||||||
|
setUp(() {
|
||||||
|
mockVoice = MockVoiceService();
|
||||||
|
provider = CallProvider(mockVoice);
|
||||||
|
});
|
||||||
|
|
||||||
|
tearDown(() {
|
||||||
|
provider.dispose();
|
||||||
|
mockVoice.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('sets state to connecting before server call', () async {
|
||||||
|
await provider.acceptQueueCall('CA123abc');
|
||||||
|
expect(provider.callInfo.state, CallState.connecting);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('auto-answers incoming call after acceptQueueCall', () async {
|
||||||
|
await provider.acceptQueueCall('CA123abc');
|
||||||
|
|
||||||
|
// Simulate the FCM incoming call event that arrives after server redirect
|
||||||
|
mockVoice.emitEvent(CallEvent.incoming);
|
||||||
|
|
||||||
|
// Allow the stream listener to process
|
||||||
|
await Future.delayed(Duration.zero);
|
||||||
|
|
||||||
|
// Should have auto-answered
|
||||||
|
expect(mockVoice.answerCalled, true);
|
||||||
|
expect(provider.callInfo.state, CallState.connecting);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('normal incoming call shows ringing without auto-answer', () async {
|
||||||
|
// Without calling acceptQueueCall first
|
||||||
|
mockVoice.emitEvent(CallEvent.incoming);
|
||||||
|
|
||||||
|
await Future.delayed(Duration.zero);
|
||||||
|
|
||||||
|
expect(mockVoice.answerCalled, false);
|
||||||
|
expect(provider.callInfo.state, CallState.ringing);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('connected event after auto-answer sets connected state', () async {
|
||||||
|
await provider.acceptQueueCall('CA123abc');
|
||||||
|
|
||||||
|
mockVoice.emitEvent(CallEvent.incoming);
|
||||||
|
await Future.delayed(Duration.zero);
|
||||||
|
expect(mockVoice.answerCalled, true);
|
||||||
|
|
||||||
|
mockVoice.emitEvent(CallEvent.connected);
|
||||||
|
await Future.delayed(Duration.zero);
|
||||||
|
expect(provider.callInfo.state, CallState.connected);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('resets to idle on API error and clears pendingAutoAnswer', () async {
|
||||||
|
mockVoice.acceptQueueCallShouldThrow = true;
|
||||||
|
await provider.acceptQueueCall('CA123abc');
|
||||||
|
|
||||||
|
// Should have reset to idle after error
|
||||||
|
expect(provider.callInfo.state, CallState.idle);
|
||||||
|
|
||||||
|
// Future incoming call should NOT be auto-answered
|
||||||
|
mockVoice.emitEvent(CallEvent.incoming);
|
||||||
|
await Future.delayed(Duration.zero);
|
||||||
|
expect(mockVoice.answerCalled, false);
|
||||||
|
expect(provider.callInfo.state, CallState.ringing);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('CallProvider.hangUp edge cases', () {
|
||||||
|
late MockVoiceService mockVoice;
|
||||||
|
late CallProvider provider;
|
||||||
|
|
||||||
|
setUp(() {
|
||||||
|
mockVoice = MockVoiceService();
|
||||||
|
provider = CallProvider(mockVoice);
|
||||||
|
});
|
||||||
|
|
||||||
|
tearDown(() {
|
||||||
|
provider.dispose();
|
||||||
|
mockVoice.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('hangUp when already idle is a no-op', () async {
|
||||||
|
expect(provider.callInfo.state, CallState.idle);
|
||||||
|
await provider.hangUp();
|
||||||
|
expect(provider.callInfo.state, CallState.idle);
|
||||||
|
expect(mockVoice.hangUpCalled, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('hangUp clears pendingAutoAnswer flag', () async {
|
||||||
|
await provider.acceptQueueCall('CA123abc');
|
||||||
|
expect(provider.callInfo.state, CallState.connecting);
|
||||||
|
|
||||||
|
await provider.hangUp();
|
||||||
|
expect(provider.callInfo.state, CallState.idle);
|
||||||
|
|
||||||
|
// Incoming call should NOT auto-answer after hangUp cleared the flag
|
||||||
|
mockVoice.emitEvent(CallEvent.incoming);
|
||||||
|
await Future.delayed(Duration.zero);
|
||||||
|
expect(mockVoice.answerCalled, false);
|
||||||
|
expect(provider.callInfo.state, CallState.ringing);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('CallProvider.toggleMute and toggleSpeaker', () {
|
||||||
|
late MockVoiceService mockVoice;
|
||||||
|
late CallProvider provider;
|
||||||
|
|
||||||
|
setUp(() {
|
||||||
|
mockVoice = MockVoiceService();
|
||||||
|
provider = CallProvider(mockVoice);
|
||||||
|
});
|
||||||
|
|
||||||
|
tearDown(() {
|
||||||
|
provider.dispose();
|
||||||
|
mockVoice.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('toggleMute flips isMuted state', () async {
|
||||||
|
expect(provider.callInfo.isMuted, false);
|
||||||
|
await provider.toggleMute();
|
||||||
|
expect(provider.callInfo.isMuted, true);
|
||||||
|
await provider.toggleMute();
|
||||||
|
expect(provider.callInfo.isMuted, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('toggleSpeaker flips isSpeakerOn state', () async {
|
||||||
|
expect(provider.callInfo.isSpeakerOn, false);
|
||||||
|
await provider.toggleSpeaker();
|
||||||
|
expect(provider.callInfo.isSpeakerOn, true);
|
||||||
|
await provider.toggleSpeaker();
|
||||||
|
expect(provider.callInfo.isSpeakerOn, false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('CallProvider.callEnded', () {
|
||||||
|
late MockVoiceService mockVoice;
|
||||||
|
late CallProvider provider;
|
||||||
|
|
||||||
|
setUp(() {
|
||||||
|
mockVoice = MockVoiceService();
|
||||||
|
provider = CallProvider(mockVoice);
|
||||||
|
});
|
||||||
|
|
||||||
|
tearDown(() {
|
||||||
|
provider.dispose();
|
||||||
|
mockVoice.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('callEnded resets state completely', () async {
|
||||||
|
// Set up a connected call
|
||||||
|
mockVoice.makeCallResult = true;
|
||||||
|
await provider.makeCall('+19095737372');
|
||||||
|
|
||||||
|
mockVoice.emitEvent(CallEvent.connected);
|
||||||
|
await Future.delayed(Duration.zero);
|
||||||
|
expect(provider.callInfo.state, CallState.connected);
|
||||||
|
expect(provider.callInfo.callerNumber, '+19095737372');
|
||||||
|
|
||||||
|
// End the call
|
||||||
|
mockVoice.emitEvent(CallEvent.callEnded);
|
||||||
|
await Future.delayed(Duration.zero);
|
||||||
|
expect(provider.callInfo.state, CallState.idle);
|
||||||
|
expect(provider.callInfo.callerNumber, isNull);
|
||||||
|
expect(provider.callInfo.callSid, isNull);
|
||||||
|
expect(provider.callInfo.isActive, false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
92
mobile/test/queue_state_test.dart
Normal file
92
mobile/test/queue_state_test.dart
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:twp_softphone/models/queue_state.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
group('QueueInfo', () {
|
||||||
|
test('parses from JSON with all fields', () {
|
||||||
|
final json = {
|
||||||
|
'id': 1,
|
||||||
|
'name': 'General',
|
||||||
|
'type': 'general',
|
||||||
|
'extension': '100',
|
||||||
|
'waiting_count': 3,
|
||||||
|
};
|
||||||
|
|
||||||
|
final queue = QueueInfo.fromJson(json);
|
||||||
|
expect(queue.id, 1);
|
||||||
|
expect(queue.name, 'General');
|
||||||
|
expect(queue.type, 'general');
|
||||||
|
expect(queue.extension, '100');
|
||||||
|
expect(queue.waitingCount, 3);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('parses from JSON with string numbers', () {
|
||||||
|
final json = {
|
||||||
|
'id': '5',
|
||||||
|
'name': 'Support',
|
||||||
|
'type': 'personal',
|
||||||
|
'waiting_count': '0',
|
||||||
|
};
|
||||||
|
|
||||||
|
final queue = QueueInfo.fromJson(json);
|
||||||
|
expect(queue.id, 5);
|
||||||
|
expect(queue.waitingCount, 0);
|
||||||
|
expect(queue.extension, isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('handles missing fields gracefully', () {
|
||||||
|
final json = <String, dynamic>{};
|
||||||
|
final queue = QueueInfo.fromJson(json);
|
||||||
|
expect(queue.id, 0);
|
||||||
|
expect(queue.name, '');
|
||||||
|
expect(queue.type, '');
|
||||||
|
expect(queue.extension, isNull);
|
||||||
|
expect(queue.waitingCount, 0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('QueueCall', () {
|
||||||
|
test('parses from JSON', () {
|
||||||
|
final json = {
|
||||||
|
'call_sid': 'CA123abc',
|
||||||
|
'from_number': '+18005551234',
|
||||||
|
'to_number': '+19095737372',
|
||||||
|
'position': 1,
|
||||||
|
'status': 'waiting',
|
||||||
|
'wait_time': 45,
|
||||||
|
};
|
||||||
|
|
||||||
|
final call = QueueCall.fromJson(json);
|
||||||
|
expect(call.callSid, 'CA123abc');
|
||||||
|
expect(call.fromNumber, '+18005551234');
|
||||||
|
expect(call.toNumber, '+19095737372');
|
||||||
|
expect(call.position, 1);
|
||||||
|
expect(call.status, 'waiting');
|
||||||
|
expect(call.waitTime, 45);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('handles string wait_time', () {
|
||||||
|
final json = {
|
||||||
|
'call_sid': 'CA456',
|
||||||
|
'from_number': '+1800',
|
||||||
|
'to_number': '+1900',
|
||||||
|
'position': '2',
|
||||||
|
'status': 'waiting',
|
||||||
|
'wait_time': '120',
|
||||||
|
};
|
||||||
|
|
||||||
|
final call = QueueCall.fromJson(json);
|
||||||
|
expect(call.position, 2);
|
||||||
|
expect(call.waitTime, 120);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('handles missing fields gracefully', () {
|
||||||
|
final json = <String, dynamic>{};
|
||||||
|
final call = QueueCall.fromJson(json);
|
||||||
|
expect(call.callSid, '');
|
||||||
|
expect(call.fromNumber, '');
|
||||||
|
expect(call.position, 0);
|
||||||
|
expect(call.waitTime, 0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
40
mobile/test/webview_app_test.dart
Normal file
40
mobile/test/webview_app_test.dart
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:twp_softphone/app.dart';
|
||||||
|
import 'package:twp_softphone/screens/login_screen.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
group('TwpSoftphoneApp', () {
|
||||||
|
testWidgets('shows loading indicator on startup', (tester) async {
|
||||||
|
await tester.pumpWidget(const TwpSoftphoneApp());
|
||||||
|
expect(find.byType(TwpSoftphoneApp), findsOneWidget);
|
||||||
|
expect(find.bySubtype<CircularProgressIndicator>(), findsOneWidget);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('LoginScreen', () {
|
||||||
|
testWidgets('renders server URL field and connect button', (tester) async {
|
||||||
|
await tester.pumpWidget(
|
||||||
|
MaterialApp(
|
||||||
|
home: LoginScreen(onLoginSuccess: (_) {}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pump(const Duration(milliseconds: 100));
|
||||||
|
expect(find.text('Server URL'), findsOneWidget);
|
||||||
|
expect(find.text('Connect'), findsOneWidget);
|
||||||
|
expect(find.text('TWP Softphone'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('validates empty server URL on submit', (tester) async {
|
||||||
|
await tester.pumpWidget(
|
||||||
|
MaterialApp(
|
||||||
|
home: LoginScreen(onLoginSuccess: (_) {}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pump(const Duration(milliseconds: 100));
|
||||||
|
await tester.tap(find.text('Connect'));
|
||||||
|
await tester.pump();
|
||||||
|
expect(find.text('Required'), findsOneWidget);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
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);
|
||||||
|
});
|
||||||
|
}
|
||||||
113
test-deploy.sh
Executable file
113
test-deploy.sh
Executable file
@@ -0,0 +1,113 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Test harness for TWP WebView Softphone deployment
|
||||||
|
# Run after deploying PHP files and flushing rewrite rules
|
||||||
|
|
||||||
|
SERVER="https://phone.cloud-hosting.io"
|
||||||
|
PASS=0
|
||||||
|
FAIL=0
|
||||||
|
|
||||||
|
check() {
|
||||||
|
local desc="$1"
|
||||||
|
local result="$2"
|
||||||
|
if [ "$result" = "0" ]; then
|
||||||
|
echo " PASS: $desc"
|
||||||
|
PASS=$((PASS + 1))
|
||||||
|
else
|
||||||
|
echo " FAIL: $desc"
|
||||||
|
FAIL=$((FAIL + 1))
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
echo "=== TWP WebView Softphone - Deployment Test Harness ==="
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# 1. Test standalone phone page exists (should redirect to login for unauthenticated)
|
||||||
|
echo "[1] Standalone Phone Page (/twp-phone/)"
|
||||||
|
RESPONSE=$(curl -s -o /dev/null -w "%{http_code}:%{redirect_url}" -L --max-redirs 0 "$SERVER/twp-phone/" 2>/dev/null)
|
||||||
|
HTTP_CODE=$(echo "$RESPONSE" | cut -d: -f1)
|
||||||
|
REDIRECT=$(echo "$RESPONSE" | cut -d: -f2-)
|
||||||
|
# Should redirect (302) to wp-login.php for unauthenticated users
|
||||||
|
if [ "$HTTP_CODE" = "302" ] && echo "$REDIRECT" | grep -q "wp-login"; then
|
||||||
|
check "Unauthenticated redirect to wp-login.php" 0
|
||||||
|
else
|
||||||
|
check "Unauthenticated redirect to wp-login.php (got $HTTP_CODE, redirect: $REDIRECT)" 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 2. Test that wp-login.php page loads
|
||||||
|
echo ""
|
||||||
|
echo "[2] WordPress Login Page"
|
||||||
|
LOGIN_RESPONSE=$(curl -s -o /dev/null -w "%{http_code}" "$SERVER/wp-login.php" 2>/dev/null)
|
||||||
|
check "wp-login.php returns 200" "$([ "$LOGIN_RESPONSE" = "200" ] && echo 0 || echo 1)"
|
||||||
|
|
||||||
|
# 3. Test authenticated access (login and get cookies, then access /twp-phone/)
|
||||||
|
echo ""
|
||||||
|
echo "[3] Authenticated Access"
|
||||||
|
# Try to log in and get session cookies
|
||||||
|
COOKIE_JAR="/tmp/twp-test-cookies.txt"
|
||||||
|
rm -f "$COOKIE_JAR"
|
||||||
|
|
||||||
|
# Login - use the test credentials if available
|
||||||
|
LOGIN_RESULT=$(curl -s -o /dev/null -w "%{http_code}" \
|
||||||
|
-c "$COOKIE_JAR" \
|
||||||
|
-d "log=admin&pwd=admin&rememberme=forever&redirect_to=$SERVER/twp-phone/&wp-submit=Log+In" \
|
||||||
|
"$SERVER/wp-login.php" 2>/dev/null)
|
||||||
|
|
||||||
|
if [ "$LOGIN_RESULT" = "302" ]; then
|
||||||
|
# Follow redirect to /twp-phone/
|
||||||
|
PAGE_RESULT=$(curl -s -b "$COOKIE_JAR" -w "%{http_code}" -o /tmp/twp-phone-page.html "$SERVER/twp-phone/" 2>/dev/null)
|
||||||
|
check "Authenticated /twp-phone/ returns 200" "$([ "$PAGE_RESULT" = "200" ] && echo 0 || echo 1)"
|
||||||
|
|
||||||
|
if [ "$PAGE_RESULT" = "200" ]; then
|
||||||
|
# Check page content
|
||||||
|
check "Page contains Twilio SDK" "$(grep -q 'twilio.min.js' /tmp/twp-phone-page.html && echo 0 || echo 1)"
|
||||||
|
check "Page contains dialpad" "$(grep -q 'dialpad' /tmp/twp-phone-page.html && echo 0 || echo 1)"
|
||||||
|
check "Page contains ajaxurl" "$(grep -q 'ajaxurl' /tmp/twp-phone-page.html && echo 0 || echo 1)"
|
||||||
|
check "Page contains TwpMobile bridge" "$(grep -q 'TwpMobile' /tmp/twp-phone-page.html && echo 0 || echo 1)"
|
||||||
|
check "Page contains twpNonce" "$(grep -q 'twpNonce' /tmp/twp-phone-page.html && echo 0 || echo 1)"
|
||||||
|
check "Page has mobile viewport" "$(grep -q 'viewport-fit=cover' /tmp/twp-phone-page.html && echo 0 || echo 1)"
|
||||||
|
check "Page has dark mode CSS" "$(grep -q 'prefers-color-scheme' /tmp/twp-phone-page.html && echo 0 || echo 1)"
|
||||||
|
check "No WP admin bar" "$(grep -q 'wp-admin-bar' /tmp/twp-phone-page.html && echo 1 || echo 0)"
|
||||||
|
check "Page contains phone-number-input" "$(grep -q 'phone-number-input' /tmp/twp-phone-page.html && echo 0 || echo 1)"
|
||||||
|
check "Page contains caller-id-select" "$(grep -q 'caller-id-select' /tmp/twp-phone-page.html && echo 0 || echo 1)"
|
||||||
|
check "Page contains hold/transfer buttons" "$(grep -q 'hold-btn' /tmp/twp-phone-page.html && echo 0 || echo 1)"
|
||||||
|
check "Page contains queue tab" "$(grep -q 'queue' /tmp/twp-phone-page.html && echo 0 || echo 1)"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo " SKIP: Could not log in (HTTP $LOGIN_RESULT) - manual auth testing required"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 4. Test AJAX endpoint availability
|
||||||
|
echo ""
|
||||||
|
echo "[4] AJAX Endpoints"
|
||||||
|
if [ -f "$COOKIE_JAR" ] && [ "$LOGIN_RESULT" = "302" ]; then
|
||||||
|
# Test that admin-ajax.php is accessible with cookies
|
||||||
|
AJAX_RESULT=$(curl -s -b "$COOKIE_JAR" -o /dev/null -w "%{http_code}" \
|
||||||
|
-d "action=twp_generate_capability_token&nonce=test" \
|
||||||
|
"$SERVER/wp-admin/admin-ajax.php" 2>/dev/null)
|
||||||
|
# Should return 200 (even if nonce fails, it means AJAX is working)
|
||||||
|
check "admin-ajax.php accessible" "$([ "$AJAX_RESULT" = "200" ] || [ "$AJAX_RESULT" = "400" ] || [ "$AJAX_RESULT" = "403" ] && echo 0 || echo 1)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 5. Test 7-day cookie expiration
|
||||||
|
echo ""
|
||||||
|
echo "[5] Session Cookie"
|
||||||
|
if [ -f "$COOKIE_JAR" ]; then
|
||||||
|
# Check if cookies have extended expiry
|
||||||
|
COOKIE_EXISTS=$(grep -c "wordpress_logged_in" "$COOKIE_JAR" 2>/dev/null)
|
||||||
|
check "Login cookies set" "$([ "$COOKIE_EXISTS" -gt 0 ] && echo 0 || echo 1)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Cleanup
|
||||||
|
rm -f "$COOKIE_JAR" /tmp/twp-phone-page.html
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "=== Results: $PASS passed, $FAIL failed ==="
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
if [ "$FAIL" -gt 0 ]; then
|
||||||
|
echo "Some tests failed. Review output above."
|
||||||
|
exit 1
|
||||||
|
else
|
||||||
|
echo "All tests passed!"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
Reference in New Issue
Block a user