Compare commits

...

5 Commits

Author SHA1 Message Date
Claude
46fc27f9bf Add runtime microphone permission request for WebRTC calls
The RECORD_AUDIO permission was declared in the manifest but never
requested at runtime, causing WebRTC to fail on Android 6+. Now
requests microphone permission on app startup before initializing
the WebView.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 10:39:55 -07:00
Claude
a2ea99bb09 Fix FCM token registration and add queue reminder alerts
- Fix silent insert failure in FCM token registration (missing NOT NULL
  refresh_token column) so WebView app tokens are actually stored
- Add 1-minute queue reminder cron that re-sends FCM alerts for calls
  still waiting, with transient-based throttle to prevent duplicates
- Send FCM cancel on queue dequeue (answered/hangup/timeout), not just
  on final call status webhook
- Clean up new cron hook on plugin deactivation

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 10:29:33 -07:00
Claude
d00a906d07 Add call history, dark mode toggle, caller ID persistence, and refactor phone page
Phone page improvements:
- Clear caller number display after call disconnects
- Add "Recent" tab with session call history (tap to call back)
- Persist outbound caller ID selection in localStorage
- Fix button overlap with proper z-index layering
- Add manual dark mode toggle (System/Light/Dark) in Settings
- Improve dark mode CSS for all UI elements

Refactor phone page into separate files:
- assets/mobile/phone.css (848 lines) — all CSS
- assets/mobile/phone.js (1065 lines) — all JavaScript
- assets/mobile/phone-template.php (267 lines) — HTML template
- includes/class-twp-mobile-phone-page.php (211 lines) — PHP controller
- PHP values passed to JS via window.twpConfig bridge

Flutter app:
- Replace FAB with slim AppBar (refresh + menu buttons)
- Fix dark mode colors using theme-aware colorScheme

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 10:02:04 -07:00
Claude
621b0890a9 Replace native Twilio Voice SDK with WebView-based softphone
Rewrites the mobile app from a native Twilio Voice SDK integration
(Android Telecom/ConnectionService) to a thin WebView shell that loads
a standalone browser phone page from WordPress. This eliminates the
buggy Android phone account registration, fixes frequent logouts by
using 7-day WP session cookies instead of JWT tokens, and maintains
all existing call features (dialpad, queues, hold, transfer, requeue,
recording, caller ID, agent status).

Server-side:
- Add class-twp-mobile-phone-page.php: standalone /twp-phone/ endpoint
  with mobile-optimized UI, dark mode, tab navigation, and Flutter
  WebView JS bridge
- Extend auth cookie to 7 days for phone agents
- Add WP AJAX handler for FCM token registration (cookie auth)

Flutter app (v2.0.0):
- Replace 18 native files with 5-file WebView shell
- Login via wp-login.php in WebView (auto-detect redirect on success)
- Full-screen WebView with auto microphone grant for WebRTC
- FCM push notifications preserved for queue alerts
- Remove: twilio_voice, dio, provider, JWT auth, SSE, native call UI

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 09:11:25 -07:00
Claude
4af4be94a4 Add FCM push notifications, queue alerts, caller ID fixes, and auto-revert agent status
All checks were successful
Create Release / build (push) Successful in 6s
Server-side:
- Add push credential auto-creation for FCM incoming call notifications
- Add queue alert FCM notifications (data-only for background delivery)
- Add queue alert cancellation on call accept/disconnect
- Fix caller ID to show caller's number instead of Twilio number
- Fix FCM token storage when refresh_token is null
- Add pre_call_status tracking to revert agent status 30s after call ends
- Add SSE fallback polling for mobile app connectivity

Mobile app:
- Add Android telecom permissions and phone account registration
- Add VoiceFirebaseMessagingService for incoming call push handling
- Add insistent queue alert notifications with custom sound
- Fix caller number display on active call screen
- Add caller ID selection dropdown on dashboard
- Add phone numbers endpoint and provider support
- Add unit tests for CallInfo, QueueState, and CallProvider
- Remove local.properties from tracking, add .gitignore

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 17:11:02 -08:00
57 changed files with 4636 additions and 2353 deletions

22
.gitignore vendored Normal file
View File

@@ -0,0 +1,22 @@
# Dependencies
vendor/
node_modules/
# Build artifacts
mobile/android/.gradle/
mobile/android/build/
mobile/android/app/build/
mobile/build/
mobile/.dart_tool/
# Local config (machine-specific paths)
mobile/android/local.properties
# IDE
.idea/
*.iml
.vscode/
# OS
.DS_Store
Thumbs.db

View File

@@ -96,6 +96,33 @@ $api->update_call($customer_call_sid, ['twiml' => $twiml_xml]);
- Options: roaming, ashburn, umatilla, dublin, frankfurt, singapore, sydney, tokyo, sao-paulo
- Wrong edge causes immediate call failures (e.g., US calls with Sydney edge)
## Mobile App SSE (Server-Sent Events)
The mobile app uses SSE for real-time updates (queue changes, agent status). If SSE doesn't work (green dot stays red), the app automatically falls back to 5-second polling.
### Apache + PHP-FPM Buffering Fix
`mod_proxy_fcgi` buffers PHP output by default, which breaks SSE streaming. Fix by adding a config file on the server:
```bash
echo 'ProxyPassMatch "^/wp-json/twilio-mobile/v1/stream/events$" "unix:/run/php-fpm/www.sock|fcgi://localhost/home/shadowdao/public_html/index.php" flushpackets=on' > /etc/httpd/conf.d/twp-sse.conf
httpd -t && systemctl restart httpd
```
- **`flushpackets=on`** is the key — tells Apache to flush PHP-FPM output immediately
- This is a `ProxyPassMatch` directive — **cannot** go in `.htaccess`, must be server config
- The PHP-FPM socket path (`/run/php-fpm/www.sock`) must match `/etc/httpd/conf.d/php.conf`
- If the server uses nginx instead of Apache, add `X-Accel-Buffering: no` header (already in PHP code)
- If behind HAProxy with HTTP/2, the issue is Apache→client buffering, not HTTP/2 framing
### Diagnosis
```bash
# Check PHP-FPM proxy config
grep -r "fcgi\|php-fpm" /etc/httpd/conf.d/
# Check if flushpackets is configured
grep -r "flushpackets" /etc/httpd/conf.d/
# Test SSE endpoint (should stream data, not hang)
curl -N -H "Authorization: Bearer TOKEN" https://phone.cloud-hosting.io/wp-json/twilio-mobile/v1/stream/events
```
## Changelog
See `README.md` for detailed version history. Current version: v2.8.9.

View File

@@ -0,0 +1,267 @@
<?php
/**
* Mobile Phone Page Template
*
* This template is require'd from TWP_Mobile_Phone_Page::render_page().
* All PHP variables ($extension_data, $is_logged_in, $agent_status, etc.)
* are in scope from the calling method.
*
* @package Twilio_WP_Plugin
*/
// Prevent direct access.
if (!defined('ABSPATH')) {
exit;
}
?><!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="mobile-web-app-capable" content="yes">
<meta name="theme-color" content="#1a1a2e">
<title>Phone - <?php echo esc_html(get_bloginfo('name')); ?></title>
<!-- jQuery (WordPress bundled) -->
<script src="<?php echo includes_url('js/jquery/jquery.min.js'); ?>"></script>
<!-- Preload Twilio SDK -->
<link rel="preload" href="https://unpkg.com/@twilio/voice-sdk@2.11.0/dist/twilio.min.js" as="script">
<link rel="dns-prefetch" href="//unpkg.com">
<link rel="dns-prefetch" href="//chunderw-vpc-gll.twilio.com">
<link rel="preconnect" href="https://chunderw-vpc-gll.twilio.com" crossorigin>
<!-- Stylesheet -->
<link rel="stylesheet" href="<?php echo plugins_url('assets/mobile/phone.css', $plugin_file); ?>">
</head>
<body>
<div class="twp-app">
<!-- Agent Status Bar -->
<div class="agent-status-bar">
<div class="status-info">
<span class="extension-badge"><?php echo $extension_data ? esc_html($extension_data->extension) : '—'; ?></span>
<button id="login-toggle-btn" class="<?php echo $is_logged_in ? 'logged-in' : ''; ?>" onclick="toggleAgentLogin()">
<?php echo $is_logged_in ? 'Log Out' : 'Log In'; ?>
</button>
<select id="agent-status-select" onchange="updateAgentStatus(this.value)" <?php echo !$is_logged_in ? 'disabled' : ''; ?>>
<option value="available" <?php selected($agent_status->status ?? '', 'available'); ?>>Available</option>
<option value="busy" <?php selected($agent_status->status ?? '', 'busy'); ?>>Busy</option>
<option value="offline" <?php selected($agent_status->status ?? 'offline', 'offline'); ?>>Offline</option>
</select>
</div>
<div class="agent-stats">
<span>Today: <strong><?php echo esc_html($agent_stats['calls_today']); ?></strong></span>
<span>Total: <strong><?php echo esc_html($agent_stats['total_calls']); ?></strong></span>
<span>Avg: <strong><?php echo round($agent_stats['avg_duration'] ?? 0); ?>s</strong></span>
</div>
</div>
<!-- Tab Navigation -->
<div class="tab-nav">
<button class="tab-btn active" data-tab="phone">Phone</button>
<button class="tab-btn" data-tab="recent">Recent</button>
<button class="tab-btn" data-tab="queues">Queues</button>
<button class="tab-btn" data-tab="settings">Settings</button>
</div>
<!-- Notices container -->
<div id="twp-notices"></div>
<!-- Error display -->
<div id="browser-phone-error" style="display:none;"></div>
<!-- Tab Content -->
<div class="tab-content">
<!-- Phone Tab -->
<div class="tab-pane active" id="tab-phone">
<div class="phone-interface">
<div class="phone-display">
<div id="phone-status">Ready</div>
<div id="device-connection-status">Loading...</div>
<div id="phone-number-display"></div>
<div id="call-timer" style="display:none;">00:00</div>
</div>
<input type="tel" id="phone-number-input" placeholder="Enter phone number" />
<div class="dialpad-grid">
<button class="dialpad-btn" data-digit="1">1</button>
<button class="dialpad-btn" data-digit="2">2<span>ABC</span></button>
<button class="dialpad-btn" data-digit="3">3<span>DEF</span></button>
<button class="dialpad-btn" data-digit="4">4<span>GHI</span></button>
<button class="dialpad-btn" data-digit="5">5<span>JKL</span></button>
<button class="dialpad-btn" data-digit="6">6<span>MNO</span></button>
<button class="dialpad-btn" data-digit="7">7<span>PQRS</span></button>
<button class="dialpad-btn" data-digit="8">8<span>TUV</span></button>
<button class="dialpad-btn" data-digit="9">9<span>WXYZ</span></button>
<button class="dialpad-btn" data-digit="*">*</button>
<button class="dialpad-btn" data-digit="0">0<span>+</span></button>
<button class="dialpad-btn" data-digit="#">#</button>
</div>
<div class="phone-controls">
<button id="call-btn" class="btn-phone btn-call">
&#128222; Call
</button>
<button id="hangup-btn" class="btn-phone btn-hangup" style="display:none;">
&#10060; Hang Up
</button>
<button id="answer-btn" class="btn-phone btn-answer" style="display:none;">
&#128222; Answer
</button>
</div>
<div id="admin-call-controls-panel" style="display:none;">
<div class="call-controls-grid">
<button id="admin-hold-btn" class="btn-ctrl" title="Hold">
&#9208; Hold
</button>
<button id="admin-transfer-btn" class="btn-ctrl" title="Transfer">
&#8618; Transfer
</button>
<button id="admin-requeue-btn" class="btn-ctrl" title="Requeue">
&#128260; Requeue
</button>
<button id="admin-record-btn" class="btn-ctrl" title="Record">
&#9210; Record
</button>
</div>
</div>
</div>
</div>
<!-- Recent Tab -->
<div class="tab-pane" id="tab-recent">
<div class="recent-panel">
<div class="recent-header">
<h4>Recent Calls</h4>
<button type="button" id="clear-history-btn" class="btn-sm">Clear</button>
</div>
<div id="recent-call-list">
<div class="recent-empty">No calls yet this session.</div>
</div>
</div>
</div>
<!-- Queues Tab -->
<div class="tab-pane" id="tab-queues">
<div class="queue-panel">
<div class="queue-header">
<h4>Your Queues</h4>
<?php if ($extension_data): ?>
<div class="user-extension-admin">Ext: <strong><?php echo esc_html($extension_data->extension); ?></strong></div>
<?php endif; ?>
</div>
<div id="admin-queue-list">
<div class="queue-loading">Loading your queues...</div>
</div>
<div class="queue-actions">
<button type="button" id="admin-refresh-queues" class="btn-refresh">Refresh Queues</button>
</div>
</div>
</div>
<!-- Settings Tab -->
<div class="tab-pane" id="tab-settings">
<div class="settings-panel">
<!-- Caller ID -->
<div class="settings-section">
<h4>Outbound Caller ID</h4>
<select id="caller-id-select">
<option value="">Loading numbers...</option>
</select>
</div>
<!-- Auto-answer -->
<div class="settings-section">
<label><input type="checkbox" id="auto-answer" /> Auto-answer incoming calls</label>
</div>
<!-- Dark Mode -->
<div class="settings-section">
<h4>Appearance</h4>
<div class="dark-mode-options">
<button type="button" class="dark-mode-opt" data-theme="system">System</button>
<button type="button" class="dark-mode-opt" data-theme="light">Light</button>
<button type="button" class="dark-mode-opt" data-theme="dark">Dark</button>
</div>
</div>
<!-- Call Mode -->
<div class="settings-section">
<h4>Call Reception Mode</h4>
<div class="mode-selection">
<label class="mode-option <?php echo $current_mode === 'browser' ? 'active' : ''; ?>">
<input type="radio" name="call_mode" value="browser" <?php checked($current_mode, 'browser'); ?>>
<div class="mode-icon">&#128187;</div>
<div class="mode-details">
<strong>Browser Phone</strong>
<small>Calls ring in this browser</small>
</div>
</label>
<label class="mode-option <?php echo $current_mode === 'cell' ? 'active' : ''; ?>">
<input type="radio" name="call_mode" value="cell" <?php checked($current_mode, 'cell'); ?>>
<div class="mode-icon">&#128241;</div>
<div class="mode-details">
<strong>Cell Phone</strong>
<small>Forward to your mobile</small>
</div>
</label>
</div>
<div class="mode-status">
<div id="current-mode-display">
<strong>Current:</strong>
<span id="mode-text"><?php echo $current_mode === 'browser' ? 'Browser Phone' : 'Cell Phone'; ?></span>
</div>
<button type="button" id="save-mode-btn" style="display:none;">Save</button>
</div>
<div class="mode-info">
<div class="browser-mode-info" style="display:<?php echo $current_mode === 'browser' ? 'block' : 'none'; ?>;">
<p>Keep this page open to receive calls.</p>
</div>
<div class="cell-mode-info" style="display:<?php echo $current_mode === 'cell' ? 'block' : 'none'; ?>;">
<p>Calls forwarded to: <?php echo $user_phone ? esc_html($user_phone) : '<em>Not configured</em>'; ?></p>
</div>
</div>
</div>
<?php if (!$smart_routing_configured && current_user_can('manage_options')): ?>
<div class="setup-info">
<h4>Setup Required</h4>
<p>Update your phone number webhook to:</p>
<code><?php echo esc_html($smart_routing_webhook); ?></code>
<button type="button" class="btn-copy" onclick="copyToClipboard('<?php echo esc_js($smart_routing_webhook); ?>')">Copy</button>
</div>
<?php endif; ?>
</div>
</div>
</div><!-- .tab-content -->
</div><!-- .twp-app -->
<!-- Configuration for JavaScript -->
<script>
window.twpConfig = {
ajaxUrl: <?php echo wp_json_encode($ajax_url); ?>,
nonce: <?php echo wp_json_encode($nonce); ?>,
ringtoneUrl: <?php echo wp_json_encode($ringtone_url); ?>,
phoneIconUrl: <?php echo wp_json_encode($phone_icon_url); ?>,
swUrl: <?php echo wp_json_encode($sw_url); ?>,
twilioEdge: <?php echo wp_json_encode($twilio_edge); ?>
};
</script>
<!-- Twilio Voice SDK v2.11.0 -->
<script src="https://unpkg.com/@twilio/voice-sdk@2.11.0/dist/twilio.min.js"></script>
<!-- Phone JavaScript -->
<script src="<?php echo plugins_url('assets/mobile/phone.js', $plugin_file); ?>"></script>
</body>
</html>

848
assets/mobile/phone.css Normal file
View File

@@ -0,0 +1,848 @@
/* ===================================================================
CSS Reset & Base
=================================================================== */
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--bg-primary: #f5f6fa;
--bg-secondary: #ffffff;
--bg-phone: #1a1a2e;
--bg-display: #16213e;
--text-primary: #2c3e50;
--text-secondary: #7f8c8d;
--text-light: #ffffff;
--accent: #2196F3;
--accent-dark: #1976D2;
--success: #4CAF50;
--warning: #FF9800;
--danger: #f44336;
--border: #e0e0e0;
--shadow: 0 2px 8px rgba(0,0,0,0.1);
--radius: 12px;
--safe-top: env(safe-area-inset-top, 0px);
--safe-bottom: env(safe-area-inset-bottom, 0px);
}
@media (prefers-color-scheme: dark) {
:root:not(.light-mode) {
--bg-primary: #0f0f23;
--bg-secondary: #1a1a2e;
--bg-phone: #16213e;
--bg-display: #0a0a1a;
--text-primary: #ecf0f1;
--text-secondary: #95a5a6;
--border: #2c3e50;
--shadow: 0 2px 8px rgba(0,0,0,0.4);
}
}
/* Manual dark mode override via class on <html> */
:root.dark-mode {
--bg-primary: #0f0f23;
--bg-secondary: #1a1a2e;
--bg-phone: #16213e;
--bg-display: #0a0a1a;
--text-primary: #ecf0f1;
--text-secondary: #95a5a6;
--border: #2c3e50;
--shadow: 0 2px 8px rgba(0,0,0,0.4);
}
html, body {
height: 100%;
overflow: hidden;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
background: var(--bg-primary);
color: var(--text-primary);
-webkit-text-size-adjust: 100%;
-webkit-tap-highlight-color: transparent;
}
/* ===================================================================
Layout — full-screen flex column
=================================================================== */
.twp-app {
display: flex;
flex-direction: column;
height: 100%;
max-width: 500px;
margin: 0 auto;
padding-top: var(--safe-top);
padding-bottom: var(--safe-bottom);
}
/* ===================================================================
Agent Status Bar (compact)
=================================================================== */
.agent-status-bar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 12px;
background: var(--bg-secondary);
border-bottom: 1px solid var(--border);
flex-shrink: 0;
gap: 8px;
flex-wrap: wrap;
}
.status-info {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
flex-wrap: wrap;
}
.extension-badge {
background: var(--accent);
color: #fff;
padding: 2px 8px;
border-radius: 10px;
font-size: 12px;
font-weight: 600;
}
.agent-stats {
display: flex;
gap: 10px;
font-size: 11px;
color: var(--text-secondary);
}
#login-toggle-btn, #agent-status-select {
font-size: 12px;
padding: 3px 10px;
border-radius: 4px;
border: 1px solid var(--border);
background: var(--bg-secondary);
color: var(--text-primary);
cursor: pointer;
}
#login-toggle-btn.logged-in {
background: var(--danger);
color: #fff;
border-color: var(--danger);
}
/* ===================================================================
Notices
=================================================================== */
.twp-notice {
padding: 8px 12px;
margin: 6px 10px;
border-radius: 6px;
font-size: 13px;
animation: fadeIn 0.2s ease;
}
.twp-notice-success { background: #e8f5e9; color: #2e7d32; }
.twp-notice-error { background: #ffebee; color: #c62828; }
.twp-notice-info { background: #e3f2fd; color: #1565c0; }
@media (prefers-color-scheme: dark) {
:root:not(.light-mode) .twp-notice-success { background: #1b5e20; color: #a5d6a7; }
:root:not(.light-mode) .twp-notice-error { background: #b71c1c; color: #ef9a9a; }
:root:not(.light-mode) .twp-notice-info { background: #0d47a1; color: #90caf9; }
}
.dark-mode .twp-notice-success { background: #1b5e20; color: #a5d6a7; }
.dark-mode .twp-notice-error { background: #b71c1c; color: #ef9a9a; }
.dark-mode .twp-notice-info { background: #0d47a1; color: #90caf9; }
@keyframes fadeIn { from { opacity: 0; transform: translateY(-4px); } to { opacity: 1; transform: translateY(0); } }
/* ===================================================================
Tab Navigation
=================================================================== */
.tab-nav {
display: flex;
background: var(--bg-secondary);
border-bottom: 1px solid var(--border);
flex-shrink: 0;
}
.tab-btn {
flex: 1;
padding: 10px 0;
text-align: center;
font-size: 13px;
font-weight: 600;
background: none;
border: none;
border-bottom: 3px solid transparent;
color: var(--text-secondary);
cursor: pointer;
transition: color 0.2s, border-color 0.2s;
}
.tab-btn.active {
color: var(--accent);
border-bottom-color: var(--accent);
}
/* ===================================================================
Tab Content
=================================================================== */
.tab-content {
flex: 1;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
}
.tab-pane { display: none; height: 100%; }
.tab-pane.active { display: flex; flex-direction: column; }
/* ===================================================================
Phone Interface
=================================================================== */
.phone-interface {
display: flex;
flex-direction: column;
height: 100%;
padding: 12px;
gap: 12px;
}
/* Display */
.phone-display {
background: var(--bg-display);
color: var(--text-light);
padding: 16px;
border-radius: var(--radius);
text-align: center;
}
#phone-status {
font-size: 14px;
color: var(--success);
margin-bottom: 4px;
}
#device-connection-status {
font-size: 11px;
color: var(--text-secondary);
margin-top: 2px;
}
#phone-number-display {
font-size: 20px;
min-height: 28px;
font-weight: 600;
letter-spacing: 1px;
}
#call-timer {
font-size: 16px;
margin-top: 6px;
font-variant-numeric: tabular-nums;
}
/* Input */
#phone-number-input {
width: 100%;
padding: 12px;
font-size: 20px;
text-align: center;
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--bg-secondary);
color: var(--text-primary);
outline: none;
}
#phone-number-input:focus {
border-color: var(--accent);
box-shadow: 0 0 0 2px rgba(33,150,243,0.2);
}
/* Dialpad */
.dialpad-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 8px;
}
.dialpad-btn {
padding: 14px 0;
font-size: 22px;
font-weight: 500;
border: 1px solid var(--border);
background: var(--bg-secondary);
color: var(--text-primary);
border-radius: var(--radius);
cursor: pointer;
-webkit-user-select: none;
user-select: none;
position: relative;
transition: background 0.1s;
min-height: 54px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.dialpad-btn:active {
background: var(--accent);
color: #fff;
border-color: var(--accent);
}
.dialpad-btn span {
display: block;
font-size: 9px;
color: var(--text-secondary);
margin-top: 1px;
letter-spacing: 2px;
}
.dialpad-btn:active span { color: rgba(255,255,255,0.8); }
/* Phone Controls */
.phone-controls {
display: flex;
gap: 8px;
}
.phone-controls .btn-phone {
flex: 1;
height: 50px;
font-size: 16px;
font-weight: 600;
border: none;
border-radius: var(--radius);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
transition: opacity 0.2s;
}
.btn-call {
background: var(--success);
color: #fff;
}
.btn-hangup {
background: var(--danger);
color: #fff;
}
.btn-answer {
background: var(--success);
color: #fff;
animation: pulse 1.5s infinite;
}
@keyframes pulse {
0%, 100% { box-shadow: 0 0 0 0 rgba(76,175,80,0.4); }
50% { box-shadow: 0 0 0 10px rgba(76,175,80,0); }
}
/* Call Controls (hold/transfer/requeue/record) */
.call-controls-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 8px;
}
.call-controls-grid .btn-ctrl {
padding: 10px 8px;
font-size: 13px;
font-weight: 500;
border: 1px solid var(--border);
background: var(--bg-secondary);
color: var(--text-primary);
border-radius: 8px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
transition: background 0.1s;
}
.btn-ctrl:active { background: #e3f2fd; }
.btn-ctrl.btn-active {
background: var(--accent);
color: #fff;
border-color: var(--accent);
}
/* Error display */
#browser-phone-error {
background: #ffebee;
color: #c62828;
padding: 10px 12px;
border-radius: 8px;
font-size: 13px;
margin: 0 12px;
}
@media (prefers-color-scheme: dark) {
:root:not(.light-mode) #browser-phone-error { background: #b71c1c; color: #ef9a9a; }
}
.dark-mode #browser-phone-error { background: #b71c1c; color: #ef9a9a; }
/* ===================================================================
Settings Tab
=================================================================== */
.settings-panel {
padding: 12px;
display: flex;
flex-direction: column;
gap: 16px;
overflow-y: auto;
}
.settings-section {
background: var(--bg-secondary);
padding: 14px;
border-radius: var(--radius);
border: 1px solid var(--border);
}
.settings-section h4 {
font-size: 14px;
margin-bottom: 10px;
color: var(--accent-dark);
}
.settings-section label {
font-size: 13px;
display: flex;
align-items: center;
gap: 6px;
}
.settings-section select {
width: 100%;
padding: 8px;
border: 1px solid var(--border);
border-radius: 6px;
font-size: 14px;
background: var(--bg-primary);
color: var(--text-primary);
margin-top: 6px;
}
/* Call mode radio cards */
.mode-selection {
display: flex;
gap: 10px;
margin: 10px 0;
}
.mode-option {
display: flex;
align-items: center;
padding: 12px;
border: 2px solid var(--border);
border-radius: 8px;
cursor: pointer;
transition: all 0.2s;
flex: 1;
background: var(--bg-primary);
}
.mode-option.active {
border-color: var(--accent);
background: #e3f2fd;
}
@media (prefers-color-scheme: dark) {
:root:not(.light-mode) .mode-option.active { background: #0d47a1; }
}
.dark-mode .mode-option.active { background: #0d47a1; }
.mode-option input[type="radio"] { margin: 0 8px 0 0; }
.mode-icon { font-size: 20px; margin-right: 8px; }
.mode-details strong { display: block; font-size: 13px; }
.mode-details small { font-size: 11px; color: var(--text-secondary); }
.mode-status {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px;
background: var(--bg-primary);
border-radius: 6px;
font-size: 13px;
}
.mode-info { margin-top: 8px; font-size: 12px; color: var(--text-secondary); }
#save-mode-btn {
padding: 6px 16px;
border: none;
background: var(--accent);
color: #fff;
border-radius: 6px;
font-size: 13px;
cursor: pointer;
}
/* Setup info box */
.setup-info {
background: #fff3cd;
padding: 12px;
border-radius: 8px;
border-left: 4px solid #ffc107;
font-size: 12px;
}
@media (prefers-color-scheme: dark) {
:root:not(.light-mode) .setup-info { background: #4a3800; color: #ffe082; }
}
.dark-mode .setup-info { background: #4a3800; color: #ffe082; }
.setup-info h4 { margin-bottom: 6px; color: #856404; font-size: 13px; }
.setup-info code {
display: block;
padding: 6px 8px;
background: rgba(0,0,0,0.05);
border-radius: 4px;
font-size: 11px;
word-break: break-all;
margin: 6px 0;
}
.btn-copy {
font-size: 11px;
padding: 2px 8px;
border: 1px solid var(--border);
background: var(--bg-secondary);
border-radius: 4px;
cursor: pointer;
}
/* ===================================================================
Queue Tab
=================================================================== */
.queue-panel {
padding: 12px;
display: flex;
flex-direction: column;
gap: 10px;
overflow-y: auto;
}
.queue-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.queue-header h4 { font-size: 15px; }
.user-extension-admin {
background: var(--bg-secondary);
padding: 4px 10px;
border-radius: 4px;
font-size: 12px;
color: var(--accent-dark);
border: 1px solid var(--border);
}
.queue-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px;
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: 8px;
}
.queue-item.queue-type-personal { border-left: 4px solid var(--success); }
.queue-item.queue-type-hold { border-left: 4px solid var(--warning); }
.queue-item.queue-type-general { border-left: 4px solid var(--accent); }
.queue-item.has-calls { background: #fff3cd; }
@media (prefers-color-scheme: dark) {
:root:not(.light-mode) .queue-item.has-calls { background: #4a3800; }
}
.dark-mode .queue-item.has-calls { background: #4a3800; }
.queue-info { flex: 1; }
.queue-name {
display: flex;
align-items: center;
gap: 6px;
font-weight: 600;
font-size: 14px;
}
.queue-details {
font-size: 12px;
color: var(--text-secondary);
margin-top: 3px;
display: flex;
gap: 10px;
}
.queue-waiting.has-calls {
color: var(--danger);
font-weight: bold;
}
.queue-loading {
text-align: center;
color: var(--text-secondary);
font-style: italic;
padding: 20px;
}
.queue-actions { text-align: center; }
.btn-sm {
font-size: 12px;
padding: 6px 12px;
border: 1px solid var(--border);
background: var(--bg-secondary);
color: var(--text-primary);
border-radius: 6px;
cursor: pointer;
}
.btn-sm:active { background: #e3f2fd; }
.btn-refresh {
padding: 8px 16px;
border: 1px solid var(--border);
background: var(--bg-secondary);
color: var(--text-primary);
border-radius: 6px;
cursor: pointer;
font-size: 13px;
}
/* ===================================================================
Recent Tab
=================================================================== */
.recent-panel {
padding: 12px;
display: flex;
flex-direction: column;
gap: 10px;
overflow-y: auto;
height: 100%;
}
.recent-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.recent-header h4 { font-size: 15px; }
.recent-empty {
text-align: center;
color: var(--text-secondary);
font-style: italic;
padding: 40px 20px;
font-size: 14px;
}
.recent-item {
display: flex;
align-items: center;
padding: 12px;
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: 8px;
gap: 10px;
cursor: pointer;
transition: background 0.1s;
}
.recent-item:active { background: var(--bg-primary); }
.recent-direction {
font-size: 18px;
flex-shrink: 0;
width: 28px;
text-align: center;
}
.recent-info { flex: 1; min-width: 0; }
.recent-number {
font-weight: 600;
font-size: 14px;
color: var(--accent);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.recent-meta {
font-size: 12px;
color: var(--text-secondary);
margin-top: 2px;
display: flex;
gap: 10px;
}
.recent-callback {
flex-shrink: 0;
padding: 6px 12px;
background: var(--success);
color: #fff;
border: none;
border-radius: 6px;
font-size: 12px;
cursor: pointer;
}
/* ===================================================================
Dialog / Overlay (transfer, requeue)
=================================================================== */
.twp-overlay {
position: fixed;
top: 0; left: 0; right: 0; bottom: 0;
background: rgba(0,0,0,0.5);
z-index: 9999;
}
.twp-dialog {
position: fixed;
top: 50%; left: 50%;
transform: translate(-50%, -50%);
background: var(--bg-secondary);
padding: 20px;
border-radius: var(--radius);
box-shadow: 0 4px 20px rgba(0,0,0,0.3);
z-index: 10000;
width: 90%;
max-width: 420px;
max-height: 80vh;
overflow-y: auto;
color: var(--text-primary);
}
.twp-dialog h3 { margin: 0 0 12px 0; font-size: 16px; }
.twp-dialog input[type="text"],
.twp-dialog input[type="tel"] {
width: 100%;
padding: 10px;
border: 1px solid var(--border);
border-radius: 6px;
font-size: 14px;
background: var(--bg-primary);
color: var(--text-primary);
margin: 8px 0;
}
.twp-dialog .dialog-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
margin-top: 14px;
}
.btn-primary {
padding: 8px 18px;
border: none;
background: var(--accent);
color: #fff;
border-radius: 6px;
font-size: 13px;
cursor: pointer;
}
.btn-primary:disabled { opacity: 0.5; cursor: default; }
.btn-secondary {
padding: 8px 18px;
border: 1px solid var(--border);
background: var(--bg-secondary);
color: var(--text-primary);
border-radius: 6px;
font-size: 13px;
cursor: pointer;
}
.agent-option, .queue-option {
padding: 10px;
border: 1px solid var(--border);
border-radius: 6px;
margin-bottom: 6px;
cursor: pointer;
background: var(--bg-primary);
display: flex;
justify-content: space-between;
align-items: center;
}
.agent-option.selected, .queue-option.selected {
background: #e3f2fd;
border-color: var(--accent);
}
@media (prefers-color-scheme: dark) {
:root:not(.light-mode) .agent-option.selected, :root:not(.light-mode) .queue-option.selected { background: #0d47a1; }
}
.dark-mode .agent-option.selected, .dark-mode .queue-option.selected { background: #0d47a1; }
/* ===================================================================
Z-Index Layering & Overlap Fixes
=================================================================== */
.twp-app { position: relative; z-index: 1; }
.agent-status-bar { position: relative; z-index: 10; }
.tab-nav { position: relative; z-index: 10; }
.tab-content { position: relative; z-index: 1; }
.phone-interface { position: relative; z-index: 1; overflow-y: auto; }
.phone-controls { position: relative; z-index: 2; }
#admin-call-controls-panel { position: relative; z-index: 2; margin-top: 4px; }
.call-controls-grid { position: relative; z-index: 2; }
.twp-overlay { z-index: 9999; }
.twp-dialog { z-index: 10000; }
/* Ensure tab panes scroll properly */
.tab-pane.active { overflow-y: auto; -webkit-overflow-scrolling: touch; }
#tab-phone.active { overflow: hidden; }
.phone-interface { flex: 1; overflow-y: auto; min-height: 0; }
/* Prevent call controls from overlapping dialpad */
.dialpad-grid { position: relative; z-index: 1; flex-shrink: 0; }
/* Dark mode for btn-ctrl active state */
@media (prefers-color-scheme: dark) {
:root:not(.light-mode) .btn-ctrl:active { background: #0d47a1; color: #fff; }
:root:not(.light-mode) .btn-sm:active { background: #0d47a1; color: #fff; }
}
.dark-mode .btn-ctrl:active { background: #0d47a1; color: #fff; }
.dark-mode .btn-sm:active { background: #0d47a1; color: #fff; }
/* ===================================================================
Comprehensive Dark Mode Enhancements
=================================================================== */
/* Shared dark mode rules — applied by media query or manual toggle */
@media (prefers-color-scheme: dark) {
:root:not(.light-mode) input[type="tel"],
:root:not(.light-mode) input[type="text"],
:root:not(.light-mode) select {
background: var(--bg-secondary);
color: var(--text-primary);
border-color: var(--border);
}
:root:not(.light-mode) .settings-section h4 { color: #64b5f6; }
:root:not(.light-mode) .setup-info h4 { color: #ffe082; }
:root:not(.light-mode) .setup-info code { background: rgba(255,255,255,0.08); color: #ffe082; }
:root:not(.light-mode) .btn-copy { background: var(--bg-secondary); color: var(--text-primary); border-color: var(--border); }
:root:not(.light-mode) .btn-refresh { background: var(--bg-secondary); color: var(--text-primary); }
:root:not(.light-mode) .btn-secondary { background: var(--bg-secondary); color: var(--text-primary); }
}
.dark-mode input[type="tel"],
.dark-mode input[type="text"],
.dark-mode select {
background: var(--bg-secondary);
color: var(--text-primary);
border-color: var(--border);
}
.dark-mode .settings-section h4 { color: #64b5f6; }
.dark-mode .setup-info h4 { color: #ffe082; }
.dark-mode .setup-info code { background: rgba(255,255,255,0.08); color: #ffe082; }
.dark-mode .btn-copy { background: var(--bg-secondary); color: var(--text-primary); border-color: var(--border); }
.dark-mode .btn-refresh { background: var(--bg-secondary); color: var(--text-primary); }
.dark-mode .btn-secondary { background: var(--bg-secondary); color: var(--text-primary); }
/* Dark mode toggle switch styling */
.dark-mode-toggle {
display: flex;
align-items: center;
justify-content: space-between;
padding: 4px 0;
}
.dark-mode-toggle .toggle-label {
font-size: 13px;
color: var(--text-primary);
}
.toggle-switch {
position: relative;
width: 48px;
height: 26px;
cursor: pointer;
}
.toggle-switch input { opacity: 0; width: 0; height: 0; }
.toggle-slider {
position: absolute;
top: 0; left: 0; right: 0; bottom: 0;
background: var(--border);
border-radius: 26px;
transition: background 0.2s;
}
.toggle-slider::before {
content: '';
position: absolute;
width: 20px;
height: 20px;
left: 3px;
bottom: 3px;
background: #fff;
border-radius: 50%;
transition: transform 0.2s;
}
.toggle-switch input:checked + .toggle-slider {
background: var(--accent);
}
.toggle-switch input:checked + .toggle-slider::before {
transform: translateX(22px);
}
.dark-mode-options {
display: flex;
gap: 8px;
margin-top: 8px;
}
.dark-mode-opt {
flex: 1;
padding: 8px 4px;
text-align: center;
font-size: 12px;
font-weight: 500;
border: 1px solid var(--border);
background: var(--bg-primary);
color: var(--text-primary);
border-radius: 6px;
cursor: pointer;
transition: all 0.2s;
}
.dark-mode-opt.active {
border-color: var(--accent);
background: var(--accent);
color: #fff;
}

1065
assets/mobile/phone.js Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -480,6 +480,12 @@ class TWP_Activator {
$wpdb->query("ALTER TABLE $table_agent_status ADD COLUMN auto_busy_at datetime DEFAULT NULL AFTER logged_in_at");
}
// Add pre_call_status column to store status before a call set agent to busy
$pre_call_exists = $wpdb->get_results("SHOW COLUMNS FROM $table_agent_status LIKE 'pre_call_status'");
if (empty($pre_call_exists)) {
$wpdb->query("ALTER TABLE $table_agent_status ADD COLUMN pre_call_status varchar(20) DEFAULT NULL AFTER auto_busy_at");
}
$table_schedules = $wpdb->prefix . 'twp_phone_schedules';
// Check if holiday_dates column exists

View File

@@ -619,17 +619,17 @@ class TWP_Agent_Manager {
}
/**
* Check and revert agents from auto-busy to available after 1 minute
* Check and revert agents from auto-busy to their previous status after 30 seconds
*/
public static function revert_auto_busy_agents() {
global $wpdb;
$table_name = $wpdb->prefix . 'twp_agent_status';
// Find agents who have been auto-busy for more than 1 minute and are still logged in
$cutoff_time = date('Y-m-d H:i:s', strtotime('-1 minute'));
// Find agents who have been auto-busy for more than 30 seconds and are still logged in
$cutoff_time = date('Y-m-d H:i:s', strtotime('-30 seconds'));
$auto_busy_agents = $wpdb->get_results($wpdb->prepare(
"SELECT user_id, current_call_sid FROM $table_name
"SELECT user_id, current_call_sid, pre_call_status FROM $table_name
WHERE status = 'busy'
AND auto_busy_at IS NOT NULL
AND auto_busy_at < %s
@@ -655,14 +655,22 @@ class TWP_Agent_Manager {
}
} catch (Exception $e) {
error_log("TWP Auto-Revert: Could not check call status for {$call_sid}: " . $e->getMessage());
// If we can't check call status, assume it's finished and proceed with revert
}
}
// Only revert if call is not active
if (!$call_active) {
error_log("TWP Auto-Revert: Reverting user {$agent->user_id} from auto-busy to available");
self::set_agent_status($agent->user_id, 'available', null, false);
$revert_to = !empty($agent->pre_call_status) ? $agent->pre_call_status : 'available';
error_log("TWP Auto-Revert: Reverting user {$agent->user_id} from busy to {$revert_to}");
self::set_agent_status($agent->user_id, $revert_to, null, false);
// Clear pre_call_status
$wpdb->update(
$table_name,
array('pre_call_status' => null),
array('user_id' => $agent->user_id),
array('%s'),
array('%d')
);
}
}

View File

@@ -33,8 +33,8 @@ class TWP_Call_Queue {
);
if ($result !== false) {
// Notify agents via SMS when a new call enters the queue
self::notify_agents_for_queue($queue_id, $call_data['from_number']);
// Notify agents via SMS and FCM when a new call enters the queue
self::notify_agents_for_queue($queue_id, $call_data['from_number'], $call_data['call_sid']);
return $position;
}
@@ -577,10 +577,73 @@ class TWP_Call_Queue {
return $status;
}
/**
* Cron callback: re-send FCM queue alerts every minute for calls still waiting.
* Only alerts for calls that have been waiting > 60 seconds (initial alert
* already sent on entry). Skips re-alerting for the same call within 55 seconds
* using a short transient to avoid overlap with the 60-second cron.
*/
public static function send_queue_reminders() {
global $wpdb;
$calls_table = $wpdb->prefix . 'twp_queued_calls';
$queue_table = $wpdb->prefix . 'twp_call_queues';
// Find calls waiting longer than 60 seconds
$waiting_calls = $wpdb->get_results(
"SELECT c.*, q.queue_name, q.user_id AS queue_owner_id, q.agent_group_id
FROM $calls_table c
JOIN $queue_table q ON q.id = c.queue_id
WHERE c.status = 'waiting'
AND c.joined_at <= DATE_SUB(NOW(), INTERVAL 60 SECOND)"
);
if (empty($waiting_calls)) {
return;
}
require_once dirname(__FILE__) . '/class-twp-fcm.php';
$fcm = new TWP_FCM();
foreach ($waiting_calls as $call) {
// Throttle: skip if we reminded for this call within the last 55 seconds
$transient_key = 'twp_queue_remind_' . $call->call_sid;
if (get_transient($transient_key)) {
continue;
}
set_transient($transient_key, 1, 55);
$waiting_minutes = max(1, round((time() - strtotime($call->joined_at)) / 60));
$title = 'Call Still Waiting';
$body = "Call from {$call->from_number} waiting {$waiting_minutes}m in {$call->queue_name}";
$notified_users = array();
// Notify queue owner
if (!empty($call->queue_owner_id)) {
$fcm->notify_queue_alert($call->queue_owner_id, $call->from_number, $call->queue_name, $call->call_sid);
$notified_users[] = $call->queue_owner_id;
}
// Notify agent group members
if (!empty($call->agent_group_id)) {
require_once dirname(__FILE__) . '/class-twp-agent-groups.php';
$members = TWP_Agent_Groups::get_group_members($call->agent_group_id);
foreach ($members as $member) {
if (!in_array($member->user_id, $notified_users)) {
$fcm->notify_queue_alert($member->user_id, $call->from_number, $call->queue_name, $call->call_sid);
$notified_users[] = $member->user_id;
}
}
}
error_log("TWP Queue Reminder: Re-alerted " . count($notified_users) . " user(s) for call {$call->call_sid} waiting {$waiting_minutes}m");
}
}
/**
* Notify agents via SMS when a call enters the queue
*/
private static function notify_agents_for_queue($queue_id, $caller_number) {
private static function notify_agents_for_queue($queue_id, $caller_number, $call_sid = '') {
global $wpdb;
error_log("TWP: notify_agents_for_queue called for queue {$queue_id}, caller {$caller_number}");
@@ -597,16 +660,8 @@ class TWP_Call_Queue {
return;
}
if (!$queue->agent_group_id) {
error_log("TWP: No agent group assigned to queue {$queue_id}, skipping SMS notifications");
return;
}
error_log("TWP: Found queue '{$queue->queue_name}' with agent group {$queue->agent_group_id}");
// Send Discord/Slack notification for incoming call
require_once dirname(__FILE__) . '/class-twp-notifications.php';
error_log("TWP: Triggering Discord/Slack notification for incoming call");
TWP_Notifications::send_call_notification('incoming_call', array(
'type' => 'incoming_call',
'caller' => $caller_number,
@@ -614,15 +669,34 @@ class TWP_Call_Queue {
'queue_id' => $queue_id
));
// Send FCM push notifications to agents' mobile devices
require_once dirname(__FILE__) . '/class-twp-fcm.php';
$fcm = new TWP_FCM();
$notified_users = array();
// Always notify personal queue owner
if (!empty($queue->user_id)) {
$fcm->notify_queue_alert($queue->user_id, $caller_number, $queue->queue_name, $call_sid);
$notified_users[] = $queue->user_id;
error_log("TWP: FCM queue alert sent to queue owner user {$queue->user_id}");
}
if (!$queue->agent_group_id) {
error_log("TWP: No agent group assigned to queue {$queue_id}, skipping SMS notifications");
return;
}
error_log("TWP: Found queue '{$queue->queue_name}' with agent group {$queue->agent_group_id}");
// Get members of the assigned agent group
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 (!in_array($member->user_id, $notified_users)) {
$fcm->notify_queue_alert($member->user_id, $caller_number, $queue->queue_name, $call_sid);
$notified_users[] = $member->user_id;
}
}
if (empty($members)) {

View File

@@ -39,6 +39,7 @@ class TWP_Core {
require_once TWP_PLUGIN_DIR . 'includes/class-twp-mobile-api.php';
require_once TWP_PLUGIN_DIR . 'includes/class-twp-mobile-sse.php';
require_once TWP_PLUGIN_DIR . 'includes/class-twp-fcm.php';
require_once TWP_PLUGIN_DIR . 'includes/class-twp-mobile-phone-page.php';
require_once TWP_PLUGIN_DIR . 'includes/class-twp-auto-updater.php';
// Feature classes
@@ -255,6 +256,9 @@ class TWP_Core {
// Initialize Shortcodes
TWP_Shortcodes::init();
// Initialize standalone mobile phone page (/twp-phone/)
new TWP_Mobile_Phone_Page();
// Scheduled events
$scheduler = new TWP_Scheduler();
$this->loader->add_action('twp_check_schedules', $scheduler, 'check_active_schedules');
@@ -262,6 +266,9 @@ class TWP_Core {
$queue = new TWP_Call_Queue();
$this->loader->add_action('twp_process_queue', $queue, 'process_waiting_calls');
// Queue reminder alerts (re-send FCM every minute for waiting calls)
add_action('twp_queue_reminders', array('TWP_Call_Queue', 'send_queue_reminders'));
// Callback processing
$this->loader->add_action('twp_process_callbacks', 'TWP_Callback_Manager', 'process_callbacks');
@@ -277,6 +284,10 @@ class TWP_Core {
wp_schedule_event(time(), 'twp_every_30_seconds', 'twp_process_queue');
}
if (!wp_next_scheduled('twp_queue_reminders')) {
wp_schedule_event(time(), 'twp_every_minute', 'twp_queue_reminders');
}
if (!wp_next_scheduled('twp_process_callbacks')) {
wp_schedule_event(time(), 'twp_every_minute', 'twp_process_callbacks');
}

View File

@@ -11,6 +11,7 @@ class TWP_Deactivator {
// Clear scheduled events
wp_clear_scheduled_hook('twp_check_schedules');
wp_clear_scheduled_hook('twp_process_queue');
wp_clear_scheduled_hook('twp_queue_reminders');
wp_clear_scheduled_hook('twp_auto_revert_agents');
// Flush rewrite rules

View File

@@ -279,22 +279,68 @@ class TWP_FCM {
}
/**
* Send incoming call notification
* Send queue alert notification (call entered queue).
* Uses data-only message so it works in background/killed state.
*/
public function notify_incoming_call($user_id, $from_number, $queue_name, $call_sid) {
$title = 'Incoming Call';
$body = "Call from $from_number in $queue_name queue";
public function notify_queue_alert($user_id, $from_number, $queue_name, $call_sid) {
$title = 'Call Waiting';
$body = "Call from $from_number in $queue_name";
$data = array(
'type' => 'incoming_call',
'type' => 'queue_alert',
'call_sid' => $call_sid,
'from_number' => $from_number,
'queue_name' => $queue_name
'queue_name' => $queue_name,
);
return $this->send_notification($user_id, $title, $body, $data, true);
}
/**
* Cancel queue alert notification (call answered or caller disconnected).
*/
public function notify_queue_alert_cancel($user_id, $call_sid) {
$data = array(
'type' => 'queue_alert_cancel',
'call_sid' => $call_sid,
);
return $this->send_notification($user_id, '', '', $data, true);
}
/**
* Send queue alert cancel to all agents assigned to a queue.
*/
public function cancel_queue_alert_for_queue($queue_id, $call_sid) {
global $wpdb;
$queue_table = $wpdb->prefix . 'twp_call_queues';
$queue = $wpdb->get_row($wpdb->prepare(
"SELECT * FROM $queue_table WHERE id = %d", $queue_id
));
if (!$queue) return;
$notified_users = array();
// Notify personal queue owner
if (!empty($queue->user_id)) {
$this->notify_queue_alert_cancel($queue->user_id, $call_sid);
$notified_users[] = $queue->user_id;
}
// Notify agent group members
if (!empty($queue->agent_group_id)) {
require_once dirname(__FILE__) . '/class-twp-agent-groups.php';
$members = TWP_Agent_Groups::get_group_members($queue->agent_group_id);
foreach ($members as $member) {
if (!in_array($member->user_id, $notified_users)) {
$this->notify_queue_alert_cancel($member->user_id, $call_sid);
$notified_users[] = $member->user_id;
}
}
}
}
/**
* Send queue timeout notification
*/

View File

@@ -113,6 +113,20 @@ class TWP_Mobile_API {
'callback' => array($this, 'get_phone_numbers'),
'permission_callback' => array($this->auth, 'verify_token')
));
// Outbound call (click-to-call via server)
register_rest_route('twilio-mobile/v1', '/calls/outbound', array(
'methods' => 'POST',
'callback' => array($this, 'initiate_outbound_call'),
'permission_callback' => array($this->auth, 'verify_token')
));
// FCM push credential setup (admin only)
register_rest_route('twilio-mobile/v1', '/admin/push-credential', array(
'methods' => 'POST',
'callback' => array($this, 'setup_push_credential'),
'permission_callback' => array($this->auth, 'verify_token')
));
});
}
@@ -297,12 +311,9 @@ class TWP_Mobile_API {
$user_id = $this->auth->get_current_user_id();
$call_sid = $request['call_sid'];
// Get agent phone number
$agent_number = get_user_meta($user_id, 'twp_agent_phone', true);
if (empty($agent_number)) {
return new WP_Error('no_phone', 'No phone number configured for agent', array('status' => 400));
}
// Check for WebRTC client_identity parameter
$body = $request->get_json_params();
$client_identity = isset($body['client_identity']) ? sanitize_text_field($body['client_identity']) : null;
// Initialize Twilio API
require_once plugin_dir_path(dirname(__FILE__)) . 'includes/class-twp-twilio-api.php';
@@ -327,6 +338,79 @@ class TWP_Mobile_API {
}
try {
if (!empty($client_identity)) {
// WebRTC path: redirect the queued call to the Twilio Client device
// Use the original caller's number as caller ID so it shows on the agent's device
$caller_id = $call->from_number;
if (empty($caller_id)) {
$caller_id = $call->to_number;
}
if (empty($caller_id)) {
$caller_id = get_option('twp_caller_id_number', '');
}
$twiml = '<Response><Dial callerId="' . htmlspecialchars($caller_id) . '"><Client>' . htmlspecialchars($client_identity) . '</Client></Dial></Response>';
error_log('TWP accept_call: call_sid=' . $call_sid . ' client=' . $client_identity . ' twiml=' . $twiml);
$result = $twilio->update_call($call_sid, array('twiml' => $twiml));
error_log('TWP accept_call result: ' . json_encode($result));
if (!$result['success']) {
return new WP_Error('twilio_error', $result['error'] ?? 'Failed to update call', array('status' => 500));
}
// Update call record
$wpdb->update(
$calls_table,
array(
'status' => 'connecting',
'agent_phone' => 'client:' . $client_identity,
),
array('call_sid' => $call_sid),
array('%s', '%s'),
array('%s')
);
// Save current status before setting busy, so we can revert after call ends
$status_table = $wpdb->prefix . 'twp_agent_status';
$current = $wpdb->get_row($wpdb->prepare(
"SELECT status FROM $status_table WHERE user_id = %d", $user_id
));
$pre_call_status = ($current && $current->status !== 'busy') ? $current->status : null;
$wpdb->update(
$status_table,
array(
'status' => 'busy',
'current_call_sid' => $call_sid,
'pre_call_status' => $pre_call_status,
'auto_busy_at' => null,
),
array('user_id' => $user_id),
array('%s', '%s', '%s', '%s'),
array('%d')
);
// Cancel queue alert notifications on all agents' devices
require_once plugin_dir_path(dirname(__FILE__)) . 'includes/class-twp-fcm.php';
$fcm = new TWP_FCM();
$fcm->cancel_queue_alert_for_queue($call->queue_id, $call_sid);
return new WP_REST_Response(array(
'success' => true,
'message' => 'Call accepted via WebRTC client',
'call_sid' => $call_sid
), 200);
} else {
// Phone-based path (original flow): dial the agent's phone number
$agent_number = get_user_meta($user_id, 'twp_agent_phone', true);
if (empty($agent_number)) {
return new WP_Error('no_phone', 'No phone number configured for agent and no client_identity provided', array('status' => 400));
}
// Connect agent to call
$agent_call = $twilio->create_call(
$agent_number,
@@ -367,6 +451,7 @@ class TWP_Mobile_API {
'message' => 'Call accepted, connecting to agent',
'agent_call_sid' => $agent_call->sid
), 200);
}
} catch (Exception $e) {
return new WP_Error('twilio_error', $e->getMessage(), array('status' => 500));
@@ -690,11 +775,35 @@ class TWP_Mobile_API {
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);
// AccessToken requires an API Key (not account credentials).
// Auto-create and cache one if it doesn't exist yet.
$api_key_sid = get_option('twp_twilio_api_key_sid');
$api_key_secret = get_option('twp_twilio_api_key_secret');
if (empty($api_key_sid) || empty($api_key_secret)) {
$client = new \Twilio\Rest\Client($account_sid, $auth_token);
$newKey = $client->newKeys->create(['friendlyName' => 'TWP Mobile Voice']);
$api_key_sid = $newKey->sid;
$api_key_secret = $newKey->secret;
update_option('twp_twilio_api_key_sid', $api_key_sid);
update_option('twp_twilio_api_key_secret', $api_key_secret);
}
$token = new \Twilio\Jwt\AccessToken($account_sid, $api_key_sid, $api_key_secret, 3600, $identity);
$voiceGrant = new \Twilio\Jwt\Grants\VoiceGrant();
$voiceGrant->setOutgoingApplicationSid($twiml_app_sid);
$voiceGrant->setIncomingAllow(true);
// Include FCM push credential for incoming call notifications.
// Auto-create from the stored Firebase service account JSON if not yet created.
$push_credential_sid = get_option('twp_twilio_push_credential_sid');
if (empty($push_credential_sid)) {
$push_credential_sid = $this->ensure_push_credential($account_sid, $auth_token);
}
if (!empty($push_credential_sid)) {
$voiceGrant->setPushCredentialSid($push_credential_sid);
}
$token->addGrant($voiceGrant);
return new WP_REST_Response(array(
@@ -766,6 +875,79 @@ class TWP_Mobile_API {
return (bool)$is_assigned;
}
/**
* Admin endpoint to force re-creation of the Twilio Push Credential.
*/
public function setup_push_credential($request) {
$user_id = $this->auth->get_current_user_id();
$user = get_userdata($user_id);
if (!user_can($user, 'manage_options')) {
return new WP_Error('forbidden', 'Admin access required', array('status' => 403));
}
try {
require_once plugin_dir_path(dirname(__FILE__)) . 'includes/class-twp-twilio-api.php';
new TWP_Twilio_API();
$account_sid = get_option('twp_twilio_account_sid');
$auth_token = get_option('twp_twilio_auth_token');
// Force re-creation by clearing existing SID
delete_option('twp_twilio_push_credential_sid');
$sid = $this->ensure_push_credential($account_sid, $auth_token);
if (empty($sid)) {
return new WP_Error('credential_error', 'Failed to create push credential. Check that Firebase service account JSON is configured in Mobile App Settings.', array('status' => 500));
}
return new WP_REST_Response(array(
'success' => true,
'credential_sid' => $sid,
), 200);
} catch (Exception $e) {
error_log('TWP setup_push_credential error: ' . $e->getMessage());
return new WP_Error('credential_error', $e->getMessage(), array('status' => 500));
}
}
/**
* Auto-create Twilio Push Credential from the stored Firebase service account JSON.
* Returns the credential SID or empty string on failure.
*/
private function ensure_push_credential($account_sid, $auth_token) {
$sa_json = get_option('twp_fcm_service_account_json', '');
if (empty($sa_json)) {
return '';
}
$sa = json_decode($sa_json, true);
if (!$sa || empty($sa['project_id']) || empty($sa['private_key'])) {
error_log('TWP: Firebase service account JSON is invalid');
return '';
}
try {
$client = new \Twilio\Rest\Client($account_sid, $auth_token);
$credential = $client->notify->v1->credentials->create(
'fcm',
[
'friendlyName' => 'TWP Mobile FCM',
'secret' => $sa_json,
]
);
update_option('twp_twilio_push_credential_sid', $credential->sid);
error_log('TWP: Created Twilio push credential: ' . $credential->sid);
return $credential->sid;
} catch (Exception $e) {
error_log('TWP ensure_push_credential error: ' . $e->getMessage());
return '';
}
}
/**
* Calculate wait time in seconds
*/

View File

@@ -423,6 +423,7 @@ class TWP_Mobile_Auth {
global $wpdb;
$table = $wpdb->prefix . 'twp_mobile_sessions';
if (!empty($refresh_token)) {
$wpdb->update(
$table,
array('fcm_token' => $fcm_token),
@@ -430,6 +431,13 @@ class TWP_Mobile_Auth {
array('%s'),
array('%d', '%s', '%d')
);
} else {
// No refresh token — update the most recent active session for this user
$wpdb->query($wpdb->prepare(
"UPDATE $table SET fcm_token = %s WHERE user_id = %d AND is_active = 1 AND expires_at > NOW() ORDER BY created_at DESC LIMIT 1",
$fcm_token, $user_id
));
}
}
/**

View File

@@ -0,0 +1,226 @@
<?php
/**
* Standalone Mobile Phone Page
*
* Registers a front-end endpoint at /twp-phone/ that serves the browser phone UI
* without any wp-admin chrome. Designed for mobile WebView usage.
*
* @package Twilio_WP_Plugin
*/
class TWP_Mobile_Phone_Page {
/**
* The endpoint slug.
*/
const ENDPOINT = 'twp-phone';
/**
* Constructor — wire up hooks.
*/
public function __construct() {
add_action('init', array($this, 'register_rewrite'));
add_action('template_redirect', array($this, 'handle_request'));
add_filter('query_vars', array($this, 'add_query_var'));
// Extend session cookie for phone agents.
add_filter('auth_cookie_expiration', array($this, 'extend_agent_cookie'), 10, 3);
// AJAX action for FCM token registration (uses WP cookie auth).
add_action('wp_ajax_twp_register_fcm_token', array($this, 'ajax_register_fcm_token'));
}
/**
* AJAX handler: register FCM token for the current user.
*/
public function ajax_register_fcm_token() {
check_ajax_referer('twp_ajax_nonce', 'nonce');
$fcm_token = sanitize_text_field($_POST['fcm_token'] ?? '');
if (empty($fcm_token)) {
wp_send_json_error('Missing FCM token');
}
$user_id = get_current_user_id();
if (!$user_id) {
wp_send_json_error('Not authenticated');
}
// Store FCM token (same as TWP_Mobile_API::register_fcm_token)
global $wpdb;
$table = $wpdb->prefix . 'twp_mobile_sessions';
// Update existing session or insert new one
$existing = $wpdb->get_row($wpdb->prepare(
"SELECT id FROM $table WHERE user_id = %d AND fcm_token = %s AND is_active = 1",
$user_id, $fcm_token
));
if ($existing) {
// Refresh the expiry on existing session
$wpdb->update($table,
array('expires_at' => date('Y-m-d H:i:s', time() + 7 * DAY_IN_SECONDS)),
array('id' => $existing->id),
array('%s'),
array('%d')
);
} else {
$wpdb->insert($table, array(
'user_id' => $user_id,
'refresh_token' => 'webview-' . wp_generate_password(32, false),
'fcm_token' => $fcm_token,
'device_info' => 'WebView Mobile App',
'is_active' => 1,
'created_at' => current_time('mysql'),
'expires_at' => date('Y-m-d H:i:s', time() + 7 * DAY_IN_SECONDS),
));
if ($wpdb->last_error) {
error_log('TWP FCM: Failed to insert token: ' . $wpdb->last_error);
wp_send_json_error('Failed to store token');
}
}
error_log("TWP FCM: Token registered for user $user_id");
wp_send_json_success('FCM token registered');
}
/**
* Register custom rewrite rule.
*/
public function register_rewrite() {
add_rewrite_rule(
'^' . self::ENDPOINT . '/?$',
'index.php?twp_phone_page=1',
'top'
);
}
/**
* Expose query variable.
*
* @param array $vars Existing query vars.
* @return array
*/
public function add_query_var($vars) {
$vars[] = 'twp_phone_page';
return $vars;
}
/**
* Handle the request on template_redirect.
*/
public function handle_request() {
if (!get_query_var('twp_phone_page')) {
return;
}
// Authentication check — redirect to login if not authenticated.
if (!is_user_logged_in()) {
$redirect_url = home_url('/' . self::ENDPOINT . '/');
wp_redirect(wp_login_url($redirect_url));
exit;
}
// Capability check.
if (!current_user_can('twp_access_browser_phone')) {
wp_die(
'You do not have permission to access the browser phone.',
'Access Denied',
array('response' => 403)
);
}
// Render the standalone page and exit.
$this->render_page();
exit;
}
/**
* Extend auth cookie to 7 days for phone agents.
*
* @param int $expiration Default expiration in seconds.
* @param int $user_id User ID.
* @param bool $remember Whether "Remember Me" was checked.
* @return int
*/
public function extend_agent_cookie($expiration, $user_id, $remember) {
$user = get_userdata($user_id);
if ($user && $user->has_cap('twp_access_browser_phone')) {
return 7 * DAY_IN_SECONDS;
}
return $expiration;
}
// ------------------------------------------------------------------
// Rendering
// ------------------------------------------------------------------
/**
* Output the complete standalone HTML page.
*/
private function render_page() {
// Gather data needed by the template (same as display_browser_phone_page).
$current_user_id = get_current_user_id();
global $wpdb;
$extensions_table = $wpdb->prefix . 'twp_user_extensions';
$extension_data = $wpdb->get_row($wpdb->prepare(
"SELECT extension FROM $extensions_table WHERE user_id = %d",
$current_user_id
));
if (!$extension_data) {
TWP_User_Queue_Manager::create_user_queues($current_user_id);
$extension_data = $wpdb->get_row($wpdb->prepare(
"SELECT extension FROM $extensions_table WHERE user_id = %d",
$current_user_id
));
}
$agent_status = TWP_Agent_Manager::get_agent_status($current_user_id);
$agent_stats = TWP_Agent_Manager::get_agent_stats($current_user_id);
$is_logged_in = TWP_Agent_Manager::is_agent_logged_in($current_user_id);
$current_mode = get_user_meta($current_user_id, 'twp_call_mode', true);
if (empty($current_mode)) {
$current_mode = 'cell';
}
$user_phone = get_user_meta($current_user_id, 'twp_phone_number', true);
// Smart routing check (for admin-only setup notice).
$smart_routing_configured = false;
try {
$twilio = new TWP_Twilio_API();
$phone_numbers = $twilio->get_phone_numbers();
if ($phone_numbers['success']) {
$smart_routing_url = home_url('/wp-json/twilio-webhook/v1/smart-routing');
foreach ($phone_numbers['data']['incoming_phone_numbers'] as $number) {
if (isset($number['voice_url']) && strpos($number['voice_url'], 'smart-routing') !== false) {
$smart_routing_configured = true;
break;
}
}
}
} catch (Exception $e) {
// Silently continue.
}
// Nonce for AJAX.
$nonce = wp_create_nonce('twp_ajax_nonce');
// URLs.
$ajax_url = admin_url('admin-ajax.php');
$ringtone_url = plugins_url('assets/sounds/ringtone.mp3', dirname(__FILE__));
$phone_icon_url = plugins_url('assets/images/phone-icon.png', dirname(__FILE__));
$sw_url = plugins_url('assets/js/twp-service-worker.js', dirname(__FILE__));
$twilio_edge = esc_js(get_option('twp_twilio_edge', 'roaming'));
$smart_routing_webhook = home_url('/wp-json/twilio-webhook/v1/smart-routing');
// Plugin file reference for plugins_url() in template.
$plugin_file = dirname(__FILE__) . '/../twilio-wp-plugin.php';
// Load the template (all variables above are in scope).
require TWP_PLUGIN_DIR . 'assets/mobile/phone-template.php';
}
}

View File

@@ -26,14 +26,32 @@ class TWP_Mobile_SSE {
'callback' => array($this, 'stream_events'),
'permission_callback' => array($this->auth, 'verify_token')
));
register_rest_route('twilio-mobile/v1', '/stream/poll', array(
'methods' => 'GET',
'callback' => array($this, 'poll_state'),
'permission_callback' => array($this->auth, 'verify_token')
));
});
}
/**
* Return current state as JSON (polling alternative to SSE)
*/
public function poll_state($request) {
$user_id = $this->auth->get_current_user_id();
if (!$user_id) {
return new WP_Error('unauthorized', 'Invalid token', array('status' => 401));
}
return rest_ensure_response($this->get_current_state($user_id));
}
/**
* Stream events to mobile app
*/
public function stream_events($request) {
error_log('TWP SSE: stream_events called');
$user_id = $this->auth->get_current_user_id();
error_log('TWP SSE: user_id=' . ($user_id ?: 'false'));
if (!$user_id) {
return new WP_Error('unauthorized', 'Invalid token', array('status' => 401));
@@ -56,6 +74,15 @@ class TWP_Mobile_SSE {
ob_end_flush();
}
// Flush padding to overcome Apache/HTTP2 frame buffering.
// SSE comments (lines starting with ':') are ignored by clients.
// We send >4KB to ensure the first HTTP/2 DATA frame is flushed.
echo ':' . str_repeat(' ', 4096) . "\n\n";
if (ob_get_level() > 0) ob_flush();
flush();
error_log('TWP SSE: padding flushed, sending connected event');
// Send initial connection event
$this->send_event('connected', array('user_id' => $user_id, 'timestamp' => time()));

View File

@@ -329,6 +329,12 @@ class TWP_Webhooks {
*/
public function handle_browser_voice($request) {
$params = $request->get_params();
error_log('TWP browser-voice webhook params: ' . json_encode(array(
'From' => $params['From'] ?? '',
'To' => $params['To'] ?? '',
'CallerId' => $params['CallerId'] ?? '',
'CallSid' => $params['CallSid'] ?? '',
)));
$call_data = array(
'CallSid' => isset($params['CallSid']) ? $params['CallSid'] : '',
@@ -371,20 +377,42 @@ class TWP_Webhooks {
if (isset($params['To']) && !empty($params['To'])) {
$to_number = $params['To'];
// Mobile SDK sends CallerId via extraOptions; browser sends From as phone number
// Mobile SDK sends From as identity (e.g. "agent2jknapp"), browser sends From as phone number
// Only use CallerId/From if it looks like a phone number (starts with + or is all digits)
$from_number = '';
if (!empty($params['CallerId']) && strpos($params['CallerId'], 'client:') !== 0) {
if (!empty($params['CallerId']) && preg_match('/^\+?\d+$/', $params['CallerId'])) {
$from_number = $params['CallerId'];
} elseif (!empty($params['From']) && strpos($params['From'], 'client:') !== 0) {
} elseif (!empty($params['From']) && preg_match('/^\+?\d+$/', $params['From'])) {
$from_number = $params['From'];
}
// Fall back to default caller ID if no valid one provided
if (empty($from_number)) {
$from_number = get_option('twp_caller_id_number', '');
}
if (empty($from_number)) {
$from_number = get_option('twp_default_sms_number', '');
}
// Last resort: fetch first Twilio number from API
if (empty($from_number)) {
try {
require_once plugin_dir_path(dirname(__FILE__)) . 'includes/class-twp-twilio-api.php';
$twilio = new TWP_Twilio_API();
$numbers = $twilio->get_phone_numbers();
if (!empty($numbers['data']['incoming_phone_numbers'][0]['phone_number'])) {
$from_number = $numbers['data']['incoming_phone_numbers'][0]['phone_number'];
}
} catch (\Exception $e) {
error_log('TWP browser-voice: failed to fetch default number: ' . $e->getMessage());
}
}
// If it's an outgoing call to a phone number
if (strpos($to_number, 'client:') !== 0) {
$twiml .= '<Dial timeout="30"';
// Add caller ID if provided
if (!empty($from_number) && strpos($from_number, 'client:') !== 0) {
// Add caller ID (required for outbound calls to phone numbers)
if (!empty($from_number)) {
$twiml .= ' callerId="' . htmlspecialchars($from_number) . '"';
}
@@ -403,6 +431,8 @@ class TWP_Webhooks {
$twiml .= '</Response>';
error_log('TWP browser-voice TwiML: ' . $twiml);
return $this->send_twiml_response($twiml);
}
@@ -914,13 +944,38 @@ class TWP_Webhooks {
// Update call status in queue if applicable
// Remove from queue for any terminal call state
if (in_array($status_data['CallStatus'], ['completed', 'busy', 'failed', 'canceled', 'no-answer'])) {
// Get queue_id before removing so we can send cancel notifications
global $wpdb;
$calls_table = $wpdb->prefix . 'twp_queued_calls';
$queued_call = $wpdb->get_row($wpdb->prepare(
"SELECT queue_id FROM $calls_table WHERE call_sid = %s",
$status_data['CallSid']
));
$queue_removed = TWP_Call_Queue::remove_from_queue($status_data['CallSid']);
if ($queue_removed) {
TWP_Call_Logger::log_action($status_data['CallSid'], 'Call removed from queue due to status: ' . $status_data['CallStatus']);
error_log('TWP Status Webhook: Removed call ' . $status_data['CallSid'] . ' from queue (status: ' . $status_data['CallStatus'] . ')');
// Cancel queue alert notifications on agents' devices
if ($queued_call) {
require_once plugin_dir_path(__FILE__) . 'class-twp-fcm.php';
$fcm = new TWP_FCM();
$fcm->cancel_queue_alert_for_queue($queued_call->queue_id, $status_data['CallSid']);
}
}
// Set auto_busy_at for agents whose call just ended, so they revert after 30s
$agent_status_table = $wpdb->prefix . 'twp_agent_status';
$wpdb->query($wpdb->prepare(
"UPDATE $agent_status_table
SET auto_busy_at = %s, current_call_sid = NULL
WHERE current_call_sid = %s AND status = 'busy'",
current_time('mysql'),
$status_data['CallSid']
));
}
// Empty response
return new WP_REST_Response('<?xml version="1.0" encoding="UTF-8"?><Response></Response>', 200, array(
'Content-Type' => 'text/xml; charset=utf-8'
@@ -1221,6 +1276,20 @@ class TWP_Webhooks {
if ($updated) {
error_log('TWP Queue Action: Updated call status to ' . $status);
// Cancel FCM queue alerts when call leaves the queue for any reason
if (in_array($status, array('answered', 'hangup', 'transferred', 'timeout', 'completed'))) {
$queued_call = $wpdb->get_row($wpdb->prepare(
"SELECT queue_id FROM $table_name WHERE call_sid = %s",
$call_sid
));
if ($queued_call) {
require_once plugin_dir_path(__FILE__) . 'class-twp-fcm.php';
$fcm = new TWP_FCM();
$fcm->cancel_queue_alert_for_queue($queued_call->queue_id, $call_sid);
error_log('TWP Queue Action: Sent FCM cancel for call ' . $call_sid);
}
}
} else {
error_log('TWP Queue Action: No call found to update with SID ' . $call_sid);
}

176
mobile/README.md Normal file
View File

@@ -0,0 +1,176 @@
# TWP Softphone — Mobile App
Flutter-based VoIP softphone client for the Twilio WordPress Plugin. Uses the Twilio Voice SDK (WebRTC) to make and receive calls via the Android Telecom framework.
## Requirements
- Flutter 3.29+ (tested with 3.41.4)
- Android device/tablet (API 26+)
- TWP WordPress plugin installed and configured on server
- Twilio account with Voice capability
## Quick Start
```bash
cd mobile
flutter pub get
flutter build apk --debug
adb install build/app/outputs/flutter-apk/app-debug.apk
```
## Server Setup
The app connects to your WordPress site running the TWP plugin. The server must have:
1. **TWP Plugin** installed and activated
2. **Twilio credentials** configured (Account SID, Auth Token)
3. **At least one Twilio phone number** purchased
4. **A WordPress user** with agent permissions
### SSE (Server-Sent Events) — Apache + PHP-FPM
The app uses SSE for real-time updates (queue changes, agent status). On Apache with PHP-FPM, `mod_proxy_fcgi` buffers output by default, which breaks SSE streaming.
**Fix** — Create a config file on the web server:
```bash
echo 'ProxyPassMatch "^/wp-json/twilio-mobile/v1/stream/events$" "unix:/run/php-fpm/www.sock|fcgi://localhost/path/to/wordpress/index.php" flushpackets=on' > /etc/httpd/conf.d/twp-sse.conf
httpd -t && systemctl restart httpd
```
> **Adjust the paths:**
> - Socket path must match your PHP-FPM config (check `grep fcgi /etc/httpd/conf.d/php.conf`)
> - Document root must match your WordPress installation path
**Diagnosis** — If the green connection dot stays red:
```bash
# Check current PHP-FPM proxy config
grep -r "fcgi\|php-fpm" /etc/httpd/conf.d/
# Check if flushpackets is configured
grep -r "flushpackets" /etc/httpd/conf.d/
# Test SSE endpoint (should stream data continuously, not hang)
curl -N -H "Authorization: Bearer YOUR_TOKEN" \
https://your-site.com/wp-json/twilio-mobile/v1/stream/events
```
**Notes:**
- `flushpackets=on` is a `ProxyPassMatch` directive — it **cannot** go in `.htaccess`
- If using **nginx** instead of Apache, the `X-Accel-Buffering: no` header (already in the PHP code) handles this automatically
- The app automatically falls back to 5-second polling if SSE fails, so the app still works without this config — just with higher latency
## App Setup (Android)
### First Launch
1. Open the app and enter your server URL (e.g., `https://phone.cloud-hosting.io`)
2. Log in with your WordPress credentials
3. Grant permissions when prompted:
- Microphone (required for calls)
- Phone/Call (required for Android Telecom integration)
### Phone Account
Android requires a registered and **enabled** phone account for VoIP apps. The app registers automatically, but enabling must be done manually:
1. If prompted, tap **"Open Settings"** to go to Android's Phone Account settings
2. Find **"TWP Softphone"** in the list and toggle it **ON**
3. Return to the app
If you skipped this step, tap the orange warning card on the dashboard.
> **Path:** Settings → Apps → Default apps → Phone → Calling accounts → TWP Softphone
### Making Calls
1. Tap the phone FAB (bottom right) to open the dialer
2. Enter the phone number
3. Caller ID is auto-selected from your Twilio numbers
4. Tap **Call** — the Android system call screen (InCallUI) handles the active call
### Receiving Calls
Incoming calls appear via Android's native call UI. Answer/reject using the standard Android interface.
> **Note:** FCM push notifications are required for receiving calls when the app is in the background. This requires `google-services.json` in `android/app/`.
### Queue Management
- View assigned queues on the dashboard
- Tap a queue with waiting calls to see callers
- Tap **Accept** to take a call from the queue
### Agent Status
Toggle between **Available**, **Busy**, and **Offline** using the status bar at the top of the dashboard.
## Development
### Project Structure
```
lib/
├── config/ # App configuration
├── models/ # Data models (CallInfo, QueueState, AgentStatus, User)
├── providers/ # State management (AuthProvider, CallProvider, AgentProvider)
├── screens/ # UI screens (Login, Dashboard, Settings, ActiveCall)
├── services/ # API/SDK services (VoiceService, SseService, ApiClient, AuthService)
├── widgets/ # Reusable widgets (Dialpad, QueueCard, AgentStatusToggle)
└── main.dart # App entry point
```
### Running Tests
```bash
flutter test
```
34 tests covering CallInfo, QueueState, and CallProvider.
### Building
```bash
# Debug APK
flutter build apk --debug
# Release APK (requires signing config)
flutter build apk --release
```
### ADB Deployment (WiFi)
```bash
# Connect to device
adb connect DEVICE_IP:PORT
# Install
adb install -r build/app/outputs/flutter-apk/app-debug.apk
# Launch
adb shell am start -n io.cloudhosting.twp.twp_softphone/.MainActivity
# View logs
adb logcat -s flutter
```
### Key Dependencies
| Package | Purpose |
|---------|---------|
| `twilio_voice` | Twilio Voice SDK (WebRTC calling) |
| `provider` | State management |
| `dio` | HTTP client (REST API, SSE) |
| `firebase_messaging` | FCM push for incoming calls |
| `flutter_secure_storage` | Secure token storage |
## Troubleshooting
| Problem | Solution |
|---------|----------|
| Green dot stays red | SSE buffering — see [Server Setup](#sse-server-sent-events--apache--php-fpm) |
| "No registered phone account" | Enable phone account in Android Settings (see [Phone Account](#phone-account)) |
| Calls fail with "Invalid callerId" | Server webhook needs phone number validation — check `handle_browser_voice` in `class-twp-webhooks.php` |
| App hangs on login | Check server is reachable: `curl https://your-site.com/wp-json/twilio-mobile/v1/auth/login` |
| No incoming calls | Ensure FCM is configured (`google-services.json`) and phone account is enabled |

View File

@@ -7,3 +7,8 @@
# Flutter
-keep class io.flutter.** { *; }
# Play Core (not used but referenced by Flutter engine)
-dontwarn com.google.android.play.core.splitcompat.SplitCompatApplication
-dontwarn com.google.android.play.core.splitinstall.**
-dontwarn com.google.android.play.core.tasks.**

View File

@@ -1,19 +1,17 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- Audio/Phone permissions for VoIP -->
<!-- Audio permissions for WebRTC -->
<uses-permission android:name="android.permission.RECORD_AUDIO"/>
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS"/>
<uses-permission android:name="android.permission.BLUETOOTH"/>
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT"/>
<!-- Foreground service for active calls -->
<!-- Foreground service -->
<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"/>
@@ -23,7 +21,7 @@
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/>
<application
android:label="TWP Softphone"
android:label="Twilio-WP"
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher">
<activity
@@ -51,6 +49,7 @@
<category android:name="android.intent.category.DEFAULT"/>
</intent-filter>
</activity>
<meta-data
android:name="flutterEmbedding"
android:value="2" />

View File

@@ -41,9 +41,9 @@ public final class GeneratedPluginRegistrant {
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());
flutterEngine.getPlugins().add(new io.flutter.plugins.webviewflutter.WebViewFlutterPlugin());
} catch (Exception e) {
Log.e(TAG, "Error registering plugin twilio_voice, com.twilio.twilio_voice.TwilioVoicePlugin", e);
Log.e(TAG, "Error registering plugin webview_flutter_android, io.flutter.plugins.webviewflutter.WebViewFlutterPlugin", e);
}
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 415 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 341 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 494 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 619 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 811 B

Binary file not shown.

View File

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

View File

@@ -1,11 +1,7 @@
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 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'screens/login_screen.dart';
import 'screens/dashboard_screen.dart';
import 'screens/phone_screen.dart';
class TwpSoftphoneApp extends StatefulWidget {
const TwpSoftphoneApp({super.key});
@@ -15,17 +11,54 @@ class TwpSoftphoneApp extends StatefulWidget {
}
class _TwpSoftphoneAppState extends State<TwpSoftphoneApp> {
final _apiClient = ApiClient();
static const _storage = FlutterSecureStorage();
String? _serverUrl;
bool _loading = true;
@override
void initState() {
super.initState();
_checkSavedSession();
}
Future<void> _checkSavedSession() async {
final url = await _storage.read(key: 'server_url');
if (mounted) {
setState(() {
_serverUrl = url;
_loading = false;
});
}
}
void _onLoginSuccess(String serverUrl) {
setState(() {
_serverUrl = serverUrl;
});
}
void _onLogout() async {
await _storage.delete(key: 'server_url');
if (mounted) {
setState(() {
_serverUrl = null;
});
}
}
void _onSessionExpired() {
// Server URL is still saved, but session cookie is gone.
// Show login screen but keep the server URL pre-filled.
if (mounted) {
setState(() {
_serverUrl = null;
});
}
}
@override
Widget build(BuildContext context) {
return ChangeNotifierProvider(
create: (_) {
final auth = AuthProvider(_apiClient);
auth.tryRestoreSession();
return auth;
},
child: MaterialApp(
return MaterialApp(
title: 'TWP Softphone',
debugShowCheckedModeBanner: false,
theme: ThemeData(
@@ -38,28 +71,17 @@ class _TwpSoftphoneAppState extends State<TwpSoftphoneApp> {
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();
},
),
),
home: _loading
? const Scaffold(
body: Center(child: CircularProgressIndicator()),
)
: _serverUrl != null
? PhoneScreen(
serverUrl: _serverUrl!,
onLogout: _onLogout,
onSessionExpired: _onSessionExpired,
)
: LoginScreen(onLoginSuccess: _onLoginSuccess),
);
}
}

View File

@@ -1,8 +0,0 @@
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';
}

View File

@@ -1,38 +0,0 @@
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

@@ -1,46 +0,0 @@
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

@@ -1,66 +0,0 @@
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

@@ -1,28 +0,0 @@
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

@@ -1,113 +0,0 @@
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

@@ -1,113 +0,0 @@
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

@@ -1,143 +0,0 @@
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

@@ -1,137 +0,0 @@
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

@@ -1,210 +0,0 @@
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

@@ -1,22 +1,29 @@
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';
import 'package:webview_flutter/webview_flutter.dart';
/// Login screen that loads wp-login.php in a WebView.
///
/// When the user successfully logs in, WordPress redirects to /twp-phone/.
/// We detect that URL change and report login success to the parent.
class LoginScreen extends StatefulWidget {
const LoginScreen({super.key});
final void Function(String serverUrl) onLoginSuccess;
const LoginScreen({super.key, required this.onLoginSuccess});
@override
State<LoginScreen> createState() => _LoginScreenState();
}
class _LoginScreenState extends State<LoginScreen> {
static const _storage = FlutterSecureStorage();
final _formKey = GlobalKey<FormState>();
final _serverController = TextEditingController();
final _usernameController = TextEditingController();
final _passwordController = TextEditingController();
bool _obscurePassword = true;
bool _showWebView = false;
bool _webViewLoading = true;
String? _error;
late WebViewController _webViewController;
@override
void initState() {
@@ -25,39 +32,106 @@ class _LoginScreenState extends State<LoginScreen> {
}
Future<void> _loadSavedServer() async {
const storage = FlutterSecureStorage();
final saved = await storage.read(key: 'server_url');
final saved = await _storage.read(key: 'server_url');
if (saved != null && mounted) {
_serverController.text = saved;
}
}
void _submit() {
void _startLogin() {
if (!_formKey.currentState!.validate()) return;
var serverUrl = _serverController.text.trim();
if (!serverUrl.startsWith('http')) {
serverUrl = 'https://$serverUrl';
}
// Remove trailing slash
serverUrl = serverUrl.replaceAll(RegExp(r'/+$'), '');
TextInput.finishAutofillContext();
context.read<AuthProvider>().login(
serverUrl,
_usernameController.text.trim(),
_passwordController.text,
);
setState(() {
_showWebView = true;
_webViewLoading = true;
_error = null;
});
final loginUrl =
'$serverUrl/wp-login.php?redirect_to=${Uri.encodeComponent('$serverUrl/twp-phone/')}';
_webViewController = WebViewController()
..setJavaScriptMode(JavaScriptMode.unrestricted)
..setNavigationDelegate(
NavigationDelegate(
onPageStarted: (url) {
// Check if we've been redirected to the phone page (login success)
if (url.contains('/twp-phone/') || url.endsWith('/twp-phone')) {
_onLoginComplete(serverUrl);
}
},
onPageFinished: (url) {
if (mounted) {
setState(() => _webViewLoading = false);
}
// Also check on page finish in case redirect was instant
if (url.contains('/twp-phone/') || url.endsWith('/twp-phone')) {
_onLoginComplete(serverUrl);
}
},
onWebResourceError: (error) {
if (mounted) {
setState(() {
_showWebView = false;
_error =
'Could not connect to server: ${error.description}';
});
}
},
),
)
..setUserAgent('TWPMobile/2.0 (Android; WebView)')
..loadRequest(Uri.parse(loginUrl));
}
Future<void> _onLoginComplete(String serverUrl) async {
// Save server URL for next launch
await _storage.write(key: 'server_url', value: serverUrl);
if (mounted) {
widget.onLoginSuccess(serverUrl);
}
}
void _cancelLogin() {
setState(() {
_showWebView = false;
_error = null;
});
}
@override
Widget build(BuildContext context) {
final auth = context.watch<AuthProvider>();
if (_showWebView) {
return Scaffold(
appBar: AppBar(
leading: IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: _cancelLogin,
),
title: const Text('Sign In'),
),
body: Stack(
children: [
WebViewWidget(controller: _webViewController),
if (_webViewLoading)
const Center(child: CircularProgressIndicator()),
],
),
);
}
return Scaffold(
body: SafeArea(
child: Center(
child: SingleChildScrollView(
padding: const EdgeInsets.all(24),
child: AutofillGroup(
child: Form(
key: _formKey,
child: Column(
@@ -87,42 +161,10 @@ class _LoginScreenState extends State<LoginScreen> {
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) ...[
if (_error != null) ...[
const SizedBox(height: 16),
Text(
auth.error!,
_error!,
style: TextStyle(
color: Theme.of(context).colorScheme.error),
),
@@ -132,17 +174,8 @@ class _LoginScreenState extends State<LoginScreen> {
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'),
onPressed: _startLogin,
child: const Text('Connect'),
),
),
],
@@ -151,15 +184,12 @@ class _LoginScreenState extends State<LoginScreen> {
),
),
),
),
);
}
@override
void dispose() {
_serverController.dispose();
_usernameController.dispose();
_passwordController.dispose();
super.dispose();
}
}

View File

@@ -0,0 +1,332 @@
import 'package:flutter/material.dart';
import 'package:permission_handler/permission_handler.dart';
import 'package:webview_flutter/webview_flutter.dart';
import 'package:webview_flutter_android/webview_flutter_android.dart';
import '../services/push_notification_service.dart';
/// Full-screen WebView that loads the TWP phone page.
///
/// Handles:
/// - Microphone permission grants for WebRTC
/// - JavaScript bridge (TwpMobile channel) for native communication
/// - Session expiry detection (redirect to wp-login.php)
/// - Back button confirmation to prevent accidental exit
/// - Network error retry UI
class PhoneScreen extends StatefulWidget {
final String serverUrl;
final VoidCallback onLogout;
final VoidCallback onSessionExpired;
const PhoneScreen({
super.key,
required this.serverUrl,
required this.onLogout,
required this.onSessionExpired,
});
@override
State<PhoneScreen> createState() => _PhoneScreenState();
}
class _PhoneScreenState extends State<PhoneScreen> with WidgetsBindingObserver {
late final WebViewController _controller;
late final PushNotificationService _pushService;
bool _loading = true;
bool _hasError = false;
String? _errorMessage;
bool _sessionExpired = false;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
_pushService = PushNotificationService();
_requestPermissionsAndInit();
}
Future<void> _requestPermissionsAndInit() async {
// Request microphone permission before initializing WebView
final micStatus = await Permission.microphone.request();
if (micStatus.isDenied || micStatus.isPermanentlyDenied) {
debugPrint('TWP: Microphone permission denied: $micStatus');
}
_initWebView();
_initPush();
}
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
super.dispose();
}
Future<void> _initPush() async {
await _pushService.initialize();
}
void _initWebView() {
_controller = WebViewController()
..setJavaScriptMode(JavaScriptMode.unrestricted)
..setUserAgent('TWPMobile/2.0 (Android; WebView)')
..setNavigationDelegate(
NavigationDelegate(
onPageStarted: (url) {
if (mounted) {
setState(() {
_loading = true;
_hasError = false;
});
}
// Detect session expiry: if we get redirected to wp-login.php
if (url.contains('/wp-login.php')) {
_sessionExpired = true;
}
},
onPageFinished: (url) {
if (mounted) {
setState(() => _loading = false);
}
if (_sessionExpired && url.contains('/wp-login.php')) {
widget.onSessionExpired();
return;
}
_sessionExpired = false;
// Inject the FCM token into the page if available
_injectFcmToken();
},
onWebResourceError: (error) {
// Only handle main frame errors
if (error.isForMainFrame ?? true) {
if (mounted) {
setState(() {
_loading = false;
_hasError = true;
_errorMessage = error.description;
});
}
}
},
onNavigationRequest: (request) {
// Allow all navigation within our server
if (request.url.startsWith(widget.serverUrl)) {
return NavigationDecision.navigate;
}
// Allow blob: and data: URLs (for downloads, etc.)
if (request.url.startsWith('blob:') ||
request.url.startsWith('data:')) {
return NavigationDecision.navigate;
}
// Block external navigation
return NavigationDecision.prevent;
},
),
)
..addJavaScriptChannel(
'TwpMobile',
onMessageReceived: _handleJsMessage,
);
// Configure Android-specific settings
final androidController =
_controller.platform as AndroidWebViewController;
// Auto-grant microphone permission for WebRTC calls
androidController.setOnPlatformPermissionRequest(
(PlatformWebViewPermissionRequest request) {
request.grant();
},
);
// Allow media playback without user gesture (for ringtones)
androidController.setMediaPlaybackRequiresUserGesture(false);
// Load the phone page
final phoneUrl = '${widget.serverUrl}/twp-phone/';
_controller.loadRequest(Uri.parse(phoneUrl));
}
void _handleJsMessage(JavaScriptMessage message) {
final msg = message.message;
if (msg == 'onSessionExpired') {
widget.onSessionExpired();
} else if (msg == 'requestFcmToken') {
_injectFcmToken();
} else if (msg == 'onPageReady') {
// Phone page loaded successfully
_injectFcmToken();
}
}
Future<void> _injectFcmToken() async {
final token = _pushService.fcmToken;
if (token != null) {
// Send the FCM token to the web page via the TwpMobile bridge
await _controller.runJavaScript(
'if (window.TwpMobile && window.TwpMobile.setFcmToken) { window.TwpMobile.setFcmToken("$token"); }',
);
}
}
Future<void> _retry() async {
setState(() {
_hasError = false;
_loading = true;
});
final phoneUrl = '${widget.serverUrl}/twp-phone/';
await _controller.loadRequest(Uri.parse(phoneUrl));
}
Future<bool> _onWillPop() async {
// Check if WebView can go back
if (await _controller.canGoBack()) {
await _controller.goBack();
return false;
}
// Show confirmation dialog
if (!mounted) return true;
final result = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Exit'),
content: const Text('Are you sure you want to exit the phone?'),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: const Text('Cancel'),
),
TextButton(
onPressed: () => Navigator.of(context).pop(true),
child: const Text('Exit'),
),
],
),
);
return result ?? false;
}
void _confirmLogout() async {
final result = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Logout'),
content: const Text(
'This will clear your session. You will need to sign in again.'),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: const Text('Cancel'),
),
TextButton(
onPressed: () => Navigator.of(context).pop(true),
child: const Text('Logout'),
),
],
),
);
if (result == true) {
// Clear WebView cookies
await WebViewCookieManager().clearCookies();
widget.onLogout();
}
}
@override
Widget build(BuildContext context) {
// ignore: deprecated_member_use
return WillPopScope(
onWillPop: _onWillPop,
child: Scaffold(
appBar: _hasError
? null
: AppBar(
toolbarHeight: 40,
titleSpacing: 12,
title: Text(
'TWP Softphone',
style: Theme.of(context).textTheme.titleSmall,
),
actions: [
IconButton(
icon: const Icon(Icons.refresh, size: 20),
tooltip: 'Reload',
visualDensity: VisualDensity.compact,
onPressed: () => _controller.reload(),
),
PopupMenuButton<String>(
icon: const Icon(Icons.more_vert, size: 20),
tooltip: 'Menu',
padding: EdgeInsets.zero,
onSelected: (value) {
if (value == 'logout') _confirmLogout();
},
itemBuilder: (context) => [
const PopupMenuItem(
value: 'logout',
child: ListTile(
leading: Icon(Icons.logout),
title: Text('Logout'),
dense: true,
contentPadding: EdgeInsets.zero,
),
),
],
),
],
),
body: SafeArea(
top: _hasError, // AppBar already handles top safe area when visible
child: Stack(
children: [
if (!_hasError) WebViewWidget(controller: _controller),
if (_hasError) _buildErrorView(),
if (_loading && !_hasError)
const Center(child: CircularProgressIndicator()),
],
),
),
),
);
}
Widget _buildErrorView() {
final colorScheme = Theme.of(context).colorScheme;
return Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.wifi_off, size: 64, color: colorScheme.onSurfaceVariant),
const SizedBox(height: 16),
Text(
'Connection Error',
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
color: colorScheme.onSurface,
),
),
const SizedBox(height: 8),
Text(
_errorMessage ?? 'Could not load the phone page.',
textAlign: TextAlign.center,
style: TextStyle(color: colorScheme.onSurfaceVariant),
),
const SizedBox(height: 24),
FilledButton.icon(
onPressed: _retry,
icon: const Icon(Icons.refresh),
label: const Text('Retry'),
),
const SizedBox(height: 12),
TextButton(
onPressed: widget.onLogout,
child: const Text('Change Server'),
),
],
),
),
);
}
}

View File

@@ -1,68 +0,0 @@
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

@@ -1,85 +0,0 @@
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

@@ -1,95 +0,0 @@
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

@@ -1,22 +1,72 @@
import 'dart:typed_data';
import 'package:firebase_core/firebase_core.dart';
import 'package:firebase_messaging/firebase_messaging.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import 'api_client.dart';
/// Notification ID for queue alerts (fixed so we can cancel it).
const int _queueAlertNotificationId = 9001;
/// Background handler -- must be top-level function.
@pragma('vm:entry-point')
Future<void> _firebaseMessagingBackgroundHandler(RemoteMessage message) async {
await Firebase.initializeApp();
// VoIP pushes are handled natively by twilio_voice plugin.
// Other data messages can show a local notification if needed.
final data = message.data;
final type = data['type'];
if (type == 'queue_alert') {
await _showQueueAlertNotification(data);
} else if (type == 'queue_alert_cancel') {
final plugin = FlutterLocalNotificationsPlugin();
await plugin.cancel(_queueAlertNotificationId);
}
}
/// Show an insistent queue alert notification (works from background handler too).
Future<void> _showQueueAlertNotification(Map<String, dynamic> data) async {
final plugin = FlutterLocalNotificationsPlugin();
final title = data['title'] ?? 'Call Waiting';
final body = data['body'] ?? 'New call in queue';
final androidDetails = AndroidNotificationDetails(
'twp_queue_alerts',
'Queue Alerts',
channelDescription: 'Alerts when calls are waiting in queue',
importance: Importance.max,
priority: Priority.max,
playSound: true,
sound: const RawResourceAndroidNotificationSound('queue_alert'),
enableVibration: true,
vibrationPattern: Int64List.fromList([0, 500, 200, 500, 200, 500]),
ongoing: true,
autoCancel: false,
category: AndroidNotificationCategory.alarm,
additionalFlags: Int32List.fromList([4]), // FLAG_INSISTENT = 4
fullScreenIntent: true,
visibility: NotificationVisibility.public,
);
await plugin.show(
_queueAlertNotificationId,
title,
body,
NotificationDetails(android: androidDetails),
);
}
/// Push notification service for queue alerts and general notifications.
///
/// FCM token registration is handled via the WebView JavaScript bridge
/// instead of a REST API call. The token is exposed via [fcmToken] and
/// injected into the web page by [PhoneScreen].
class PushNotificationService {
final ApiClient _api;
final FirebaseMessaging _messaging = FirebaseMessaging.instance;
final FlutterLocalNotificationsPlugin _localNotifications =
FlutterLocalNotificationsPlugin();
String? _fcmToken;
PushNotificationService(this._api);
String? get fcmToken => _fcmToken;
Future<void> initialize() async {
FirebaseMessaging.onBackgroundMessage(_firebaseMessagingBackgroundHandler);
@@ -34,33 +84,43 @@ class PushNotificationService {
const initSettings = InitializationSettings(android: androidSettings);
await _localNotifications.initialize(initSettings);
// Get and register FCM token
// Get FCM token
final token = await _messaging.getToken();
debugPrint(
'FCM token: ${token != null ? "${token.substring(0, 20)}..." : "NULL"}');
if (token != null) {
await _registerToken(token);
_fcmToken = token;
} else {
debugPrint(
'FCM: Failed to get token - Firebase may not be configured correctly');
}
// Listen for token refresh
_messaging.onTokenRefresh.listen(_registerToken);
_messaging.onTokenRefresh.listen((token) {
_fcmToken = token;
});
// Handle foreground messages (non-VoIP)
// Handle foreground messages
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;
// Queue alert -- show insistent notification
if (type == 'queue_alert') {
_showQueueAlertNotification(data);
return;
}
// Show local notification for other types (missed call, queue alert, etc.)
// Queue alert cancel -- dismiss notification
if (type == 'queue_alert_cancel') {
_localNotifications.cancel(_queueAlertNotificationId);
return;
}
// Show local notification for other types (missed call, etc.)
_localNotifications.show(
message.hashCode,
data['title'] ?? 'TWP Softphone',
@@ -75,4 +135,9 @@ class PushNotificationService {
),
);
}
/// Cancel any active queue alert.
void cancelQueueAlert() {
_localNotifications.cancel(_queueAlertNotificationId);
}
}

View File

@@ -1,119 +0,0 @@
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

@@ -1,103 +0,0 @@
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

@@ -1,51 +0,0 @@
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

@@ -1,118 +0,0 @@
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

@@ -1,55 +0,0 @@
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

@@ -1,37 +0,0 @@
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,
),
);
}
}

View File

@@ -1,14 +1,6 @@
# 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:
@@ -17,14 +9,6 @@ packages:
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:
@@ -37,138 +21,42 @@ packages:
dependency: transitive
description:
name: async
sha256: "758e6d74e971c3e5aceb4110bfd6698efc7f501675bcfe0c775459a8140750eb"
sha256: "947bfcf187f74dbc5e146c9eb9c0f10c9f8b30743e341481c1e2ed3ecc18c20c"
url: "https://pub.dev"
source: hosted
version: "2.13.0"
version: "2.11.0"
boolean_selector:
dependency: transitive
description:
name: boolean_selector
sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea"
sha256: "6cfb5af12253eaf2b368f07bacc5a80d1301a071c73360d746b7f2e32d762c66"
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"
version: "2.1.1"
characters:
dependency: transitive
description:
name: characters
sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b
sha256: "04a925763edad70e8443c99234dc3328f442e811f1d8fd1a72f1c8ad0f69a605"
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"
version: "1.3.0"
clock:
dependency: transitive
description:
name: clock
sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b
sha256: cb6d7f03e1de671e34607e909a7213e31d7752be4fb66a86d29fe1eb14bfb5cf
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"
version: "1.1.1"
collection:
dependency: transitive
description:
name: collection
sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76"
sha256: a1ace0a119f20aabc852d165077c036cd864315bd99b7eaa10a60100341941bf
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"
version: "1.19.0"
dbus:
dependency: transitive
description:
@@ -177,46 +65,22 @@ packages:
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"
sha256: "511392330127add0b769b75a987850d136345d9227c6b94c96a04cf4a391bf78"
url: "https://pub.dev"
source: hosted
version: "1.3.3"
version: "1.3.1"
ffi:
dependency: transitive
description:
name: ffi
sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45"
sha256: "16ed7b077ef01ad6170a3d0c57caa4a112a38d7a2ed5602e0aca9ca6f3d98da6"
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"
version: "2.1.3"
firebase_core:
dependency: "direct main"
description:
@@ -265,14 +129,6 @@ packages:
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
@@ -368,110 +224,30 @@ packages:
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"
sha256: "7bb2830ebd849694d1ec25bf1f44582d6ac531a57a365a803a6034ff751d2d06"
url: "https://pub.dev"
source: hosted
version: "11.0.2"
version: "10.0.7"
leak_tracker_flutter_testing:
dependency: transitive
description:
name: leak_tracker_flutter_testing
sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1"
sha256: "9491a714cca3667b60b5c420da8217e6de0d1ba7a5ec322fab01758f6998f379"
url: "https://pub.dev"
source: hosted
version: "3.0.10"
version: "3.0.8"
leak_tracker_testing:
dependency: transitive
description:
name: leak_tracker_testing
sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1"
sha256: "6ba465d5d76e67ddf503e1161d1f4a6bc42306f9d66ca1e8f079a47290fb06d3"
url: "https://pub.dev"
source: hosted
version: "3.0.2"
version: "3.0.1"
lints:
dependency: transitive
description:
@@ -480,86 +256,38 @@ packages:
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
sha256: d2323aa2060500f906aa31a895b4030b6da3ebdcc5619d14ce1aada65cd161cb
url: "https://pub.dev"
source: hosted
version: "0.12.19"
version: "0.12.16+1"
material_color_utilities:
dependency: transitive
description:
name: material_color_utilities
sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b"
sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec
url: "https://pub.dev"
source: hosted
version: "0.13.0"
version: "0.11.1"
meta:
dependency: transitive
description:
name: meta
sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394"
sha256: bdb68674043280c3428e9ec998512fb681678676b3c54e773629ffe74419f8c7
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"
version: "1.15.0"
path:
dependency: transitive
description:
name: path
sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5"
sha256: "087ce49c3f0dc39180befefc60fdb4acd8f8620e5682fe2476afd0b3688bb4af"
url: "https://pub.dev"
source: hosted
version: "1.9.1"
version: "1.9.0"
path_provider:
dependency: transitive
description:
@@ -572,18 +300,18 @@ packages:
dependency: transitive
description:
name: path_provider_android
sha256: f2c65e21139ce2c3dad46922be8272bb5963516045659e71bb16e151c93b580e
sha256: d0d310befe2c8ab9e7f393288ccbb11b60c019c6b5afc21973eeee4dda2b35e9
url: "https://pub.dev"
source: hosted
version: "2.2.22"
version: "2.2.17"
path_provider_foundation:
dependency: transitive
description:
name: path_provider_foundation
sha256: "2a376b7d6392d80cd3705782d2caa734ca4727776db0b6ec36ef3f1855197699"
sha256: "4843174df4d288f5e29185bd6e72a6fbdf5a4a4602717eed565497429f179942"
url: "https://pub.dev"
source: hosted
version: "2.6.0"
version: "2.4.1"
path_provider_linux:
dependency: transitive
description:
@@ -608,14 +336,62 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.3.0"
permission_handler:
dependency: "direct main"
description:
name: permission_handler
sha256: "59adad729136f01ea9e35a48f5d1395e25cba6cea552249ddbe9cf950f5d7849"
url: "https://pub.dev"
source: hosted
version: "11.4.0"
permission_handler_android:
dependency: transitive
description:
name: permission_handler_android
sha256: d3971dcdd76182a0c198c096b5db2f0884b0d4196723d21a866fc4cdea057ebc
url: "https://pub.dev"
source: hosted
version: "12.1.0"
permission_handler_apple:
dependency: transitive
description:
name: permission_handler_apple
sha256: f000131e755c54cf4d84a5d8bd6e4149e262cc31c5a8b1d698de1ac85fa41023
url: "https://pub.dev"
source: hosted
version: "9.4.7"
permission_handler_html:
dependency: transitive
description:
name: permission_handler_html
sha256: "38f000e83355abb3392140f6bc3030660cfaef189e1f87824facb76300b4ff24"
url: "https://pub.dev"
source: hosted
version: "0.1.3+5"
permission_handler_platform_interface:
dependency: transitive
description:
name: permission_handler_platform_interface
sha256: eb99b295153abce5d683cac8c02e22faab63e50679b937fa1bf67d58bb282878
url: "https://pub.dev"
source: hosted
version: "4.3.0"
permission_handler_windows:
dependency: transitive
description:
name: permission_handler_windows
sha256: "1a790728016f79a41216d88672dbc5df30e686e811ad4e698bfc51f76ad91f1e"
url: "https://pub.dev"
source: hosted
version: "0.2.1"
petitparser:
dependency: transitive
description:
name: petitparser
sha256: "91bd59303e9f769f108f8df05e371341b15d59e995e6806aefab827b58336675"
sha256: c15605cd28af66339f8eb6fbe0e541bfe2d1b72d5825efc6598f3e0a31b9ad27
url: "https://pub.dev"
source: hosted
version: "7.0.2"
version: "6.0.2"
platform:
dependency: transitive
description:
@@ -632,139 +408,59 @@ packages:
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"
sha256: "53e943d4206a5e30df338fd4c6e7a077e02254531b138a15aec3bd143c1a8b3c"
url: "https://pub.dev"
source: hosted
version: "1.10.2"
version: "1.10.0"
stack_trace:
dependency: transitive
description:
name: stack_trace
sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1"
sha256: "9f47fd3630d76be3ab26f0ee06d213679aa425996925ff3feffdec504931c377"
url: "https://pub.dev"
source: hosted
version: "1.12.1"
version: "1.12.0"
stream_channel:
dependency: transitive
description:
name: stream_channel
sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d"
sha256: ba2aa5d8cc609d96bbb2899c28934f9e1af5cddbd60a827822ea467161eb54e7
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"
version: "2.1.2"
string_scanner:
dependency: transitive
description:
name: string_scanner
sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43"
sha256: "688af5ed3402a4bde5b3a6c15fd768dbf2621a614950b17f04626c431ab3c4c3"
url: "https://pub.dev"
source: hosted
version: "1.4.1"
version: "1.3.0"
term_glyph:
dependency: transitive
description:
name: term_glyph
sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e"
sha256: a29248a84fbb7c79282b40b8c72a1209db169a2e0542bce341da992fe1bc7e84
url: "https://pub.dev"
source: hosted
version: "1.2.2"
version: "1.2.1"
test_api:
dependency: transitive
description:
name: test_api
sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a"
sha256: "664d3a9a64782fcdeb83ce9c6b39e78fd2971d4e37827b9b06c3aa1edc5e760c"
url: "https://pub.dev"
source: hosted
version: "0.7.10"
version: "0.7.3"
timezone:
dependency: transitive
description:
@@ -773,54 +469,22 @@ packages:
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
sha256: "80b3257d1492ce4d091729e3a67a60407d227c27241d6927be0130c98e741803"
url: "https://pub.dev"
source: hosted
version: "2.2.0"
version: "2.1.4"
vm_service:
dependency: transitive
description:
name: vm_service
sha256: "45caa6c5917fa127b5dbcfbd1fa60b14e583afdc08bfc96dda38886ca252eb60"
sha256: f6be3ed8bd01289b34d679c2b62226f63c0e69f9fd2e50a6b3c1c729a961041b
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"
version: "14.3.0"
web:
dependency: transitive
description:
@@ -829,38 +493,46 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.1.1"
web_callkit:
dependency: transitive
webview_flutter:
dependency: "direct main"
description:
name: web_callkit
sha256: ca05b0fd79366ea072c1ea4982c8a7880ad219e4d1cc74a3a541b010533febee
name: webview_flutter
sha256: c3e4fe614b1c814950ad07186007eff2f2e5dd2935eba7b9a9a1af8e5885f1ba
url: "https://pub.dev"
source: hosted
version: "0.0.4+1"
web_socket:
dependency: transitive
version: "4.13.0"
webview_flutter_android:
dependency: "direct main"
description:
name: web_socket
sha256: "34d64019aa8e36bf9842ac014bb5d2f5586ca73df5e4d9bf5c936975cae6982c"
name: webview_flutter_android
sha256: "0a42444056b24ed832bdf3442d65c5194f6416f7e782152384944053c2ecc9a3"
url: "https://pub.dev"
source: hosted
version: "1.0.1"
web_socket_channel:
version: "4.10.0"
webview_flutter_platform_interface:
dependency: transitive
description:
name: web_socket_channel
sha256: d645757fb0f4773d602444000a8131ff5d48c9e47adfe9772652dd1a4f2d45c8
name: webview_flutter_platform_interface
sha256: "63d26ee3aca7256a83ccb576a50272edd7cfc80573a4305caa98985feb493ee0"
url: "https://pub.dev"
source: hosted
version: "3.0.3"
version: "2.14.0"
webview_flutter_wkwebview:
dependency: transitive
description:
name: webview_flutter_wkwebview
sha256: fb46db8216131a3e55bcf44040ca808423539bc6732e7ed34fb6d8044e3d512f
url: "https://pub.dev"
source: hosted
version: "3.23.0"
win32:
dependency: transitive
description:
name: win32
sha256: d7cb55e04cd34096cd3a79b3330245f54cb96a370a1c27adb3c84b917de8b08e
sha256: daf97c9d80197ed7b619040e86c8ab9a9dad285e7671ee7390f9180cc828a51e
url: "https://pub.dev"
source: hosted
version: "5.15.0"
version: "5.10.1"
xdg_directories:
dependency: transitive
description:
@@ -873,18 +545,10 @@ packages:
dependency: transitive
description:
name: xml
sha256: "971043b3a0d3da28727e40ed3e0b5d18b742fa5a68665cca88e74b7876d5e025"
sha256: b015a8ad1c488f66851d762d3090a21c600e479dc75e68328c52774040cf9226
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"
version: "6.5.0"
sdks:
dart: ">=3.10.3 <4.0.0"
flutter: ">=3.38.4"
dart: ">=3.6.0 <4.0.0"
flutter: ">=3.27.0"

View File

@@ -1,7 +1,7 @@
name: twp_softphone
description: TWP Softphone - VoIP client for Twilio WordPress Plugin
description: TWP Softphone - WebView client for Twilio WordPress Plugin
publish_to: 'none'
version: 1.0.0+1
version: 2.0.1+7
environment:
sdk: ^3.5.0
@@ -9,21 +9,18 @@ environment:
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
webview_flutter: ^4.10.0
webview_flutter_android: ^4.3.0
permission_handler: ^11.3.0
dev_dependencies:
flutter_test:
sdk: flutter
flutter_lints: ^4.0.0
build_runner: ^2.4.0
json_serializable: ^6.7.0
flutter:
uses-material-design: true

View File

@@ -0,0 +1,129 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:twp_softphone/models/call_info.dart';
void main() {
group('CallInfo', () {
test('default state is idle', () {
const info = CallInfo();
expect(info.state, CallState.idle);
expect(info.callSid, isNull);
expect(info.callerNumber, isNull);
expect(info.duration, Duration.zero);
expect(info.isMuted, false);
expect(info.isSpeakerOn, false);
expect(info.isOnHold, false);
});
test('isActive returns true for ringing, connecting, connected', () {
expect(const CallInfo(state: CallState.ringing).isActive, true);
expect(const CallInfo(state: CallState.connecting).isActive, true);
expect(const CallInfo(state: CallState.connected).isActive, true);
});
test('isActive returns false for idle and disconnected', () {
expect(const CallInfo(state: CallState.idle).isActive, false);
expect(const CallInfo(state: CallState.disconnected).isActive, false);
});
test('copyWith preserves unmodified fields', () {
const original = CallInfo(
state: CallState.connected,
callSid: 'CA123',
callerNumber: '+1234567890',
isMuted: true,
);
final modified = original.copyWith(isSpeakerOn: true);
expect(modified.state, CallState.connected);
expect(modified.callSid, 'CA123');
expect(modified.callerNumber, '+1234567890');
expect(modified.isMuted, true);
expect(modified.isSpeakerOn, true);
});
test('copyWith can change state', () {
const info = CallInfo(state: CallState.connecting);
final updated = info.copyWith(state: CallState.connected);
expect(updated.state, CallState.connected);
});
test('copyWith with callerNumber preserves it', () {
const info = CallInfo(callerNumber: '+19095737372');
final updated = info.copyWith(state: CallState.connected);
expect(updated.callerNumber, '+19095737372');
});
test('reset to idle clears all fields', () {
// Verify a complex state exists
const connected = CallInfo(
state: CallState.connected,
callSid: 'CA123',
callerNumber: '+1234567890',
isMuted: true,
isSpeakerOn: true,
isOnHold: true,
duration: Duration(seconds: 30),
);
expect(connected.isActive, true);
// Simulating what callEnded does
const reset = CallInfo();
expect(reset.state, CallState.idle);
expect(reset.callSid, isNull);
expect(reset.callerNumber, isNull);
expect(reset.isActive, false);
});
});
group('CallState transitions', () {
test('outbound call flow: idle -> connecting -> connected -> idle', () {
var info = const CallInfo();
expect(info.state, CallState.idle);
// makeCall sets connecting + callerNumber
info = info.copyWith(state: CallState.connecting, callerNumber: '+19095737372');
expect(info.state, CallState.connecting);
expect(info.callerNumber, '+19095737372');
expect(info.isActive, true);
// SDK fires connected
info = info.copyWith(state: CallState.connected);
expect(info.state, CallState.connected);
expect(info.callerNumber, '+19095737372'); // preserved
expect(info.isActive, true);
// callEnded resets
info = const CallInfo();
expect(info.state, CallState.idle);
expect(info.isActive, false);
});
test('inbound call flow: idle -> ringing -> connected -> idle', () {
var info = const CallInfo();
info = info.copyWith(state: CallState.ringing);
expect(info.isActive, true);
// callerNumber set from active.from
info = info.copyWith(callerNumber: '+18005551234');
expect(info.callerNumber, '+18005551234');
info = info.copyWith(state: CallState.connected);
expect(info.state, CallState.connected);
info = const CallInfo();
expect(info.state, CallState.idle);
});
test('outbound callerNumber not overwritten by null copyWith', () {
var info = const CallInfo(
state: CallState.connecting,
callerNumber: '+19095737372',
);
// copyWith without callerNumber should preserve it
info = info.copyWith(state: CallState.connected);
expect(info.callerNumber, '+19095737372');
});
});
}

View File

@@ -0,0 +1,367 @@
import 'dart:async';
import 'package:flutter_test/flutter_test.dart';
import 'package:twilio_voice/twilio_voice.dart';
import 'package:twp_softphone/models/call_info.dart';
import 'package:twp_softphone/providers/call_provider.dart';
import 'package:twp_softphone/services/voice_service.dart';
/// Minimal mock of VoiceService for testing CallProvider logic.
/// Only stubs methods that CallProvider calls directly.
class MockVoiceService implements VoiceService {
final StreamController<CallEvent> _eventController =
StreamController<CallEvent>.broadcast();
bool makeCallResult = true;
bool acceptQueueCallShouldThrow = false;
String? lastCallTo;
String? lastCallerId;
bool answerCalled = false;
bool hangUpCalled = false;
@override
Stream<CallEvent> get callEvents => _eventController.stream;
void emitEvent(CallEvent event) => _eventController.add(event);
@override
Future<bool> makeCall(String to, {String? callerId}) async {
lastCallTo = to;
lastCallerId = callerId;
return makeCallResult;
}
@override
Future<void> answer() async {
answerCalled = true;
}
@override
Future<void> hangUp() async {
hangUpCalled = true;
}
@override
Future<void> reject() async {}
@override
Future<void> toggleMute(bool mute) async {}
@override
Future<void> toggleSpeaker(bool speaker) async {}
@override
Future<void> sendDigits(String digits) async {}
@override
Future<List<Map<String, dynamic>>> getQueueCalls(int queueId) async => [];
@override
Future<void> acceptQueueCall(String callSid) async {
if (acceptQueueCallShouldThrow) {
throw Exception('Network error');
}
}
@override
Future<void> holdCall(String callSid) async {}
@override
Future<void> unholdCall(String callSid) async {}
@override
Future<void> transferCall(String callSid, String target) async {}
@override
Future<void> initialize({String? deviceToken}) async {}
@override
String? get identity => 'agent2testuser';
@override
void dispose() {
_eventController.close();
}
// Unused stubs required by the interface
@override
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
}
void main() {
group('CallProvider.makeCall', () {
late MockVoiceService mockVoice;
late CallProvider provider;
setUp(() {
mockVoice = MockVoiceService();
provider = CallProvider(mockVoice);
});
tearDown(() {
provider.dispose();
mockVoice.dispose();
});
test('sets state to connecting and passes number', () async {
mockVoice.makeCallResult = true;
await provider.makeCall('+19095737372');
expect(mockVoice.lastCallTo, '+19095737372');
expect(provider.callInfo.state, CallState.connecting);
expect(provider.callInfo.callerNumber, '+19095737372');
});
test('passes callerId when provided', () async {
mockVoice.makeCallResult = true;
await provider.makeCall('+19095737372', callerId: '+19516215107');
expect(mockVoice.lastCallerId, '+19516215107');
});
test('resets to idle when call.place() returns false', () async {
mockVoice.makeCallResult = false;
await provider.makeCall('+19095737372');
expect(provider.callInfo.state, CallState.idle);
expect(provider.callInfo.callerNumber, isNull);
});
test('stays connecting when call.place() returns true', () async {
mockVoice.makeCallResult = true;
await provider.makeCall('+19095737372');
expect(provider.callInfo.state, CallState.connecting);
expect(provider.callInfo.callerNumber, '+19095737372');
});
});
group('CallProvider.hangUp', () {
late MockVoiceService mockVoice;
late CallProvider provider;
setUp(() {
mockVoice = MockVoiceService();
provider = CallProvider(mockVoice);
});
tearDown(() {
provider.dispose();
mockVoice.dispose();
});
test('resets to idle even if SDK does not fire callEnded', () async {
// Simulate a connecting state
mockVoice.makeCallResult = true;
await provider.makeCall('+19095737372');
expect(provider.callInfo.state, CallState.connecting);
// Hang up without SDK firing callEnded
await provider.hangUp();
expect(provider.callInfo.state, CallState.idle);
expect(provider.callInfo.callerNumber, isNull);
expect(mockVoice.hangUpCalled, true);
});
});
group('CallProvider state transitions', () {
test('outbound: connecting preserves callerNumber through state changes', () {
// Simulating what CallProvider does internally
var info = const CallInfo();
// makeCall sets connecting + callerNumber
info = info.copyWith(state: CallState.connecting, callerNumber: '+19095737372');
expect(info.state, CallState.connecting);
expect(info.callerNumber, '+19095737372');
// SDK fires connected — callerNumber preserved
info = info.copyWith(state: CallState.connected);
expect(info.state, CallState.connected);
expect(info.callerNumber, '+19095737372');
});
test('makeCall failure resets cleanly to idle', () {
var info = const CallInfo();
// makeCall sets connecting
info = info.copyWith(state: CallState.connecting, callerNumber: '+19095737372');
expect(info.state, CallState.connecting);
// call.place() returns false -> reset
info = const CallInfo();
expect(info.state, CallState.idle);
expect(info.callerNumber, isNull);
expect(info.isActive, false);
});
});
group('CallProvider.acceptQueueCall', () {
late MockVoiceService mockVoice;
late CallProvider provider;
setUp(() {
mockVoice = MockVoiceService();
provider = CallProvider(mockVoice);
});
tearDown(() {
provider.dispose();
mockVoice.dispose();
});
test('sets state to connecting before server call', () async {
await provider.acceptQueueCall('CA123abc');
expect(provider.callInfo.state, CallState.connecting);
});
test('auto-answers incoming call after acceptQueueCall', () async {
await provider.acceptQueueCall('CA123abc');
// Simulate the FCM incoming call event that arrives after server redirect
mockVoice.emitEvent(CallEvent.incoming);
// Allow the stream listener to process
await Future.delayed(Duration.zero);
// Should have auto-answered
expect(mockVoice.answerCalled, true);
expect(provider.callInfo.state, CallState.connecting);
});
test('normal incoming call shows ringing without auto-answer', () async {
// Without calling acceptQueueCall first
mockVoice.emitEvent(CallEvent.incoming);
await Future.delayed(Duration.zero);
expect(mockVoice.answerCalled, false);
expect(provider.callInfo.state, CallState.ringing);
});
test('connected event after auto-answer sets connected state', () async {
await provider.acceptQueueCall('CA123abc');
mockVoice.emitEvent(CallEvent.incoming);
await Future.delayed(Duration.zero);
expect(mockVoice.answerCalled, true);
mockVoice.emitEvent(CallEvent.connected);
await Future.delayed(Duration.zero);
expect(provider.callInfo.state, CallState.connected);
});
test('resets to idle on API error and clears pendingAutoAnswer', () async {
mockVoice.acceptQueueCallShouldThrow = true;
await provider.acceptQueueCall('CA123abc');
// Should have reset to idle after error
expect(provider.callInfo.state, CallState.idle);
// Future incoming call should NOT be auto-answered
mockVoice.emitEvent(CallEvent.incoming);
await Future.delayed(Duration.zero);
expect(mockVoice.answerCalled, false);
expect(provider.callInfo.state, CallState.ringing);
});
});
group('CallProvider.hangUp edge cases', () {
late MockVoiceService mockVoice;
late CallProvider provider;
setUp(() {
mockVoice = MockVoiceService();
provider = CallProvider(mockVoice);
});
tearDown(() {
provider.dispose();
mockVoice.dispose();
});
test('hangUp when already idle is a no-op', () async {
expect(provider.callInfo.state, CallState.idle);
await provider.hangUp();
expect(provider.callInfo.state, CallState.idle);
expect(mockVoice.hangUpCalled, true);
});
test('hangUp clears pendingAutoAnswer flag', () async {
await provider.acceptQueueCall('CA123abc');
expect(provider.callInfo.state, CallState.connecting);
await provider.hangUp();
expect(provider.callInfo.state, CallState.idle);
// Incoming call should NOT auto-answer after hangUp cleared the flag
mockVoice.emitEvent(CallEvent.incoming);
await Future.delayed(Duration.zero);
expect(mockVoice.answerCalled, false);
expect(provider.callInfo.state, CallState.ringing);
});
});
group('CallProvider.toggleMute and toggleSpeaker', () {
late MockVoiceService mockVoice;
late CallProvider provider;
setUp(() {
mockVoice = MockVoiceService();
provider = CallProvider(mockVoice);
});
tearDown(() {
provider.dispose();
mockVoice.dispose();
});
test('toggleMute flips isMuted state', () async {
expect(provider.callInfo.isMuted, false);
await provider.toggleMute();
expect(provider.callInfo.isMuted, true);
await provider.toggleMute();
expect(provider.callInfo.isMuted, false);
});
test('toggleSpeaker flips isSpeakerOn state', () async {
expect(provider.callInfo.isSpeakerOn, false);
await provider.toggleSpeaker();
expect(provider.callInfo.isSpeakerOn, true);
await provider.toggleSpeaker();
expect(provider.callInfo.isSpeakerOn, false);
});
});
group('CallProvider.callEnded', () {
late MockVoiceService mockVoice;
late CallProvider provider;
setUp(() {
mockVoice = MockVoiceService();
provider = CallProvider(mockVoice);
});
tearDown(() {
provider.dispose();
mockVoice.dispose();
});
test('callEnded resets state completely', () async {
// Set up a connected call
mockVoice.makeCallResult = true;
await provider.makeCall('+19095737372');
mockVoice.emitEvent(CallEvent.connected);
await Future.delayed(Duration.zero);
expect(provider.callInfo.state, CallState.connected);
expect(provider.callInfo.callerNumber, '+19095737372');
// End the call
mockVoice.emitEvent(CallEvent.callEnded);
await Future.delayed(Duration.zero);
expect(provider.callInfo.state, CallState.idle);
expect(provider.callInfo.callerNumber, isNull);
expect(provider.callInfo.callSid, isNull);
expect(provider.callInfo.isActive, false);
});
});
}

View File

@@ -0,0 +1,92 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:twp_softphone/models/queue_state.dart';
void main() {
group('QueueInfo', () {
test('parses from JSON with all fields', () {
final json = {
'id': 1,
'name': 'General',
'type': 'general',
'extension': '100',
'waiting_count': 3,
};
final queue = QueueInfo.fromJson(json);
expect(queue.id, 1);
expect(queue.name, 'General');
expect(queue.type, 'general');
expect(queue.extension, '100');
expect(queue.waitingCount, 3);
});
test('parses from JSON with string numbers', () {
final json = {
'id': '5',
'name': 'Support',
'type': 'personal',
'waiting_count': '0',
};
final queue = QueueInfo.fromJson(json);
expect(queue.id, 5);
expect(queue.waitingCount, 0);
expect(queue.extension, isNull);
});
test('handles missing fields gracefully', () {
final json = <String, dynamic>{};
final queue = QueueInfo.fromJson(json);
expect(queue.id, 0);
expect(queue.name, '');
expect(queue.type, '');
expect(queue.extension, isNull);
expect(queue.waitingCount, 0);
});
});
group('QueueCall', () {
test('parses from JSON', () {
final json = {
'call_sid': 'CA123abc',
'from_number': '+18005551234',
'to_number': '+19095737372',
'position': 1,
'status': 'waiting',
'wait_time': 45,
};
final call = QueueCall.fromJson(json);
expect(call.callSid, 'CA123abc');
expect(call.fromNumber, '+18005551234');
expect(call.toNumber, '+19095737372');
expect(call.position, 1);
expect(call.status, 'waiting');
expect(call.waitTime, 45);
});
test('handles string wait_time', () {
final json = {
'call_sid': 'CA456',
'from_number': '+1800',
'to_number': '+1900',
'position': '2',
'status': 'waiting',
'wait_time': '120',
};
final call = QueueCall.fromJson(json);
expect(call.position, 2);
expect(call.waitTime, 120);
});
test('handles missing fields gracefully', () {
final json = <String, dynamic>{};
final call = QueueCall.fromJson(json);
expect(call.callSid, '');
expect(call.fromNumber, '');
expect(call.position, 0);
expect(call.waitTime, 0);
});
});
}

View File

@@ -0,0 +1,40 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:twp_softphone/app.dart';
import 'package:twp_softphone/screens/login_screen.dart';
void main() {
group('TwpSoftphoneApp', () {
testWidgets('shows loading indicator on startup', (tester) async {
await tester.pumpWidget(const TwpSoftphoneApp());
expect(find.byType(TwpSoftphoneApp), findsOneWidget);
expect(find.bySubtype<CircularProgressIndicator>(), findsOneWidget);
});
});
group('LoginScreen', () {
testWidgets('renders server URL field and connect button', (tester) async {
await tester.pumpWidget(
MaterialApp(
home: LoginScreen(onLoginSuccess: (_) {}),
),
);
await tester.pump(const Duration(milliseconds: 100));
expect(find.text('Server URL'), findsOneWidget);
expect(find.text('Connect'), findsOneWidget);
expect(find.text('TWP Softphone'), findsOneWidget);
});
testWidgets('validates empty server URL on submit', (tester) async {
await tester.pumpWidget(
MaterialApp(
home: LoginScreen(onLoginSuccess: (_) {}),
),
);
await tester.pump(const Duration(milliseconds: 100));
await tester.tap(find.text('Connect'));
await tester.pump();
expect(find.text('Required'), findsOneWidget);
});
});
}

113
test-deploy.sh Executable file
View File

@@ -0,0 +1,113 @@
#!/bin/bash
# Test harness for TWP WebView Softphone deployment
# Run after deploying PHP files and flushing rewrite rules
SERVER="https://phone.cloud-hosting.io"
PASS=0
FAIL=0
check() {
local desc="$1"
local result="$2"
if [ "$result" = "0" ]; then
echo " PASS: $desc"
PASS=$((PASS + 1))
else
echo " FAIL: $desc"
FAIL=$((FAIL + 1))
fi
}
echo "=== TWP WebView Softphone - Deployment Test Harness ==="
echo ""
# 1. Test standalone phone page exists (should redirect to login for unauthenticated)
echo "[1] Standalone Phone Page (/twp-phone/)"
RESPONSE=$(curl -s -o /dev/null -w "%{http_code}:%{redirect_url}" -L --max-redirs 0 "$SERVER/twp-phone/" 2>/dev/null)
HTTP_CODE=$(echo "$RESPONSE" | cut -d: -f1)
REDIRECT=$(echo "$RESPONSE" | cut -d: -f2-)
# Should redirect (302) to wp-login.php for unauthenticated users
if [ "$HTTP_CODE" = "302" ] && echo "$REDIRECT" | grep -q "wp-login"; then
check "Unauthenticated redirect to wp-login.php" 0
else
check "Unauthenticated redirect to wp-login.php (got $HTTP_CODE, redirect: $REDIRECT)" 1
fi
# 2. Test that wp-login.php page loads
echo ""
echo "[2] WordPress Login Page"
LOGIN_RESPONSE=$(curl -s -o /dev/null -w "%{http_code}" "$SERVER/wp-login.php" 2>/dev/null)
check "wp-login.php returns 200" "$([ "$LOGIN_RESPONSE" = "200" ] && echo 0 || echo 1)"
# 3. Test authenticated access (login and get cookies, then access /twp-phone/)
echo ""
echo "[3] Authenticated Access"
# Try to log in and get session cookies
COOKIE_JAR="/tmp/twp-test-cookies.txt"
rm -f "$COOKIE_JAR"
# Login - use the test credentials if available
LOGIN_RESULT=$(curl -s -o /dev/null -w "%{http_code}" \
-c "$COOKIE_JAR" \
-d "log=admin&pwd=admin&rememberme=forever&redirect_to=$SERVER/twp-phone/&wp-submit=Log+In" \
"$SERVER/wp-login.php" 2>/dev/null)
if [ "$LOGIN_RESULT" = "302" ]; then
# Follow redirect to /twp-phone/
PAGE_RESULT=$(curl -s -b "$COOKIE_JAR" -w "%{http_code}" -o /tmp/twp-phone-page.html "$SERVER/twp-phone/" 2>/dev/null)
check "Authenticated /twp-phone/ returns 200" "$([ "$PAGE_RESULT" = "200" ] && echo 0 || echo 1)"
if [ "$PAGE_RESULT" = "200" ]; then
# Check page content
check "Page contains Twilio SDK" "$(grep -q 'twilio.min.js' /tmp/twp-phone-page.html && echo 0 || echo 1)"
check "Page contains dialpad" "$(grep -q 'dialpad' /tmp/twp-phone-page.html && echo 0 || echo 1)"
check "Page contains ajaxurl" "$(grep -q 'ajaxurl' /tmp/twp-phone-page.html && echo 0 || echo 1)"
check "Page contains TwpMobile bridge" "$(grep -q 'TwpMobile' /tmp/twp-phone-page.html && echo 0 || echo 1)"
check "Page contains twpNonce" "$(grep -q 'twpNonce' /tmp/twp-phone-page.html && echo 0 || echo 1)"
check "Page has mobile viewport" "$(grep -q 'viewport-fit=cover' /tmp/twp-phone-page.html && echo 0 || echo 1)"
check "Page has dark mode CSS" "$(grep -q 'prefers-color-scheme' /tmp/twp-phone-page.html && echo 0 || echo 1)"
check "No WP admin bar" "$(grep -q 'wp-admin-bar' /tmp/twp-phone-page.html && echo 1 || echo 0)"
check "Page contains phone-number-input" "$(grep -q 'phone-number-input' /tmp/twp-phone-page.html && echo 0 || echo 1)"
check "Page contains caller-id-select" "$(grep -q 'caller-id-select' /tmp/twp-phone-page.html && echo 0 || echo 1)"
check "Page contains hold/transfer buttons" "$(grep -q 'hold-btn' /tmp/twp-phone-page.html && echo 0 || echo 1)"
check "Page contains queue tab" "$(grep -q 'queue' /tmp/twp-phone-page.html && echo 0 || echo 1)"
fi
else
echo " SKIP: Could not log in (HTTP $LOGIN_RESULT) - manual auth testing required"
fi
# 4. Test AJAX endpoint availability
echo ""
echo "[4] AJAX Endpoints"
if [ -f "$COOKIE_JAR" ] && [ "$LOGIN_RESULT" = "302" ]; then
# Test that admin-ajax.php is accessible with cookies
AJAX_RESULT=$(curl -s -b "$COOKIE_JAR" -o /dev/null -w "%{http_code}" \
-d "action=twp_generate_capability_token&nonce=test" \
"$SERVER/wp-admin/admin-ajax.php" 2>/dev/null)
# Should return 200 (even if nonce fails, it means AJAX is working)
check "admin-ajax.php accessible" "$([ "$AJAX_RESULT" = "200" ] || [ "$AJAX_RESULT" = "400" ] || [ "$AJAX_RESULT" = "403" ] && echo 0 || echo 1)"
fi
# 5. Test 7-day cookie expiration
echo ""
echo "[5] Session Cookie"
if [ -f "$COOKIE_JAR" ]; then
# Check if cookies have extended expiry
COOKIE_EXISTS=$(grep -c "wordpress_logged_in" "$COOKIE_JAR" 2>/dev/null)
check "Login cookies set" "$([ "$COOKIE_EXISTS" -gt 0 ] && echo 0 || echo 1)"
fi
# Cleanup
rm -f "$COOKIE_JAR" /tmp/twp-phone-page.html
echo ""
echo "=== Results: $PASS passed, $FAIL failed ==="
echo ""
if [ "$FAIL" -gt 0 ]; then
echo "Some tests failed. Review output above."
exit 1
else
echo "All tests passed!"
exit 0
fi