Compare commits

...

15 Commits

Author SHA1 Message Date
Claude
eedb7bdb8f Fix auto-updater to fetch newest release by creation date
All checks were successful
Create Release / build (push) Successful in 3s
Use /releases?limit=1 instead of /releases/latest which sorts by
semver tag. Date-based tags (2026.03.06-1805) have a hyphen that
semver treats as a prerelease separator, causing incorrect ordering.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 18:59:23 -08:00
Claude
f8c9c23077 Move auto-update settings from Mobile App page to Settings page
All checks were successful
Create Release / build (push) Successful in 5s
- Relocate update section (version check, repo config, token) to Settings
- Fix download URL for private repos: append Gitea auth token
- Mobile App page now only has FCM/notification settings

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 18:57:34 -08:00
Claude
5d3035a62c Remove redundant update-version.yml workflow
All checks were successful
Create Release / build (push) Successful in 4s
release.yml already handles version stamping, zipping, and release creation on push to main.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 18:34:33 -08:00
Claude
7df6090554 Fix mobile app: AccessToken for voice, Agent Manager for status, caller ID support
All checks were successful
Create Release / build (push) Successful in 3s
- Voice token: use AccessToken + VoiceGrant instead of browser-only ClientToken
- Agent status: delegate to TWP_Agent_Manager matching browser phone behavior
- Queue loading: add missing require_once for TWP_User_Queue_Manager
- Add /phone-numbers endpoint for caller ID selection
- Webhook: support CallerId param from mobile extraOptions
- Flutter: caller ID dropdown in dialer, error logging in all catch blocks

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 18:06:35 -08:00
Claude
8cc6fa8c3c Fix queue loading, null-safe models, autofill, and add outbound dialer
All checks were successful
Create Release / build (push) Successful in 4s
- Fix queue queries in mobile API and SSE to use twp_group_members
  (matching browser phone) instead of twp_queue_assignments
- Auto-create personal queues if user has no extension
- Make all model JSON parsing null-safe (handle null, string ints, bools)
- Add AutofillGroup and autofill hints to login form
- Add outbound calling with dialpad bottom sheet on dashboard

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 15:32:22 -08:00
Claude
d41b6aa535 Reuse existing Twilio credentials for mobile voice tokens
All checks were successful
Create Release / build (push) Successful in 3s
Mobile voice token endpoint now uses TWP_Twilio_API::generate_capability_token()
instead of separate API Key SID/Secret. Removes duplicate Twilio credential
fields from Mobile App settings page.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 14:50:53 -08:00
Claude
4da794ed0c Remove Twilio Push Credential references and add real Firebase config
All checks were successful
Create Release / build (push) Successful in 4s
All FCM push notifications are handled by WordPress backend (TWP_FCM),
so Twilio Push Credentials are unnecessary. Also replaces placeholder
google-services.json with real Firebase project config (twp-softphone).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 14:43:21 -08:00
Claude
5adfa694c1 Migrate FCM from legacy v1 API to HTTP v2 with service account auth
All checks were successful
Create Release / build (push) Successful in 3s
Replace deprecated FCM server key authentication with Google service
account OAuth2 flow. The class now creates a signed JWT from the
service account credentials, exchanges it for a short-lived access
token (cached via WordPress transients), and sends messages to the
FCM v2 endpoint (projects/{id}/messages:send).

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-23 19:02:03 -08:00
f8919af31a Fix SDK autoloader path for Twilio namespace
All checks were successful
Create Release / build (push) Successful in 3s
The SDK files are at twilio/sdk/Twilio/Rest/Client.php but the
autoloader was looking at twilio/sdk/Rest/Client.php. Fixed by
using the full class name in the path instead of stripping the
Twilio\ prefix.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-23 18:32:19 -08:00
57 changed files with 3931 additions and 371 deletions

View File

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

View File

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

View File

@@ -6,8 +6,26 @@
- **URL**: `https://phone.cloud-hosting.io/`
- **Deployment**: rsync to Docker (remote server only, not local)
- **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)
## 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
**Use**: `incoming_number`, `agent_number`, `customer_number`, `workflow_number`, `queue_number`, `default_number`
**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_TTS_Helper**: ElevenLabs/Alice fallback, 30-day cache
- **TWP_User_Queue_Manager**: Auto-creates queues/extensions (100-9999)
- **TWP_Webhooks**: 26 endpoints at `twilio-webhook/v1`
- **TWP_Activator**: Creates 15 DB tables, run `ensure_tables_exist()` if missing
- **TWP_Webhooks**: 35 endpoints at `twilio-webhook/v1`
- **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
15 tables with `twp_` prefix. Key notes:
16 tables with `twp_` prefix. Key notes:
- `twp_call_queues`: User queues (general/personal/hold)
- `twp_agent_status`: Has `auto_busy_at` for 1-min auto-revert
- `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()`
- 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
- **API**: E.164 format (+1XXXXXXXXXX)
- **Database**: Use `$wpdb`, prepared statements
@@ -79,30 +80,24 @@ $api->update_call($customer_call_sid, ['twiml' => $twiml_xml]);
- User-specific queues with extensions
- Browser phone at `admin.php?page=twilio-wp-browser-phone`
- 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
- **Problem**: WordPress plugin updates delete entire plugin folder including `vendor/` SDK
- **Solution**: External SDK installation at `wp-content/twilio-sdk/` survives updates
- **Implementation**:
- 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
## Browser Phone Configuration
- **Edge Location**: `twp_twilio_edge` option, default `roaming`
- Options: roaming, ashburn, umatilla, dublin, frankfurt, singapore, sydney, tokyo, sao-paulo
- Wrong edge causes immediate call failures (e.g., US calls with Sydney edge)
### US Calls Failing Fix (Browser Phone)
- **Problem**: Browser phone had hardcoded `edge: 'sydney'`, causing US calls to fail with immediate HANGUP
- **Solution**: Configurable edge location via WordPress settings
- **Implementation**:
- New setting: `twp_twilio_edge` with default value `roaming`
- Settings UI: Dropdown in admin settings with 8 edge options
- Browser phone JS: Uses `get_option('twp_twilio_edge', 'roaming')` instead of hardcoded value
- Edge options: roaming, ashburn, umatilla, dublin, frankfurt, singapore, sydney, tokyo, sao-paulo
## Changelog
See `README.md` for detailed version history. Current version: v2.8.9.
---
*Updated: Jan 2026*
*Updated: Mar 2026*

View File

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

View File

@@ -13,13 +13,6 @@ if (!current_user_can('manage_options')) {
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
if (isset($_POST['twp_test_notification']) && check_admin_referer('twp_mobile_settings')) {
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
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_auto_update_enabled', isset($_POST['twp_auto_update_enabled']) ? '1' : '0');
update_option('twp_gitea_repo', sanitize_text_field($_POST['twp_gitea_repo']));
update_option('twp_gitea_token', sanitize_text_field($_POST['twp_gitea_token']));
update_option('twp_fcm_project_id', sanitize_text_field($_POST['twp_fcm_project_id']));
// Service account JSON — validate it parses as JSON before saving
$sa_json_raw = isset($_POST['twp_fcm_service_account_json']) ? wp_unslash($_POST['twp_fcm_service_account_json']) : '';
if (!empty($sa_json_raw)) {
$sa_parsed = json_decode($sa_json_raw, true);
if ($sa_parsed && isset($sa_parsed['client_email'], $sa_parsed['private_key'])) {
update_option('twp_fcm_service_account_json', $sa_json_raw);
} else {
$sa_json_error = 'Invalid service account JSON — must contain client_email and private_key fields.';
}
} else {
update_option('twp_fcm_service_account_json', '');
}
$settings_saved = true;
}
// Get current settings
$fcm_server_key = get_option('twp_fcm_server_key', '');
$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', '');
// 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();
$fcm_project_id = get_option('twp_fcm_project_id', '');
$fcm_service_account_json = get_option('twp_fcm_service_account_json', '');
$fcm_sa_configured = !empty($fcm_service_account_json) && !empty($fcm_project_id);
// Get mobile app statistics
global $wpdb;
$sessions_table = $wpdb->prefix . 'twp_mobile_sessions';
@@ -72,18 +66,18 @@ $total_sessions = $wpdb->get_var("SELECT COUNT(*) FROM $sessions_table");
</div>
<?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)): ?>
<div class="notice notice-<?php echo $notification_result['success'] ? 'success' : 'error'; ?> is-dismissible">
<p><strong><?php echo esc_html($notification_result['message']); ?></strong></p>
</div>
<?php endif; ?>
<?php if (isset($sa_json_error)): ?>
<div class="notice notice-error is-dismissible">
<p><strong><?php echo esc_html($sa_json_error); ?></strong></p>
</div>
<?php endif; ?>
<div class="twp-mobile-settings">
<!-- Mobile App Overview -->
<div class="card" style="max-width: 100%; margin-bottom: 20px;">
@@ -112,29 +106,48 @@ $total_sessions = $wpdb->get_var("SELECT COUNT(*) FROM $sessions_table");
<!-- FCM Configuration -->
<div class="card" style="max-width: 100%; margin-bottom: 20px;">
<h2>Firebase Cloud Messaging (FCM)</h2>
<p>Configure FCM to enable push notifications for the mobile app.</p>
<h2>Firebase Cloud Messaging (FCM) — HTTP v2 API</h2>
<p>Configure FCM using a service account for push notifications. The legacy server key API has been retired by Google.</p>
<table class="form-table">
<tr>
<th scope="row">
<label for="twp_fcm_server_key">FCM Server Key</label>
<label for="twp_fcm_project_id">Firebase Project ID</label>
</th>
<td>
<input type="text"
id="twp_fcm_server_key"
name="twp_fcm_server_key"
value="<?php echo esc_attr($fcm_server_key); ?>"
id="twp_fcm_project_id"
name="twp_fcm_project_id"
value="<?php echo esc_attr($fcm_project_id); ?>"
class="regular-text"
placeholder="AAAA...">
placeholder="my-project-12345">
<p class="description">
Get your server key from Firebase Console > Project Settings > Cloud Messaging > Server Key
Found in Firebase Console &gt; Project Settings &gt; General &gt; Project ID
</p>
</td>
</tr>
<tr>
<th scope="row">
<label for="twp_fcm_service_account_json">Service Account JSON</label>
</th>
<td>
<textarea id="twp_fcm_service_account_json"
name="twp_fcm_service_account_json"
rows="6"
class="large-text code"
placeholder='Paste the entire contents of your service account JSON file...'><?php echo esc_textarea($fcm_service_account_json); ?></textarea>
<p class="description">
Generate in Firebase Console &gt; Project Settings &gt; Service Accounts &gt; Generate New Private Key.
Paste the entire JSON file contents here. Must contain <code>client_email</code> and <code>private_key</code> fields.
</p>
<?php if ($fcm_sa_configured): ?>
<p style="color: #00a32a; margin-top: 5px;">&#10003; Service account configured</p>
<?php endif; ?>
</td>
</tr>
</table>
<?php if (!empty($fcm_server_key)): ?>
<?php if ($fcm_sa_configured): ?>
<p>
<button type="submit" name="twp_test_notification" class="button">
Send Test Notification
@@ -144,91 +157,6 @@ $total_sessions = $wpdb->get_var("SELECT COUNT(*) FROM $sessions_table");
<?php endif; ?>
</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 -->
<div class="card" style="max-width: 100%; margin-bottom: 20px;">
<h2>API Endpoints</h2>
@@ -273,6 +201,11 @@ $total_sessions = $wpdb->get_var("SELECT COUNT(*) FROM $sessions_table");
<td>GET</td>
<td>Server-Sent Events stream for real-time updates</td>
</tr>
<tr>
<td><code>/twilio-mobile/v1/voice/token</code></td>
<td>GET</td>
<td>Get Twilio Voice access token for VoIP</td>
</tr>
</tbody>
</table>

View File

@@ -19,7 +19,7 @@ class TWP_Auto_Updater {
public function __construct() {
$this->plugin_basename = plugin_basename(dirname(dirname(__FILE__)) . '/twilio-wp-plugin.php');
$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', '');
if (!empty($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();
@@ -184,9 +184,16 @@ class TWP_Auto_Updater {
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');
return false;
}
@@ -210,6 +217,12 @@ class TWP_Auto_Updater {
$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
$changelog = !empty($release->body) ? $release->body : 'No changelog provided for this release.';

View File

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

View File

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

View File

@@ -99,6 +99,20 @@ class TWP_Mobile_API {
'callback' => array($this, 'update_agent_phone'),
'permission_callback' => array($this->auth, 'verify_token')
));
// Voice token for VoIP
register_rest_route('twilio-mobile/v1', '/voice/token', array(
'methods' => 'GET',
'callback' => array($this, 'get_voice_token'),
'permission_callback' => array($this->auth, 'verify_token')
));
// Phone numbers for caller ID
register_rest_route('twilio-mobile/v1', '/phone-numbers', array(
'methods' => 'GET',
'callback' => array($this, 'get_phone_numbers'),
'permission_callback' => array($this->auth, 'verify_token')
));
});
}
@@ -155,39 +169,16 @@ class TWP_Mobile_API {
return new WP_Error('invalid_status', 'Status must be available, busy, or offline', array('status' => 400));
}
global $wpdb;
$table = $wpdb->prefix . 'twp_agent_status';
// 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')
);
require_once plugin_dir_path(__FILE__) . 'class-twp-agent-manager.php';
require_once plugin_dir_path(__FILE__) . 'class-twp-user-queue-manager.php';
// Handle login status change first (matches browser phone behavior)
if ($is_logged_in !== null) {
$data['is_logged_in'] = $is_logged_in ? 1 : 0;
if ($is_logged_in) {
$data['logged_in_at'] = current_time('mysql');
}
TWP_Agent_Manager::set_agent_login_status($user_id, (bool)$is_logged_in);
}
if ($exists) {
$wpdb->update(
$table,
$data,
array('user_id' => $user_id),
array('%s', '%s'),
array('%d')
);
} else {
$data['user_id'] = $user_id;
$wpdb->insert($table, $data);
}
// Set agent status (handles auto_busy_at and all status fields)
TWP_Agent_Manager::set_agent_status($user_id, $new_status);
return new WP_REST_Response(array(
'success' => true,
@@ -204,44 +195,42 @@ class TWP_Mobile_API {
global $wpdb;
$queues_table = $wpdb->prefix . 'twp_call_queues';
$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
$queue_ids = $wpdb->get_col($wpdb->prepare(
"SELECT queue_id FROM $assignments_table WHERE user_id = %d",
// Auto-create personal queues if they don't exist
$extensions_table = $wpdb->prefix . 'twp_user_extensions';
$existing_extension = $wpdb->get_row($wpdb->prepare(
"SELECT extension FROM $extensions_table WHERE user_id = %d",
$user_id
));
// Also include personal queues
$personal_queue_ids = $wpdb->get_col($wpdb->prepare(
"SELECT id FROM $queues_table WHERE user_id = %d",
$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);
if (!$existing_extension) {
require_once plugin_dir_path(__FILE__) . 'class-twp-user-queue-manager.php';
TWP_User_Queue_Manager::create_user_queues($user_id);
}
$queue_ids_str = implode(',', array_map('intval', $all_queue_ids));
// Get queue information with call counts
$queues = $wpdb->get_results("
SELECT
// Get queues where user is a member of the assigned agent group OR personal/hold queues
$queues = $wpdb->get_results($wpdb->prepare("
SELECT DISTINCT
q.id,
q.queue_name,
q.queue_type,
q.extension,
COUNT(c.id) as waiting_count
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'
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
");
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();
foreach ($queues as $queue) {
@@ -507,11 +496,46 @@ class TWP_Mobile_API {
* Unhold a call (resume from hold queue)
*/
public function unhold_call($request) {
// Implementation would retrieve from hold queue and reconnect
return new WP_REST_Response(array(
'success' => true,
'message' => 'Unhold functionality - to be implemented with queue retrieval'
), 501);
$user_id = $this->auth->get_current_user_id();
$call_sid = $request['call_sid'];
try {
require_once plugin_dir_path(dirname(__FILE__)) . 'includes/class-twp-admin.php';
require_once plugin_dir_path(dirname(__FILE__)) . 'includes/class-twp-twilio-api.php';
$admin = new TWP_Admin('twilio-wp-plugin', TWP_VERSION);
$twilio = new TWP_Twilio_API();
// Find customer call leg
$customer_call_sid = $admin->find_customer_call_leg($call_sid, $twilio);
if (!$customer_call_sid) {
return new WP_Error('call_not_found', 'Could not find customer call leg', array('status' => 404));
}
// Build identity for this agent
$user = get_userdata($user_id);
$clean_name = preg_replace('/[^a-zA-Z0-9]/', '', $user->user_login);
if (empty($clean_name)) {
$clean_name = 'user';
}
$identity = 'agent' . $user_id . $clean_name;
// Redirect customer back to agent's client
$twiml = new \Twilio\TwiML\VoiceResponse();
$dial = $twiml->dial();
$dial->client($identity);
$twilio->update_call($customer_call_sid, array('twiml' => $twiml->asXML()));
return new WP_REST_Response(array(
'success' => true,
'message' => 'Call resumed from hold'
), 200);
} catch (Exception $e) {
return new WP_Error('unhold_error', $e->getMessage(), array('status' => 500));
}
}
/**
@@ -641,6 +665,80 @@ class TWP_Mobile_API {
), 200);
}
/**
* Get Voice access token for VoIP
*/
public function get_voice_token($request) {
$user_id = $this->auth->get_current_user_id();
$user = get_userdata($user_id);
$clean_name = preg_replace('/[^a-zA-Z0-9]/', '', $user->user_login);
if (empty($clean_name)) {
$clean_name = 'user';
}
$identity = 'agent' . $user_id . $clean_name;
try {
// Ensure Twilio SDK autoloader is loaded
require_once plugin_dir_path(dirname(__FILE__)) . 'includes/class-twp-twilio-api.php';
new TWP_Twilio_API();
$account_sid = get_option('twp_twilio_account_sid');
$auth_token = get_option('twp_twilio_auth_token');
$twiml_app_sid = get_option('twp_twiml_app_sid');
if (empty($account_sid) || empty($auth_token) || empty($twiml_app_sid)) {
return new WP_Error('token_error', 'Twilio credentials not configured', array('status' => 500));
}
// AccessToken for mobile Voice SDK (not ClientToken which is browser-only)
$token = new \Twilio\Jwt\AccessToken($account_sid, $account_sid, $auth_token, 3600, $identity);
$voiceGrant = new \Twilio\Jwt\Grants\VoiceGrant();
$voiceGrant->setOutgoingApplicationSid($twiml_app_sid);
$voiceGrant->setIncomingAllow(true);
$token->addGrant($voiceGrant);
return new WP_REST_Response(array(
'token' => $token->toJWT(),
'identity' => $identity,
'expires_in' => 3600
), 200);
} catch (Exception $e) {
return new WP_Error('token_error', $e->getMessage(), array('status' => 500));
}
}
/**
* Get available Twilio phone numbers for caller ID
*/
public function get_phone_numbers($request) {
try {
require_once plugin_dir_path(dirname(__FILE__)) . 'includes/class-twp-twilio-api.php';
$twilio = new TWP_Twilio_API();
$result = $twilio->get_phone_numbers();
if (!$result['success']) {
return new WP_Error('twilio_error', $result['error'], array('status' => 500));
}
$phone_numbers = array();
foreach ($result['data']['incoming_phone_numbers'] as $number) {
$phone_numbers[] = array(
'phone_number' => $number['phone_number'],
'friendly_name' => $number['friendly_name'],
);
}
return new WP_REST_Response(array(
'success' => true,
'phone_numbers' => $phone_numbers
), 200);
} catch (Exception $e) {
return new WP_Error('twilio_error', $e->getMessage(), array('status' => 500));
}
}
/**
* Check if user has access to a queue
*/

View File

@@ -142,38 +142,40 @@ class TWP_Mobile_SSE {
global $wpdb;
$queues_table = $wpdb->prefix . 'twp_call_queues';
$calls_table = $wpdb->prefix . 'twp_queued_calls';
$assignments_table = $wpdb->prefix . 'twp_queue_assignments';
$groups_table = $wpdb->prefix . 'twp_group_members';
// Get queue IDs
$queue_ids = $wpdb->get_col($wpdb->prepare(
"SELECT queue_id FROM $assignments_table WHERE user_id = %d",
// Auto-create personal queues if they don't exist
$extensions_table = $wpdb->prefix . 'twp_user_extensions';
$existing_extension = $wpdb->get_row($wpdb->prepare(
"SELECT extension FROM $extensions_table WHERE user_id = %d",
$user_id
));
$personal_queue_ids = $wpdb->get_col($wpdb->prepare(
"SELECT id FROM $queues_table WHERE user_id = %d",
$user_id
));
$all_queue_ids = array_unique(array_merge($queue_ids, $personal_queue_ids));
if (empty($all_queue_ids)) {
return array();
if (!$existing_extension) {
TWP_User_Queue_Manager::create_user_queues($user_id);
}
$queue_ids_str = implode(',', array_map('intval', $all_queue_ids));
$queues = $wpdb->get_results("
SELECT
// Get queues where user is a member of the assigned agent group OR personal/hold queues
$queues = $wpdb->get_results($wpdb->prepare("
SELECT DISTINCT
q.id,
q.queue_name,
COUNT(c.id) as waiting_count,
MIN(c.enqueued_at) as oldest_call_time
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'
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
");
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();
foreach ($queues as $queue) {

View File

@@ -371,7 +371,13 @@ class TWP_Webhooks {
if (isset($params['To']) && !empty($params['To'])) {
$to_number = $params['To'];
$from_number = isset($params['From']) ? $params['From'] : '';
// Mobile SDK sends CallerId via extraOptions; browser sends From as phone number
$from_number = '';
if (!empty($params['CallerId']) && strpos($params['CallerId'], 'client:') !== 0) {
$from_number = $params['CallerId'];
} elseif (!empty($params['From']) && strpos($params['From'], 'client:') !== 0) {
$from_number = $params['From'];
}
// If it's an outgoing call to a phone number
if (strpos($to_number, 'client:') !== 0) {
@@ -477,6 +483,13 @@ class TWP_Webhooks {
$twilio_api->send_sms($agent_phone, $message, $twilio_number);
}
}
// Send FCM push notifications for missed browser call
require_once dirname(__FILE__) . '/class-twp-fcm.php';
$fcm = new TWP_FCM();
foreach ($agents as $agent) {
$fcm->notify_incoming_call($agent->ID, $customer_number, 'Browser Phone', '');
}
}
/**
@@ -704,6 +717,13 @@ class TWP_Webhooks {
$twilio_api->send_sms($agent_phone, $message, $twilio_number);
}
}
// Send FCM push notifications for missed call
require_once dirname(__FILE__) . '/class-twp-fcm.php';
$fcm = new TWP_FCM();
foreach ($agents as $agent) {
$fcm->notify_incoming_call($agent->ID, $customer_number, 'General', '');
}
}
/**

View File

@@ -101,8 +101,8 @@ if (!defined('TWILIO_AUTOLOADER_REGISTERED')) {
}
// Convert class name to file path
$relative_class = substr($class, 7); // Remove 'Twilio\'
$file = __DIR__ . '/twilio/sdk/' . str_replace('\\', '/', $relative_class) . '.php';
// The SDK structure is: twilio/sdk/Twilio/Rest/Client.php for Twilio\Rest\Client
$file = __DIR__ . '/twilio/sdk/' . str_replace('\\', '/', $class) . '.php';
if (file_exists($file)) {
require_once $file;
@@ -120,10 +120,10 @@ if (!defined('TWILIO_AUTOLOADER_REGISTERED')) {
// Load essential Twilio classes manually to ensure they're available
$essential_classes = [
__DIR__ . '/twilio/sdk/Rest/Client.php',
__DIR__ . '/twilio/sdk/TwiML/VoiceResponse.php',
__DIR__ . '/twilio/sdk/Exceptions/TwilioException.php',
__DIR__ . '/twilio/sdk/Security/RequestValidator.php'
__DIR__ . '/twilio/sdk/Twilio/Rest/Client.php',
__DIR__ . '/twilio/sdk/Twilio/TwiML/VoiceResponse.php',
__DIR__ . '/twilio/sdk/Twilio/Exceptions/TwilioException.php',
__DIR__ . '/twilio/sdk/Twilio/Security/RequestValidator.php'
];
foreach ($essential_classes as $class_file) {

View File

@@ -79,8 +79,8 @@ if (!defined('TWILIO_AUTOLOADER_REGISTERED')) {
}
// Convert class name to file path
$relative_class = substr($class, 7); // Remove 'Twilio\'
$file = __DIR__ . '/twilio/sdk/' . str_replace('\\', '/', $relative_class) . '.php';
// The SDK structure is: twilio/sdk/Twilio/Rest/Client.php for Twilio\Rest\Client
$file = __DIR__ . '/twilio/sdk/' . str_replace('\\', '/', $class) . '.php';
if (file_exists($file)) {
require_once $file;
@@ -98,10 +98,10 @@ if (!defined('TWILIO_AUTOLOADER_REGISTERED')) {
// Load essential Twilio classes manually to ensure they're available
$essential_classes = [
__DIR__ . '/twilio/sdk/Rest/Client.php',
__DIR__ . '/twilio/sdk/TwiML/VoiceResponse.php',
__DIR__ . '/twilio/sdk/Exceptions/TwilioException.php',
__DIR__ . '/twilio/sdk/Security/RequestValidator.php'
__DIR__ . '/twilio/sdk/Twilio/Rest/Client.php',
__DIR__ . '/twilio/sdk/Twilio/TwiML/VoiceResponse.php',
__DIR__ . '/twilio/sdk/Twilio/Exceptions/TwilioException.php',
__DIR__ . '/twilio/sdk/Twilio/Security/RequestValidator.php'
];
foreach ($essential_classes as $class_file) {

45
mobile/.gitignore vendored Normal file
View File

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

45
mobile/.metadata Normal file
View File

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

View File

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

View File

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

View File

@@ -0,0 +1,29 @@
{
"project_info": {
"project_number": "187457540438",
"project_id": "twp-softphone",
"storage_bucket": "twp-softphone.firebasestorage.app"
},
"client": [
{
"client_info": {
"mobilesdk_app_id": "1:187457540438:android:d6d777270c23f6660946f7",
"android_client_info": {
"package_name": "io.cloudhosting.twp.twp_softphone"
}
},
"oauth_client": [],
"api_key": [
{
"current_key": "AIzaSyAdGJrWPy9b9arqHnlY5G_hsGiDcm6cchA"
}
],
"services": {
"appinvite_service": {
"other_platform_oauth_client": []
}
}
}
],
"configuration_version": "1"
}

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

Binary file not shown.

View File

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

View File

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

View File

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

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

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,210 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../providers/agent_provider.dart';
import '../providers/call_provider.dart';
import '../widgets/agent_status_toggle.dart';
import '../widgets/dialpad.dart';
import '../widgets/queue_card.dart';
import 'active_call_screen.dart';
import 'settings_screen.dart';
class DashboardScreen extends StatefulWidget {
const DashboardScreen({super.key});
@override
State<DashboardScreen> createState() => _DashboardScreenState();
}
class _DashboardScreenState extends State<DashboardScreen> {
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
context.read<AgentProvider>().refresh();
});
}
void _showDialer(BuildContext context) {
final numberController = TextEditingController();
String? selectedCallerId;
showModalBottomSheet(
context: context,
isScrollControlled: true,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
),
builder: (ctx) {
final phoneNumbers = context.read<AgentProvider>().phoneNumbers;
return StatefulBuilder(
builder: (ctx, setSheetState) {
return Padding(
padding: EdgeInsets.only(
bottom: MediaQuery.of(ctx).viewInsets.bottom,
top: 16,
left: 16,
right: 16,
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// Number display
TextField(
controller: numberController,
keyboardType: TextInputType.phone,
autofillHints: const [AutofillHints.telephoneNumber],
textAlign: TextAlign.center,
style: Theme.of(ctx).textTheme.headlineSmall,
decoration: InputDecoration(
hintText: 'Enter phone number',
suffixIcon: IconButton(
icon: const Icon(Icons.backspace_outlined),
onPressed: () {
final text = numberController.text;
if (text.isNotEmpty) {
numberController.text =
text.substring(0, text.length - 1);
numberController.selection = TextSelection.fromPosition(
TextPosition(offset: numberController.text.length),
);
}
},
),
),
),
// Caller ID selector
if (phoneNumbers.isNotEmpty) ...[
const SizedBox(height: 12),
DropdownButtonFormField<String>(
initialValue: selectedCallerId,
decoration: const InputDecoration(
labelText: 'Caller ID',
isDense: true,
contentPadding: EdgeInsets.symmetric(horizontal: 12, vertical: 8),
),
items: [
const DropdownMenuItem<String>(
value: null,
child: Text('Default'),
),
...phoneNumbers.map((p) => DropdownMenuItem<String>(
value: p.phoneNumber,
child: Text('${p.friendlyName} (${p.phoneNumber})'),
)),
],
onChanged: (value) {
setSheetState(() {
selectedCallerId = value;
});
},
),
],
const SizedBox(height: 16),
// Dialpad
Dialpad(
onDigit: (digit) {
numberController.text += digit;
numberController.selection = TextSelection.fromPosition(
TextPosition(offset: numberController.text.length),
);
},
onClose: () => Navigator.pop(ctx),
),
const SizedBox(height: 8),
// Call button
ElevatedButton.icon(
style: ElevatedButton.styleFrom(
backgroundColor: Colors.green,
foregroundColor: Colors.white,
minimumSize: const Size(double.infinity, 48),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(24),
),
),
icon: const Icon(Icons.call),
label: const Text('Call'),
onPressed: () {
final number = numberController.text.trim();
if (number.isNotEmpty) {
context.read<CallProvider>().makeCall(number, callerId: selectedCallerId);
Navigator.pop(ctx);
}
},
),
const SizedBox(height: 16),
],
),
);
},
);
},
);
}
@override
Widget build(BuildContext context) {
final agent = context.watch<AgentProvider>();
final call = context.watch<CallProvider>();
// Navigate to active call screen when a call comes in
if (call.callInfo.isActive) {
WidgetsBinding.instance.addPostFrameCallback((_) {
Navigator.of(context).pushAndRemoveUntil(
MaterialPageRoute(builder: (_) => const ActiveCallScreen()),
(route) => route.isFirst,
);
});
}
return Scaffold(
appBar: AppBar(
title: const Text('TWP Softphone'),
actions: [
// SSE connection indicator
Padding(
padding: const EdgeInsets.only(right: 8),
child: Icon(
Icons.circle,
size: 12,
color: agent.sseConnected ? Colors.green : Colors.red,
),
),
IconButton(
icon: const Icon(Icons.settings),
onPressed: () => Navigator.push(context,
MaterialPageRoute(builder: (_) => const SettingsScreen())),
),
],
),
floatingActionButton: FloatingActionButton(
onPressed: () => _showDialer(context),
child: const Icon(Icons.phone),
),
body: RefreshIndicator(
onRefresh: () => agent.refresh(),
child: ListView(
padding: const EdgeInsets.all(16),
children: [
const AgentStatusToggle(),
const SizedBox(height: 24),
Text('Queues',
style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: 8),
if (agent.queues.isEmpty)
const Card(
child: Padding(
padding: EdgeInsets.all(24),
child: Center(child: Text('No queues assigned')),
),
)
else
...agent.queues.map((q) => Padding(
padding: const EdgeInsets.only(bottom: 8),
child: QueueCard(queue: q),
)),
],
),
),
);
}
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,78 @@
import 'package:firebase_core/firebase_core.dart';
import 'package:firebase_messaging/firebase_messaging.dart';
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import 'api_client.dart';
@pragma('vm:entry-point')
Future<void> _firebaseMessagingBackgroundHandler(RemoteMessage message) async {
await Firebase.initializeApp();
// VoIP pushes are handled natively by twilio_voice plugin.
// Other data messages can show a local notification if needed.
}
class PushNotificationService {
final ApiClient _api;
final FirebaseMessaging _messaging = FirebaseMessaging.instance;
final FlutterLocalNotificationsPlugin _localNotifications =
FlutterLocalNotificationsPlugin();
PushNotificationService(this._api);
Future<void> initialize() async {
FirebaseMessaging.onBackgroundMessage(_firebaseMessagingBackgroundHandler);
await _messaging.requestPermission(
alert: true,
badge: true,
sound: true,
criticalAlert: true,
);
// Initialize local notifications
const androidSettings =
AndroidInitializationSettings('@mipmap/ic_launcher');
const initSettings = InitializationSettings(android: androidSettings);
await _localNotifications.initialize(initSettings);
// Get and register FCM token
final token = await _messaging.getToken();
if (token != null) {
await _registerToken(token);
}
// Listen for token refresh
_messaging.onTokenRefresh.listen(_registerToken);
// Handle foreground messages (non-VoIP)
FirebaseMessaging.onMessage.listen(_handleForegroundMessage);
}
Future<void> _registerToken(String token) async {
try {
await _api.dio.post('/fcm/register', data: {'fcm_token': token});
} catch (_) {}
}
void _handleForegroundMessage(RemoteMessage message) {
final data = message.data;
final type = data['type'];
// VoIP incoming_call is handled by twilio_voice natively
if (type == 'incoming_call') return;
// Show local notification for other types (missed call, queue alert, etc.)
_localNotifications.show(
message.hashCode,
data['title'] ?? 'TWP Softphone',
data['body'] ?? '',
const NotificationDetails(
android: AndroidNotificationDetails(
'twp_general',
'General Notifications',
importance: Importance.high,
priority: Priority.high,
),
),
);
}
}

View File

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

View File

@@ -0,0 +1,103 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:twilio_voice/twilio_voice.dart';
import 'api_client.dart';
class VoiceService {
final ApiClient _api;
Timer? _tokenRefreshTimer;
String? _identity;
final StreamController<CallEvent> _callEventController =
StreamController<CallEvent>.broadcast();
Stream<CallEvent> get callEvents => _callEventController.stream;
VoiceService(this._api);
Future<void> initialize() async {
await _fetchAndRegisterToken();
TwilioVoice.instance.callEventsListener.listen((event) {
_callEventController.add(event);
});
// Refresh token every 50 minutes
_tokenRefreshTimer?.cancel();
_tokenRefreshTimer = Timer.periodic(
const Duration(minutes: 50),
(_) => _fetchAndRegisterToken(),
);
}
Future<void> _fetchAndRegisterToken() async {
try {
final response = await _api.dio.get('/voice/token');
final data = response.data;
final token = data['token'] as String;
_identity = data['identity'] as String;
await TwilioVoice.instance.setTokens(accessToken: token);
} catch (e) {
debugPrint('VoiceService._fetchAndRegisterToken error: $e');
}
}
String? get identity => _identity;
Future<void> answer() async {
await TwilioVoice.instance.call.answer();
}
Future<void> reject() async {
await TwilioVoice.instance.call.hangUp();
}
Future<void> hangUp() async {
await TwilioVoice.instance.call.hangUp();
}
Future<void> toggleMute(bool mute) async {
await TwilioVoice.instance.call.toggleMute(mute);
}
Future<void> toggleSpeaker(bool speaker) async {
await TwilioVoice.instance.call.toggleSpeaker(speaker);
}
Future<bool> makeCall(String to, {String? callerId}) async {
try {
final extraOptions = <String, dynamic>{};
if (callerId != null && callerId.isNotEmpty) {
extraOptions['CallerId'] = callerId;
}
return await TwilioVoice.instance.call.place(
to: to,
from: _identity ?? '',
extraOptions: extraOptions,
) ?? false;
} catch (e) {
debugPrint('VoiceService.makeCall error: $e');
return false;
}
}
Future<void> sendDigits(String digits) async {
await TwilioVoice.instance.call.sendDigits(digits);
}
Future<void> holdCall(String callSid) async {
await _api.dio.post('/calls/$callSid/hold');
}
Future<void> unholdCall(String callSid) async {
await _api.dio.post('/calls/$callSid/unhold');
}
Future<void> transferCall(String callSid, String target) async {
await _api.dio.post('/calls/$callSid/transfer', data: {'target': target});
}
void dispose() {
_tokenRefreshTimer?.cancel();
_callEventController.close();
}
}

View File

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

View File

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

View File

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

View File

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

890
mobile/pubspec.lock Normal file
View File

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

29
mobile/pubspec.yaml Normal file
View File

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

View File

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