diff --git a/assets/mobile/phone-template.php b/assets/mobile/phone-template.php
new file mode 100644
index 0000000..8ed0502
--- /dev/null
+++ b/assets/mobile/phone-template.php
@@ -0,0 +1,267 @@
+
+
+
+
+
+
+
+
+Phone -
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ extension) : '—'; ?>
+
+
+
+
+
+
+ Today:
+ Total:
+ Avg: s
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Ready
+
Loading...
+
+
00:00
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
No calls yet this session.
+
+
+
+
+
+
+
+
+
+
Loading your queues...
+
+
+
+
+
+
+
+
+
+
+
+
+
Outbound Caller ID
+
+
+
+
+
+
+
+
+
+
+
Appearance
+
+
+
+
+
+
+
+
+
+
Call Reception Mode
+
+
+
+
+
+
+ Current:
+
+
+
+
+
+
+
Keep this page open to receive calls.
+
+
+
Calls forwarded to: Not configured'; ?>
+
+
+
+
+
+
+
Setup Required
+
Update your phone number webhook to:
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/assets/mobile/phone.css b/assets/mobile/phone.css
new file mode 100644
index 0000000..ac61566
--- /dev/null
+++ b/assets/mobile/phone.css
@@ -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 */
+: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;
+}
diff --git a/assets/mobile/phone.js b/assets/mobile/phone.js
new file mode 100644
index 0000000..1285181
--- /dev/null
+++ b/assets/mobile/phone.js
@@ -0,0 +1,1138 @@
+// Configuration injected by PHP template via twpConfig global
+var ajaxurl = window.twpConfig.ajaxUrl;
+var twpNonce = window.twpConfig.nonce;
+var twpRingtoneUrl = window.twpConfig.ringtoneUrl;
+var twpPhoneIconUrl = window.twpConfig.phoneIconUrl;
+var twpSwUrl = window.twpConfig.swUrl;
+var twpTwilioEdge = window.twpConfig.twilioEdge;
+
+(function($) {
+ // ============================================================
+ // Flutter WebView Bridge
+ // ============================================================
+ window.TwpMobile = window.TwpMobile || {};
+
+ /** Flutter injects FCM token via this method. */
+ window.TwpMobile.getFcmToken = function() {
+ return window.TwpMobile._fcmToken || null;
+ };
+ window.TwpMobile.setFcmToken = function(token) {
+ window.TwpMobile._fcmToken = token;
+ // Register FCM token with server via WP AJAX (uses cookie auth)
+ $.post(ajaxurl, {
+ action: 'twp_register_fcm_token',
+ nonce: twpNonce,
+ fcm_token: token
+ }).fail(function() { console.warn('TWP: FCM token registration failed'); });
+ };
+
+ /** Flutter calls this when a notification is tapped. */
+ window.TwpMobile.onNotificationTap = function(data) {
+ // Switch to phone tab and focus.
+ switchTab('phone');
+ if (data && data.caller) {
+ $('#phone-number-input').val(data.caller);
+ }
+ };
+
+ /** Notify Flutter that page is ready (via webview_flutter JavaScriptChannel). */
+ function notifyFlutterReady() {
+ try {
+ if (window.TwpMobile && window.TwpMobile.postMessage) {
+ window.TwpMobile.postMessage('onPageReady');
+ }
+ } catch (e) { /* not in WebView */ }
+ }
+
+ /** Notify Flutter that session has expired. */
+ function notifyFlutterSessionExpired() {
+ try {
+ if (window.TwpMobile && window.TwpMobile.postMessage) {
+ window.TwpMobile.postMessage('onSessionExpired');
+ }
+ } catch (e) { /* not in WebView */ }
+ }
+
+ /**
+ * Wrapper around $.post that detects session expiration.
+ */
+ function twpPost(data, successCb, failCb) {
+ return $.post(ajaxurl, data, function(response) {
+ if (successCb) successCb(response);
+ }).fail(function(xhr) {
+ // Detect login redirect / 403
+ if (xhr.status === 403 || (xhr.responseText && xhr.responseText.indexOf('wp-login') !== -1)) {
+ notifyFlutterSessionExpired();
+ }
+ if (failCb) failCb(xhr);
+ });
+ }
+
+ // ============================================================
+ // Tab Navigation
+ // ============================================================
+ function switchTab(name) {
+ $('.tab-btn').removeClass('active');
+ $('.tab-btn[data-tab="' + name + '"]').addClass('active');
+ $('.tab-pane').removeClass('active');
+ $('#tab-' + name).addClass('active');
+ }
+ $('.tab-btn').on('click', function() { switchTab($(this).data('tab')); });
+
+ // ============================================================
+ // Notices
+ // ============================================================
+ function showNotice(message, type) {
+ var cls = 'twp-notice twp-notice-' + (type || 'info');
+ var $el = $('' + message + '
');
+ $('#twp-notices').append($el);
+ setTimeout(function() { $el.fadeOut(300, function() { $el.remove(); }); }, 4000);
+ }
+
+ function showError(message) {
+ $('#browser-phone-error').html('Error: ' + message + '
').show();
+ $('#phone-status').text('Error').css('color', 'var(--danger)');
+ }
+
+ // ============================================================
+ // Core phone state
+ // ============================================================
+ var device = null;
+ var currentCall = null;
+ var callTimer = null;
+ var callStartTime = null;
+ var tokenRefreshTimer = null;
+ var tokenExpiry = null;
+ var audioContext = null;
+ var ringtoneAudio = null;
+ var isPageVisible = true;
+ var deviceConnectionState = 'disconnected';
+ var serviceWorkerRegistration = null;
+ var currentCallDirection = null;
+ var callHistory = [];
+ var wakeLock = null;
+ var deviceKeepaliveTimer = null;
+
+ // ============================================================
+ // AudioContext & Ringtone
+ // ============================================================
+ function initializeAudioContext() {
+ try {
+ if (!audioContext) {
+ var AC = window.AudioContext || window.webkitAudioContext;
+ audioContext = new AC();
+ }
+ if (audioContext.state === 'suspended') {
+ audioContext.resume().catch(function() {});
+ }
+ return true;
+ } catch (e) { return false; }
+ }
+
+ function setupRingtone() {
+ if (!ringtoneAudio) {
+ ringtoneAudio = new Audio();
+ ringtoneAudio.loop = true;
+ ringtoneAudio.volume = 0.7;
+ ringtoneAudio.src = twpRingtoneUrl;
+ ringtoneAudio.addEventListener('error', function() {}, { once: true });
+ ringtoneAudio.load();
+ }
+ }
+
+ function playRingtone() {
+ try {
+ initializeAudioContext();
+ if (ringtoneAudio) {
+ var p = ringtoneAudio.play();
+ if (p !== undefined) p.catch(function() { vibrateDevice([300,200,300,200,300]); });
+ }
+ vibrateDevice([300,200,300,200,300]);
+ } catch (e) {}
+ }
+
+ function stopRingtone() {
+ try { if (ringtoneAudio) { ringtoneAudio.pause(); ringtoneAudio.currentTime = 0; } } catch (e) {}
+ }
+
+ function vibrateDevice(pattern) {
+ if ('vibrate' in navigator) navigator.vibrate(pattern);
+ }
+
+ // ============================================================
+ // Service Worker & Notifications
+ // ============================================================
+ function registerServiceWorker() {
+ if ('serviceWorker' in navigator) {
+ navigator.serviceWorker.register(twpSwUrl).then(function(reg) {
+ serviceWorkerRegistration = reg;
+ if ('Notification' in window && Notification.permission === 'default') {
+ Notification.requestPermission();
+ }
+ }).catch(function() {});
+ }
+ }
+
+ function sendIncomingCallNotification(callerNumber) {
+ if ('Notification' in window && Notification.permission === 'granted') {
+ if (serviceWorkerRegistration && serviceWorkerRegistration.active) {
+ serviceWorkerRegistration.active.postMessage({
+ type: 'SHOW_NOTIFICATION',
+ title: 'Incoming Call',
+ body: 'Call from ' + (callerNumber || 'Unknown Number'),
+ icon: twpPhoneIconUrl,
+ tag: 'incoming-call',
+ requireInteraction: true
+ });
+ } else {
+ new Notification('Incoming Call', {
+ body: 'Call from ' + (callerNumber || 'Unknown Number'),
+ icon: twpPhoneIconUrl,
+ tag: 'incoming-call',
+ requireInteraction: true
+ });
+ }
+ }
+ }
+
+ // ============================================================
+ // Screen Wake Lock
+ // ============================================================
+ async function requestWakeLock() {
+ if (!('wakeLock' in navigator)) return;
+ try {
+ wakeLock = await navigator.wakeLock.request('screen');
+ console.log('TWP: Wake lock acquired');
+ wakeLock.addEventListener('release', function() {
+ console.log('TWP: Wake lock released');
+ wakeLock = null;
+ });
+ } catch (e) {
+ console.warn('TWP: Wake lock request failed:', e.message);
+ }
+ }
+
+ function releaseWakeLock() {
+ if (wakeLock) {
+ wakeLock.release().catch(function() {});
+ wakeLock = null;
+ }
+ }
+
+ // ============================================================
+ // Device Keepalive Monitor
+ // ============================================================
+ function startDeviceKeepalive() {
+ stopDeviceKeepalive();
+ deviceKeepaliveTimer = setInterval(function() {
+ if (device && deviceConnectionState !== 'connected' && !currentCall) {
+ console.log('TWP: Keepalive — device disconnected, re-registering');
+ device.register().catch(function(e) {
+ console.warn('TWP: Keepalive re-register failed:', e.message);
+ });
+ }
+ }, 30000);
+ }
+
+ function stopDeviceKeepalive() {
+ if (deviceKeepaliveTimer) {
+ clearInterval(deviceKeepaliveTimer);
+ deviceKeepaliveTimer = null;
+ }
+ }
+
+ // ============================================================
+ // Page Visibility
+ // ============================================================
+ function setupPageVisibility() {
+ document.addEventListener('visibilitychange', function() {
+ isPageVisible = !document.hidden;
+ if (isPageVisible) {
+ console.log('TWP: Page visible — checking connections');
+ // Resume audio
+ if (audioContext) initializeAudioContext();
+ // Re-request wake lock (auto-released when tab hidden)
+ requestWakeLock();
+ // Check token expiry — refresh if expired or within 2 minutes
+ if (tokenExpiry && (Date.now() > tokenExpiry - 2 * 60 * 1000)) {
+ console.log('TWP: Token expired or expiring soon, refreshing');
+ refreshToken();
+ }
+ // Check device registration
+ if (device && deviceConnectionState !== 'connected' && !currentCall) {
+ console.log('TWP: Device disconnected, re-registering');
+ device.register().catch(function(e) {
+ console.warn('TWP: Visibility re-register failed:', e.message);
+ });
+ }
+ } else {
+ console.log('TWP: Page hidden — wake lock auto-released by browser');
+ }
+ });
+ }
+
+ // ============================================================
+ // Connection Status
+ // ============================================================
+ function updateConnectionStatus(state) {
+ deviceConnectionState = state;
+ var text = '', color = '';
+ switch (state) {
+ case 'connected': text = 'Connected'; color = 'var(--success)'; break;
+ case 'connecting': text = 'Connecting...'; color = 'var(--warning)'; break;
+ case 'disconnected': text = 'Disconnected'; color = 'var(--danger)'; break;
+ default: text = 'Unknown'; color = 'var(--text-secondary)';
+ }
+ $('#device-connection-status').text(text).css('color', color);
+ }
+
+ // ============================================================
+ // Twilio Device Setup
+ // ============================================================
+ function waitForTwilioSDK(cb) {
+ if (typeof Twilio !== 'undefined' && Twilio.Device) { cb(); }
+ else { setTimeout(function() { waitForTwilioSDK(cb); }, 100); }
+ }
+
+ function initializeBrowserPhone() {
+ $('#phone-status').text('Initializing...');
+ updateConnectionStatus('connecting');
+
+ setupRingtone();
+ registerServiceWorker();
+ setupPageVisibility();
+
+ $(document).one('click touchstart', function() { initializeAudioContext(); });
+
+ waitForTwilioSDK(function() {
+ twpPost({
+ action: 'twp_generate_capability_token',
+ nonce: twpNonce
+ }, function(response) {
+ if (response.success) {
+ $('#browser-phone-error').hide();
+ setupTwilioDevice(response.data.token);
+ tokenExpiry = Date.now() + (response.data.expires_in || 3600) * 1000;
+ scheduleTokenRefresh();
+ } else {
+ var msg = response.data || 'Unknown error';
+ showError('Failed to initialize: ' + msg);
+ updateConnectionStatus('disconnected');
+ }
+ }, function() {
+ showError('Failed to connect to server');
+ updateConnectionStatus('disconnected');
+ });
+ });
+ }
+
+ async function requestMediaPermissions() {
+ try {
+ var stream = await navigator.mediaDevices.getUserMedia({ audio: true, video: false });
+ stream.getTracks().forEach(function(t) { t.stop(); });
+ return true;
+ } catch (error) {
+ var msg = 'Microphone access is required. ';
+ if (error.name === 'NotAllowedError') msg += 'Please allow microphone access.';
+ else if (error.name === 'NotFoundError') msg += 'No microphone found.';
+ else msg += 'Check browser settings.';
+ showError(msg);
+ return false;
+ }
+ }
+
+ async function setupTwilioDevice(token) {
+ try {
+ if (typeof Twilio === 'undefined' || !Twilio.Device) throw new Error('Twilio Voice SDK not loaded');
+
+ updateConnectionStatus('connecting');
+
+ var hasPerms = await requestMediaPermissions();
+ if (!hasPerms) { updateConnectionStatus('disconnected'); return; }
+
+ if (device) { await device.destroy(); }
+
+ var isAndroid = /Android/i.test(navigator.userAgent);
+ var audioConstraints = {
+ echoCancellation: true,
+ noiseSuppression: true,
+ autoGainControl: true
+ };
+ if (isAndroid) {
+ audioConstraints.googEchoCancellation = true;
+ audioConstraints.googNoiseSuppression = true;
+ audioConstraints.googAutoGainControl = true;
+ audioConstraints.googHighpassFilter = true;
+ }
+
+ device = new Twilio.Device(token, {
+ logLevel: 1,
+ codecPreferences: ['opus', 'pcmu'],
+ edge: twpTwilioEdge,
+ enableIceRestart: true,
+ audioConstraints: audioConstraints,
+ maxCallSignalingTimeoutMs: 30000,
+ closeProtection: true
+ });
+
+ device.on('registered', function() {
+ $('#phone-status').text('Ready').css('color', 'var(--success)');
+ $('#call-btn').prop('disabled', false);
+ updateConnectionStatus('connected');
+ });
+
+ device.on('unregistered', function() { updateConnectionStatus('disconnected'); });
+
+ device.on('error', function(error) {
+ updateConnectionStatus('disconnected');
+ var msg = error.message || error.toString();
+ if (msg.includes('valid callerId')) {
+ msg = 'Select a verified Twilio phone number as Caller ID.';
+ } else if (msg.includes('token') || msg.includes('Token')) {
+ msg = 'Token error: ' + msg;
+ setTimeout(initializeBrowserPhone, 5000);
+ } else if (msg.includes('31005') || msg.includes('Connection error')) {
+ msg = 'Connection error. Check internet connection.';
+ setTimeout(function() { if (device) device.register(); }, 3000);
+ }
+ showError(msg);
+ });
+
+ device.on('incoming', function(call) {
+ currentCall = call;
+ currentCallDirection = 'inbound';
+ var caller = call.parameters.From || 'Unknown';
+ $('#phone-status').text('Incoming Call').css('color', 'var(--warning)');
+ $('#phone-number-display').text(caller);
+ $('#call-btn').hide();
+ $('#answer-btn').show();
+
+ playRingtone();
+ if (!isPageVisible) sendIncomingCallNotification(caller);
+ setupCallHandlers(call);
+
+ // Switch to phone tab on incoming call
+ switchTab('phone');
+
+ if ($('#auto-answer').is(':checked')) call.accept();
+ });
+
+ device.on('tokenWillExpire', function() { refreshToken(); });
+
+ await device.register();
+ requestWakeLock();
+ startDeviceKeepalive();
+ } catch (error) {
+ showError('Failed to setup device: ' + error.message);
+ }
+ }
+
+ // ============================================================
+ // Call Handlers
+ // ============================================================
+ function setupCallHandlers(call) {
+ call.on('accept', function() {
+ stopRingtone();
+ requestWakeLock();
+ $('#phone-status').text('Connected').css('color', 'var(--accent)');
+ $('#call-btn').hide();
+ $('#answer-btn').hide();
+ $('#hangup-btn').show();
+ $('#admin-call-controls-panel').show();
+ startCallTimer();
+ });
+
+ call.on('disconnect', function() {
+ stopRingtone();
+ // Capture call info for history before clearing
+ var disconnectedNumber = $('#phone-number-display').text() || $('#phone-number-input').val();
+ var callDuration = $('#call-timer').text();
+ if (disconnectedNumber && callStartTime) {
+ addToCallHistory(disconnectedNumber, currentCallDirection || 'outbound', callDuration);
+ }
+ currentCall = null;
+ currentCallDirection = null;
+ $('#phone-status').text('Ready').css('color', 'var(--success)');
+ $('#hangup-btn').hide();
+ $('#answer-btn').hide();
+ $('#call-btn').show();
+ $('#admin-call-controls-panel').hide();
+ $('#call-timer').hide();
+ stopCallTimer();
+ $('#phone-number-input').val('');
+ $('#phone-number-display').text('');
+ $('#admin-hold-btn').html('⏸ Hold').removeClass('btn-active');
+ $('#admin-record-btn').html('⏺ Record').removeClass('btn-active');
+ adminIsOnHold = false;
+ adminIsRecording = false;
+ adminRecordingSid = null;
+ });
+
+ call.on('reject', function() {
+ stopRingtone();
+ currentCall = null;
+ $('#phone-status').text('Ready').css('color', 'var(--success)');
+ $('#answer-btn').hide();
+ $('#call-btn').show();
+ });
+
+ call.on('cancel', function() {
+ stopRingtone();
+ currentCall = null;
+ $('#phone-status').text('Missed Call').css('color', 'var(--warning)');
+ $('#answer-btn').hide();
+ $('#call-btn').show();
+ setTimeout(function() { $('#phone-status').text('Ready').css('color', 'var(--success)'); }, 3000);
+ });
+
+ call.on('error', function(error) {
+ stopRingtone();
+ var msg = error.message || error.toString();
+ if (error.code === 31005) msg = 'Connection failed. Check network.';
+ else if (error.code === 31201 || error.code === 31204) msg = 'Call setup failed. Try again.';
+ else if (error.code === 31208) msg = 'Media failed. Check microphone.';
+ showError('Call error: ' + msg);
+ });
+ }
+
+ // ============================================================
+ // Token Refresh
+ // ============================================================
+ function refreshToken() {
+ if (currentCall) { setTimeout(refreshToken, 60000); return; }
+
+ twpPost({
+ action: 'twp_generate_capability_token',
+ nonce: twpNonce
+ }, function(response) {
+ if (response.success && device) {
+ device.updateToken(response.data.token);
+ tokenExpiry = Date.now() + (response.data.expires_in || 3600) * 1000;
+ scheduleTokenRefresh();
+ } else {
+ showError('Failed to refresh connection. Please reload.');
+ }
+ }, function() {
+ setTimeout(refreshToken, 30000);
+ });
+ }
+
+ function scheduleTokenRefresh() {
+ if (tokenRefreshTimer) clearTimeout(tokenRefreshTimer);
+ if (!tokenExpiry) return;
+ var ms = tokenExpiry - Date.now() - 5 * 60 * 1000;
+ if (ms <= 0) refreshToken();
+ else tokenRefreshTimer = setTimeout(refreshToken, ms);
+ }
+
+ // ============================================================
+ // Timer
+ // ============================================================
+ function startCallTimer() {
+ callStartTime = new Date();
+ $('#call-timer').show();
+ callTimer = setInterval(function() {
+ var s = Math.floor((new Date() - callStartTime) / 1000);
+ var m = Math.floor(s / 60);
+ s = s % 60;
+ $('#call-timer').text((m < 10 ? '0' : '') + m + ':' + (s < 10 ? '0' : '') + s);
+ }, 1000);
+ }
+
+ function stopCallTimer() {
+ if (callTimer) { clearInterval(callTimer); callTimer = null; }
+ $('#call-timer').text('00:00');
+ }
+
+ // ============================================================
+ // Caller ID loading
+ // ============================================================
+ twpPost({
+ action: 'twp_get_phone_numbers',
+ nonce: twpNonce
+ }, function(response) {
+ if (response.success) {
+ var opts = '';
+ response.data.forEach(function(n) { opts += ''; });
+ $('#caller-id-select').html(opts);
+ // Restore saved caller ID from localStorage
+ var savedCallerId = localStorage.getItem('twp_caller_id');
+ if (savedCallerId) {
+ $('#caller-id-select').val(savedCallerId);
+ }
+ } else {
+ $('#caller-id-select').html('');
+ }
+ }, function() {
+ $('#caller-id-select').html('');
+ });
+
+ // Persist caller ID selection to localStorage
+ $('#caller-id-select').on('change', function() {
+ localStorage.setItem('twp_caller_id', $(this).val());
+ });
+
+ // ============================================================
+ // Dialpad
+ // ============================================================
+ $('.dialpad-btn').on('click touchend', function(e) {
+ e.preventDefault();
+ var digit = $(this).data('digit');
+ $('#phone-number-input').val($('#phone-number-input').val() + digit);
+ initializeAudioContext();
+
+ // Send DTMF during active call
+ if (currentCall) {
+ currentCall.sendDigits(String(digit));
+ }
+ });
+
+ // ============================================================
+ // Call / Hangup / Answer
+ // ============================================================
+ $('#call-btn').on('click', async function() {
+ var num = $('#phone-number-input').val().trim();
+ var cid = $('#caller-id-select').val();
+ if (!num) { showNotice('Enter a phone number', 'error'); return; }
+ if (!cid) { showNotice('Select a caller ID', 'error'); return; }
+ if (!device) { showNotice('Phone not initialized. Reload page.', 'error'); return; }
+
+ num = num.replace(/\D/g, '');
+ if (num.length === 10) num = '+1' + num;
+ else if (num.length === 11 && num.charAt(0) === '1') num = '+' + num;
+ else if (!num.startsWith('+')) num = '+' + num;
+
+ $('#phone-number-display').text(num);
+ $('#phone-status').text('Calling...').css('color', 'var(--warning)');
+ currentCallDirection = 'outbound';
+
+ try {
+ currentCall = await device.connect({ params: { To: num, From: cid } });
+ setupCallHandlers(currentCall);
+ } catch (err) {
+ showError('Failed to call: ' + err.message);
+ $('#phone-status').text('Ready').css('color', 'var(--success)');
+ }
+ });
+
+ $('#hangup-btn').on('click', function() { if (currentCall) currentCall.disconnect(); });
+
+ $('#answer-btn').on('click', function() {
+ if (!currentCall) { showError('No incoming call'); return; }
+ if (deviceConnectionState !== 'connected') {
+ showError('Phone not connected. Reconnecting...');
+ if (device) device.register().then(function() { if (currentCall) currentCall.accept(); }).catch(function() { showError('Reconnect failed. Reload page.'); });
+ return;
+ }
+ initializeAudioContext();
+ try { currentCall.accept(); } catch (e) { showError('Failed to answer: ' + e.message); }
+ });
+
+ // ============================================================
+ // Call Controls: Hold / Transfer / Requeue / Record
+ // ============================================================
+ var adminIsOnHold = false;
+ var adminIsRecording = false;
+ var adminRecordingSid = null;
+
+ function getCallSid() {
+ if (!currentCall) return null;
+ return currentCall.parameters.CallSid ||
+ (currentCall.customParameters && currentCall.customParameters.CallSid) ||
+ currentCall.outgoingConnectionId ||
+ currentCall.sid;
+ }
+
+ $('#admin-hold-btn').on('click', function() {
+ var sid = getCallSid();
+ if (!sid) return;
+ var $btn = $(this);
+ twpPost({
+ action: 'twp_toggle_hold',
+ call_sid: sid,
+ hold: !adminIsOnHold,
+ nonce: twpNonce
+ }, function(r) {
+ if (r.success) {
+ adminIsOnHold = !adminIsOnHold;
+ $btn.html(adminIsOnHold ? '▶ Unhold' : '⏸ Hold').toggleClass('btn-active', adminIsOnHold);
+ showNotice(adminIsOnHold ? 'Call on hold' : 'Call resumed', 'info');
+ } else { showNotice('Hold failed: ' + (r.data || ''), 'error'); }
+ });
+ });
+
+ $('#admin-transfer-btn').on('click', function() {
+ if (!currentCall) return;
+ twpPost({
+ action: 'twp_get_transfer_targets',
+ nonce: twpNonce
+ }, function(r) {
+ if (r.success && r.data && (r.data.users || r.data.queues)) {
+ showEnhancedTransferDialog(r.data);
+ } else {
+ twpPost({ action: 'twp_get_online_agents', nonce: twpNonce }, function(lr) {
+ if (lr.success && lr.data.length > 0) showAgentTransferDialog(lr.data);
+ else showManualTransferDialog();
+ }, function() { showManualTransferDialog(); });
+ }
+ }, function() { showManualTransferDialog(); });
+ });
+
+ $('#admin-requeue-btn').on('click', function() {
+ if (!currentCall) return;
+ twpPost({ action: 'twp_get_all_queues', nonce: twpNonce }, function(r) {
+ if (r.success && r.data.length > 0) showRequeueDialog(r.data);
+ else showNotice('No queues available', 'error');
+ }, function() { showNotice('Failed to load queues', 'error'); });
+ });
+
+ $('#admin-record-btn').on('click', function() {
+ if (!currentCall) return;
+ if (adminIsRecording) stopRecording();
+ else startRecording();
+ });
+
+ function startRecording() {
+ var sid = getCallSid();
+ if (!sid) { showNotice('Cannot determine call SID', 'error'); return; }
+ twpPost({ action: 'twp_start_recording', call_sid: sid, nonce: twpNonce }, function(r) {
+ if (r.success) {
+ adminIsRecording = true;
+ adminRecordingSid = r.data.recording_sid;
+ $('#admin-record-btn').html('⏹ Stop Rec').addClass('btn-active');
+ showNotice('Recording started', 'success');
+ } else { showNotice('Recording failed: ' + (r.data || ''), 'error'); }
+ });
+ }
+
+ function stopRecording() {
+ if (!adminRecordingSid) return;
+ var sid = getCallSid() || '';
+ twpPost({ action: 'twp_stop_recording', call_sid: sid, recording_sid: adminRecordingSid, nonce: twpNonce }, function(r) {
+ if (r.success) {
+ adminIsRecording = false;
+ adminRecordingSid = null;
+ $('#admin-record-btn').html('⏺ Record').removeClass('btn-active');
+ showNotice('Recording stopped', 'info');
+ } else { showNotice('Stop recording failed: ' + (r.data || ''), 'error'); }
+ });
+ }
+
+ // ============================================================
+ // Transfer Dialogs
+ // ============================================================
+ function closeDialog() { $('.twp-overlay, .twp-dialog').remove(); }
+
+ function showEnhancedTransferDialog(data) {
+ var html = 'Transfer Call
';
+
+ if (data.users && data.users.length > 0) {
+ html += '
Agents
';
+ data.users.forEach(function(u) {
+ var status = u.is_logged_in ? '🟢 Online' : '🔴 Offline';
+ html += '
';
+ html += '
' + u.display_name + '
Ext: ' + u.extension + '
';
+ html += '
' + status + '
';
+ });
+ }
+ if (data.queues && data.queues.length > 0) {
+ html += '
Queues
';
+ data.queues.forEach(function(q) {
+ html += '
';
+ html += '
' + q.queue_name + '
';
+ html += '
' + q.waiting_calls + ' waiting
';
+ });
+ }
+
+ html += '
Manual
';
+ html += '
';
+ html += '
';
+ html += '';
+ html += '';
+ html += '
';
+
+ $('body').append(html);
+ var selected = null;
+
+ $('.twp-dialog .agent-option, .twp-dialog .queue-option').on('click', function() {
+ $('.agent-option, .queue-option').removeClass('selected');
+ $(this).addClass('selected');
+ selected = { type: $(this).data('type'), target: $(this).data('target') };
+ $('#transfer-manual-input').val('');
+ $('#confirm-transfer').prop('disabled', false);
+ });
+
+ $('#transfer-manual-input').on('input', function() {
+ var v = $(this).val().trim();
+ if (v) {
+ $('.agent-option, .queue-option').removeClass('selected');
+ selected = { type: /^\d{3,4}$/.test(v) ? 'extension' : 'phone', target: v };
+ $('#confirm-transfer').prop('disabled', false);
+ }
+ });
+
+ $('#confirm-transfer').on('click', function() { if (selected) executeTransfer(selected.type, selected.target); });
+ $('.close-dialog, .twp-overlay').on('click', closeDialog);
+ }
+
+ function showAgentTransferDialog(agents) {
+ var html = 'Transfer to Agent
';
+ agents.forEach(function(a) {
+ var st = a.is_available ? '🟢' : '🔴';
+ html += '
';
+ html += '
' + a.name + '' + st + '
';
+ });
+ html += '
Or enter number:
';
+ html += '
';
+ html += '
';
+ html += '';
+ html += '';
+ html += '
';
+ $('body').append(html);
+
+ var sel = null;
+ $('.agent-option').on('click', function() {
+ $('.agent-option').removeClass('selected');
+ $(this).addClass('selected');
+ sel = { id: $(this).data('agent-id'), method: $(this).data('method'), value: $(this).data('value') };
+ $('#transfer-manual-input').val('');
+ $('#confirm-transfer').prop('disabled', false);
+ });
+ $('#transfer-manual-input').on('input', function() {
+ if ($(this).val().trim()) { sel = null; $('#confirm-transfer').prop('disabled', false); }
+ });
+ $('#confirm-transfer').on('click', function() {
+ var manual = $('#transfer-manual-input').val().trim();
+ if (manual) transferToNumber(manual);
+ else if (sel) transferToAgent(sel);
+ });
+ $('.close-dialog, .twp-overlay').on('click', closeDialog);
+ }
+
+ function showManualTransferDialog() {
+ var html = 'Transfer Call
';
+ html += '
Enter phone number:
';
+ html += '
';
+ html += '
';
+ html += '';
+ html += '';
+ html += '
';
+ $('body').append(html);
+
+ $('#confirm-transfer').on('click', function() {
+ var n = $('#transfer-manual-input').val().trim();
+ if (n) transferToNumber(n);
+ });
+ $('.close-dialog, .twp-overlay').on('click', closeDialog);
+ }
+
+ function executeTransfer(type, target) {
+ var sid = getCallSid();
+ if (!sid) { showNotice('No call SID', 'error'); return; }
+
+ var data = { action: 'twp_transfer_call', call_sid: sid, nonce: twpNonce };
+ if (/^\d{3,4}$/.test(target)) data.target_queue_id = target;
+ else { data.transfer_type = 'phone'; data.transfer_target = target; }
+
+ twpPost(data, function(r) {
+ if (r.success) { showNotice('Call transferred', 'success'); closeDialog(); }
+ else showNotice('Transfer failed: ' + (r.data || ''), 'error');
+ }, function() { showNotice('Transfer failed', 'error'); });
+ }
+
+ function transferToNumber(num) {
+ var sid = getCallSid();
+ if (!sid) return;
+ twpPost({ action: 'twp_transfer_call', call_sid: sid, agent_number: num, nonce: twpNonce }, function(r) {
+ if (r.success) { showNotice('Call transferred', 'success'); closeDialog(); if (currentCall) currentCall.disconnect(); }
+ else showNotice('Transfer failed: ' + (r.data || ''), 'error');
+ }, function() { showNotice('Transfer failed', 'error'); });
+ }
+
+ function transferToAgent(agent) {
+ var sid = getCallSid();
+ if (!sid) return;
+ twpPost({ action: 'twp_transfer_to_agent_queue', call_sid: sid, agent_id: agent.id, transfer_method: agent.method, transfer_value: agent.value, nonce: twpNonce }, function(r) {
+ if (r.success) { showNotice('Call transferred', 'success'); closeDialog(); if (currentCall) currentCall.disconnect(); }
+ else showNotice('Transfer failed: ' + (r.data || ''), 'error');
+ }, function() { showNotice('Transfer failed', 'error'); });
+ }
+
+ // ============================================================
+ // Requeue Dialog
+ // ============================================================
+ function showRequeueDialog(queues) {
+ var html = 'Requeue Call
';
+ html += '
Select a queue:
';
+ queues.forEach(function(q) {
+ html += '
' + q.queue_name + '
';
+ });
+ html += '
';
+ html += '';
+ html += '';
+ html += '
';
+ $('body').append(html);
+
+ var selQ = null;
+ $('.twp-dialog .queue-option').on('click', function() {
+ $('.queue-option').removeClass('selected');
+ $(this).addClass('selected');
+ selQ = $(this).data('queue-id');
+ $('#confirm-requeue').prop('disabled', false);
+ });
+ $('#confirm-requeue').on('click', function() {
+ if (!selQ) return;
+ var sid = getCallSid();
+ if (!sid) return;
+ twpPost({ action: 'twp_requeue_call', call_sid: sid, queue_id: selQ, nonce: twpNonce }, function(r) {
+ if (r.success) { showNotice('Call requeued', 'success'); closeDialog(); if (currentCall) currentCall.disconnect(); }
+ else showNotice('Requeue failed: ' + (r.data || ''), 'error');
+ }, function() { showNotice('Requeue failed', 'error'); });
+ });
+ $('.close-dialog, .twp-overlay').on('click', closeDialog);
+ }
+
+ // ============================================================
+ // Call History (Recent tab)
+ // ============================================================
+ function addToCallHistory(number, direction, duration) {
+ if (!number || number === 'Unknown') return;
+ callHistory.unshift({
+ number: number,
+ direction: direction || 'outbound',
+ time: new Date(),
+ duration: duration || '00:00'
+ });
+ // Keep last 50 entries
+ if (callHistory.length > 50) callHistory.pop();
+ renderCallHistory();
+ }
+
+ function renderCallHistory() {
+ var $list = $('#recent-call-list');
+ if (callHistory.length === 0) {
+ $list.html('No calls yet this session.
');
+ return;
+ }
+ var h = '';
+ callHistory.forEach(function(entry, idx) {
+ var icon = entry.direction === 'inbound' ? '📥' : '📤';
+ var dirLabel = entry.direction === 'inbound' ? 'Inbound' : 'Outbound';
+ var timeStr = entry.time.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
+ h += '';
+ h += '
' + icon + '
';
+ h += '
';
+ h += '
' + entry.number + '
';
+ h += '
' + dirLabel + '' + timeStr + '' + entry.duration + '
';
+ h += '
';
+ h += '
';
+ h += '
';
+ });
+ $list.html(h);
+ }
+
+ $(document).on('click', '.recent-item', function() {
+ var num = $(this).data('number');
+ if (num) {
+ $('#phone-number-input').val(num);
+ switchTab('phone');
+ }
+ });
+
+ $(document).on('click', '.recent-callback', function(e) {
+ e.stopPropagation();
+ var num = $(this).data('number');
+ if (num) {
+ $('#phone-number-input').val(num);
+ switchTab('phone');
+ }
+ });
+
+ $('#clear-history-btn').on('click', function() {
+ callHistory = [];
+ renderCallHistory();
+ });
+
+ // ============================================================
+ // Queue Management
+ // ============================================================
+ var adminUserQueues = [];
+
+ function loadAdminQueues() {
+ twpPost({ action: 'twp_get_agent_queues', nonce: twpNonce }, function(r) {
+ if (r.success) { adminUserQueues = r.data; displayAdminQueues(); }
+ else { $('#admin-queue-list').html('Failed to load queues
'); }
+ }, function() { $('#admin-queue-list').html('Failed to load queues
'); });
+ }
+
+ function displayAdminQueues() {
+ var $list = $('#admin-queue-list');
+ if (adminUserQueues.length === 0) { $list.html('No queues assigned.
'); return; }
+
+ var h = '';
+ adminUserQueues.forEach(function(q) {
+ var hasW = parseInt(q.current_waiting) > 0;
+ var wc = q.current_waiting || 0;
+ var qt = q.queue_type || 'general';
+ var icon = qt === 'personal' ? '👤' : qt === 'hold' ? '⏸' : '📋';
+ var desc = qt === 'personal' ? (q.extension ? ' (Ext: ' + q.extension + ')' : '') : qt === 'hold' ? ' (Hold)' : ' (Team)';
+
+ h += '';
+ h += '
' + icon + ' ' + q.queue_name + desc + '
';
+ h += '
' + wc + ' waiting';
+ h += 'Max: ' + q.max_size + '
';
+ h += '
';
+ h += '
';
+ });
+ $list.html(h);
+ }
+
+ $(document).on('click', '.accept-queue-call', function() {
+ var qid = $(this).data('queue-id');
+ var $btn = $(this);
+ $btn.prop('disabled', true).text('...');
+ twpPost({ action: 'twp_accept_next_queue_call', queue_id: qid, nonce: twpNonce }, function(r) {
+ if (r.success) { showNotice('Connecting to caller...', 'success'); setTimeout(loadAdminQueues, 1000); }
+ else showNotice(r.data || 'No calls waiting', 'info');
+ }, function() { showNotice('Failed to accept call', 'error'); });
+ $btn.prop('disabled', false).text('Accept');
+ });
+
+ $('#admin-refresh-queues').on('click', loadAdminQueues);
+
+ // Load queues immediately and poll every 5 seconds.
+ loadAdminQueues();
+ setInterval(loadAdminQueues, 5000);
+
+ // ============================================================
+ // Mode Switching
+ // ============================================================
+ $('input[name="call_mode"]').on('change', function() {
+ var sel = $(this).val();
+ var cur = $('#mode-text').text().indexOf('Browser') !== -1 ? 'browser' : 'cell';
+ if (sel !== cur) {
+ $('#save-mode-btn').show();
+ $('.mode-option').removeClass('active');
+ $(this).closest('.mode-option').addClass('active');
+ $('#mode-text').text((sel === 'browser' ? 'Browser Phone' : 'Cell Phone') + ' (unsaved)').css('color', 'var(--warning)');
+ $('.mode-info > div').hide();
+ $('.' + sel + '-mode-info').show();
+ }
+ });
+
+ $('#save-mode-btn').on('click', function() {
+ var $btn = $(this);
+ var sel = $('input[name="call_mode"]:checked').val();
+ $btn.prop('disabled', true).text('...');
+ twpPost({ action: 'twp_save_call_mode', mode: sel, nonce: twpNonce }, function(r) {
+ if (r.success) {
+ $('#mode-text').text(sel === 'browser' ? 'Browser Phone' : 'Cell Phone').css('color', '');
+ $('#save-mode-btn').hide();
+ showNotice('Call mode saved', 'success');
+ } else { showNotice('Failed to save mode', 'error'); }
+ }, function() { showNotice('Failed to save mode', 'error'); });
+ $btn.prop('disabled', false).text('Save');
+ });
+
+ // ============================================================
+ // Agent Status Bar
+ // ============================================================
+ window.toggleAgentLogin = function() {
+ twpPost({ action: 'twp_toggle_agent_login', nonce: twpNonce }, function(r) {
+ if (r.success) location.reload();
+ else showNotice('Failed to change login status', 'error');
+ }, function() { showNotice('Failed to change login status', 'error'); });
+ };
+
+ window.updateAgentStatus = function(status) {
+ twpPost({ action: 'twp_set_agent_status', status: status, nonce: twpNonce }, function(r) {
+ if (r.success) showNotice('Status: ' + status, 'success');
+ else showNotice('Failed to update status', 'error');
+ }, function() { showNotice('Failed to update status', 'error'); });
+ };
+
+ // ============================================================
+ // Dark Mode Toggle
+ // ============================================================
+ function applyTheme(theme) {
+ var $html = $('html');
+ $html.removeClass('dark-mode light-mode');
+ if (theme === 'dark') {
+ $html.addClass('dark-mode');
+ $('meta[name="theme-color"]').attr('content', '#0f0f23');
+ } else if (theme === 'light') {
+ $html.addClass('light-mode');
+ $('meta[name="theme-color"]').attr('content', '#f5f6fa');
+ } else {
+ // System default — no class override
+ var prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
+ $('meta[name="theme-color"]').attr('content', prefersDark ? '#0f0f23' : '#1a1a2e');
+ }
+ // Update button states
+ $('.dark-mode-opt').removeClass('active');
+ $('.dark-mode-opt[data-theme="' + theme + '"]').addClass('active');
+ }
+
+ // Initialize theme from localStorage
+ var savedTheme = localStorage.getItem('twp_theme') || 'system';
+ applyTheme(savedTheme);
+
+ // Listen for system theme changes
+ window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', function() {
+ var currentTheme = localStorage.getItem('twp_theme') || 'system';
+ if (currentTheme === 'system') applyTheme('system');
+ });
+
+ // Theme option buttons
+ $('.dark-mode-opt').on('click', function() {
+ var theme = $(this).data('theme');
+ localStorage.setItem('twp_theme', theme);
+ applyTheme(theme);
+ });
+
+ // ============================================================
+ // Clipboard helper
+ // ============================================================
+ window.copyToClipboard = function(text) {
+ if (navigator.clipboard) {
+ navigator.clipboard.writeText(text).then(function() { showNotice('Copied!', 'success'); });
+ } else {
+ var ta = document.createElement('textarea');
+ ta.value = text;
+ document.body.appendChild(ta);
+ ta.select();
+ document.execCommand('copy');
+ document.body.removeChild(ta);
+ showNotice('Copied!', 'success');
+ }
+ };
+
+ // ============================================================
+ // Initialize
+ // ============================================================
+ $(window).on('beforeunload', function() {
+ if (tokenRefreshTimer) clearTimeout(tokenRefreshTimer);
+ releaseWakeLock();
+ stopDeviceKeepalive();
+ if (device) device.destroy();
+ });
+
+ // SDK init
+ var sdkAttempts = 0;
+ function checkAndInit() {
+ sdkAttempts++;
+ if (typeof Twilio !== 'undefined' && Twilio.Device) { initializeBrowserPhone(); }
+ else if (sdkAttempts < 100) { setTimeout(checkAndInit, 50); }
+ else { showError('Twilio Voice SDK failed to load. Check internet connection.'); }
+ }
+
+ if (typeof Twilio !== 'undefined' && Twilio.Device) initializeBrowserPhone();
+ else checkAndInit();
+
+ $(window).on('load', function() {
+ if (typeof Twilio !== 'undefined' && !device) initializeBrowserPhone();
+ // Signal Flutter that page is fully loaded.
+ notifyFlutterReady();
+ });
+
+})(jQuery);
diff --git a/includes/class-twp-auto-updater.php b/includes/class-twp-auto-updater.php
index a09f4cf..46470f0 100644
--- a/includes/class-twp-auto-updater.php
+++ b/includes/class-twp-auto-updater.php
@@ -177,7 +177,6 @@ class TWP_Auto_Updater {
$response = curl_exec($ch);
$http_code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
- curl_close($ch);
if (!$response || $http_code !== 200) {
error_log("TWP Auto-Updater: Gitea API returned status $http_code");
diff --git a/includes/class-twp-call-queue.php b/includes/class-twp-call-queue.php
index ee71b0c..87e6955 100644
--- a/includes/class-twp-call-queue.php
+++ b/includes/class-twp-call-queue.php
@@ -577,6 +577,69 @@ 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
*/
diff --git a/includes/class-twp-core.php b/includes/class-twp-core.php
index 2cc9b26..99d0b01 100644
--- a/includes/class-twp-core.php
+++ b/includes/class-twp-core.php
@@ -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
@@ -254,13 +255,19 @@ 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');
$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');
}
diff --git a/includes/class-twp-deactivator.php b/includes/class-twp-deactivator.php
index a1fcd74..687b8a5 100644
--- a/includes/class-twp-deactivator.php
+++ b/includes/class-twp-deactivator.php
@@ -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
diff --git a/includes/class-twp-fcm.php b/includes/class-twp-fcm.php
index 84a494e..9112ef6 100644
--- a/includes/class-twp-fcm.php
+++ b/includes/class-twp-fcm.php
@@ -119,7 +119,6 @@ class TWP_FCM {
$response = curl_exec($ch);
$http_code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
- curl_close($ch);
if ($http_code !== 200) {
error_log("TWP FCM: Failed to send notification. HTTP $http_code: $response");
@@ -173,7 +172,6 @@ class TWP_FCM {
$response = curl_exec($ch);
$http_code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
- curl_close($ch);
if ($http_code !== 200) {
error_log("TWP FCM: Failed to get access token. HTTP $http_code: $response");
diff --git a/includes/class-twp-mobile-phone-page.php b/includes/class-twp-mobile-phone-page.php
new file mode 100644
index 0000000..740439f
--- /dev/null
+++ b/includes/class-twp-mobile-phone-page.php
@@ -0,0 +1,226 @@
+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';
+ }
+}
diff --git a/includes/class-twp-webhooks.php b/includes/class-twp-webhooks.php
index aec5f5c..4e11c3a 100644
--- a/includes/class-twp-webhooks.php
+++ b/includes/class-twp-webhooks.php
@@ -1276,10 +1276,24 @@ 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);
}
-
+
// Return empty response - this is just for tracking
return $this->send_twiml_response('');
}
diff --git a/mobile/android/app/proguard-rules.pro b/mobile/android/app/proguard-rules.pro
index 4d6d06e..25923c7 100644
--- a/mobile/android/app/proguard-rules.pro
+++ b/mobile/android/app/proguard-rules.pro
@@ -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.**
diff --git a/mobile/android/app/src/main/AndroidManifest.xml b/mobile/android/app/src/main/AndroidManifest.xml
index 9f22cb5..c63ef57 100644
--- a/mobile/android/app/src/main/AndroidManifest.xml
+++ b/mobile/android/app/src/main/AndroidManifest.xml
@@ -1,19 +1,17 @@
-
+
-
+
-
-
@@ -23,7 +21,7 @@
-
-
-
-
-
-
{
- final _apiClient = ApiClient();
+ static const _storage = FlutterSecureStorage();
+ String? _serverUrl;
+ bool _loading = true;
+
+ @override
+ void initState() {
+ super.initState();
+ _checkSavedSession();
+ }
+
+ Future _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(
- title: 'TWP Softphone',
- debugShowCheckedModeBanner: false,
- theme: ThemeData(
- colorSchemeSeed: Colors.blue,
- useMaterial3: true,
- brightness: Brightness.light,
- ),
- darkTheme: ThemeData(
- colorSchemeSeed: Colors.blue,
- useMaterial3: true,
- brightness: Brightness.dark,
- ),
- home: Consumer(
- 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();
- },
- ),
+ return MaterialApp(
+ title: 'TWP Softphone',
+ debugShowCheckedModeBanner: false,
+ theme: ThemeData(
+ colorSchemeSeed: Colors.blue,
+ useMaterial3: true,
+ brightness: Brightness.light,
),
+ darkTheme: ThemeData(
+ colorSchemeSeed: Colors.blue,
+ useMaterial3: true,
+ brightness: Brightness.dark,
+ ),
+ home: _loading
+ ? const Scaffold(
+ body: Center(child: CircularProgressIndicator()),
+ )
+ : _serverUrl != null
+ ? PhoneScreen(
+ serverUrl: _serverUrl!,
+ onLogout: _onLogout,
+ onSessionExpired: _onSessionExpired,
+ )
+ : LoginScreen(onLoginSuccess: _onLoginSuccess),
);
}
}
diff --git a/mobile/lib/config/app_config.dart b/mobile/lib/config/app_config.dart
deleted file mode 100644
index 41dc521..0000000
--- a/mobile/lib/config/app_config.dart
+++ /dev/null
@@ -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';
-}
diff --git a/mobile/lib/models/agent_status.dart b/mobile/lib/models/agent_status.dart
deleted file mode 100644
index 0f58aee..0000000
--- a/mobile/lib/models/agent_status.dart
+++ /dev/null
@@ -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 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;
- }
- }
-}
diff --git a/mobile/lib/models/call_info.dart b/mobile/lib/models/call_info.dart
deleted file mode 100644
index 72b68db..0000000
--- a/mobile/lib/models/call_info.dart
+++ /dev/null
@@ -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;
-}
diff --git a/mobile/lib/models/queue_state.dart b/mobile/lib/models/queue_state.dart
deleted file mode 100644
index 748674e..0000000
--- a/mobile/lib/models/queue_state.dart
+++ /dev/null
@@ -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 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 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;
- }
-}
diff --git a/mobile/lib/models/user.dart b/mobile/lib/models/user.dart
deleted file mode 100644
index d535ca3..0000000
--- a/mobile/lib/models/user.dart
+++ /dev/null
@@ -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 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;
- }
-}
diff --git a/mobile/lib/providers/agent_provider.dart b/mobile/lib/providers/agent_provider.dart
deleted file mode 100644
index 0586dd1..0000000
--- a/mobile/lib/providers/agent_provider.dart
+++ /dev/null
@@ -1,132 +0,0 @@
-import 'dart:async';
-import 'package:dio/dio.dart';
-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 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 _queues = [];
- bool _sseConnected = false;
- List _phoneNumbers = [];
- StreamSubscription? _sseSub;
- StreamSubscription? _connSub;
- Timer? _refreshTimer;
-
- AgentStatus? get status => _status;
- List get queues => _queues;
- bool get sseConnected => _sseConnected;
- List get phoneNumbers => _phoneNumbers;
-
- AgentProvider(this._api, this._sse) {
- _connSub = _sse.connectionState.listen((connected) {
- _sseConnected = connected;
- notifyListeners();
- });
-
- _sseSub = _sse.events.listen(_handleSseEvent);
-
- _refreshTimer = Timer.periodic(
- const Duration(seconds: 15),
- (_) => fetchQueues(),
- );
- }
-
- Future fetchStatus() async {
- try {
- final response = await _api.dio.get('/agent/status');
- _status = AgentStatus.fromJson(response.data);
- notifyListeners();
- } catch (e) {
- debugPrint('AgentProvider.fetchStatus error: $e');
- if (e is DioException) debugPrint(' response: ${e.response?.data}');
- }
- }
-
- Future 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');
- if (e is DioException) {
- debugPrint('AgentProvider.updateStatus response: ${e.response?.data}');
- }
- }
- }
-
- Future 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))
- .toList();
- notifyListeners();
- } catch (e) {
- debugPrint('AgentProvider.fetchQueues error: $e');
- if (e is DioException) debugPrint(' response: ${e.response?.data}');
- }
- }
-
- Future 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))
- .toList();
- notifyListeners();
- } catch (e) {
- debugPrint('AgentProvider.fetchPhoneNumbers error: $e');
- }
- }
-
- Future 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() {
- _refreshTimer?.cancel();
- _sseSub?.cancel();
- _connSub?.cancel();
- super.dispose();
- }
-}
diff --git a/mobile/lib/providers/auth_provider.dart b/mobile/lib/providers/auth_provider.dart
deleted file mode 100644
index d53442c..0000000
--- a/mobile/lib/providers/auth_provider.dart
+++ /dev/null
@@ -1,122 +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 AuthService _authService;
- late VoiceService _voiceService;
- late PushNotificationService _pushService;
- late 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 tryRestoreSession() async {
- final user = await _authService.tryRestoreSession();
- if (user != null) {
- _user = user;
- _state = AuthState.authenticated;
- await _initializeServices();
- notifyListeners();
- }
- }
-
- Future 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 _initializeServices() async {
- try {
- await _pushService.initialize();
- } catch (e) {
- debugPrint('AuthProvider: push service init error: $e');
- }
- try {
- await _voiceService.initialize(deviceToken: _pushService.fcmToken);
- } catch (e) {
- debugPrint('AuthProvider: voice service init error: $e');
- }
- try {
- await _sseService.connect();
- } catch (e) {
- debugPrint('AuthProvider: SSE connect error: $e');
- }
- }
-
- Future 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() {
- _voiceService.dispose();
- _sseService.disconnect();
-
- _state = AuthState.unauthenticated;
- _user = null;
- _error = 'Session expired. Please log in again.';
-
- // Re-create services for potential re-login
- _voiceService = VoiceService(_apiClient);
- _pushService = PushNotificationService(_apiClient);
- _sseService = SseService(_apiClient);
-
- notifyListeners();
- }
-
- @override
- void dispose() {
- _authService.dispose();
- _voiceService.dispose();
- _sseService.dispose();
- super.dispose();
- }
-}
diff --git a/mobile/lib/providers/call_provider.dart b/mobile/lib/providers/call_provider.dart
deleted file mode 100644
index 8093242..0000000
--- a/mobile/lib/providers/call_provider.dart
+++ /dev/null
@@ -1,180 +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;
- bool _pendingAutoAnswer = false;
-
- CallInfo get callInfo => _callInfo;
-
- CallProvider(this._voiceService) {
- _eventSub = _voiceService.callEvents.listen(_handleCallEvent);
- }
-
- void _handleCallEvent(CallEvent event) {
- switch (event) {
- case CallEvent.incoming:
- if (_pendingAutoAnswer) {
- _pendingAutoAnswer = false;
- _callInfo = _callInfo.copyWith(state: CallState.connecting);
- _voiceService.answer();
- } else {
- _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 (skip if call just ended)
- if (_callInfo.state != CallState.idle) {
- final call = TwilioVoice.instance.call;
- final active = call.activeCall;
- if (active != null) {
- if (_callInfo.callerNumber == null) {
- _callInfo = _callInfo.copyWith(
- callerNumber: active.from,
- );
- }
- // Fetch SID asynchronously
- call.getSid().then((sid) {
- if (sid != null && sid != _callInfo.callSid && _callInfo.isActive) {
- _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 answer() => _voiceService.answer();
- Future reject() => _voiceService.reject();
- Future hangUp() async {
- await _voiceService.hangUp();
- // If SDK didn't fire callEnded (e.g. no active SDK call), reset manually
- if (_callInfo.state != CallState.idle) {
- _stopDurationTimer();
- _callInfo = const CallInfo();
- _pendingAutoAnswer = false;
- notifyListeners();
- }
- }
-
- Future toggleMute() async {
- final newMuted = !_callInfo.isMuted;
- await _voiceService.toggleMute(newMuted);
- _callInfo = _callInfo.copyWith(isMuted: newMuted);
- notifyListeners();
- }
-
- Future toggleSpeaker() async {
- final newSpeaker = !_callInfo.isSpeakerOn;
- await _voiceService.toggleSpeaker(newSpeaker);
- _callInfo = _callInfo.copyWith(isSpeakerOn: newSpeaker);
- notifyListeners();
- }
-
- Future sendDigits(String digits) => _voiceService.sendDigits(digits);
-
- Future makeCall(String number, {String? callerId}) async {
- _callInfo = _callInfo.copyWith(
- state: CallState.connecting,
- callerNumber: number,
- );
- notifyListeners();
- final success = await _voiceService.makeCall(number, callerId: callerId);
- if (!success) {
- debugPrint('CallProvider.makeCall: call.place() returned false');
- _callInfo = const CallInfo(); // reset to idle
- notifyListeners();
- }
- }
-
- Future holdCall() async {
- final sid = _callInfo.callSid;
- if (sid == null) return;
- await _voiceService.holdCall(sid);
- _callInfo = _callInfo.copyWith(isOnHold: true);
- notifyListeners();
- }
-
- Future unholdCall() async {
- final sid = _callInfo.callSid;
- if (sid == null) return;
- await _voiceService.unholdCall(sid);
- _callInfo = _callInfo.copyWith(isOnHold: false);
- notifyListeners();
- }
-
- Future transferCall(String target) async {
- final sid = _callInfo.callSid;
- if (sid == null) return;
- await _voiceService.transferCall(sid, target);
- }
-
- Future acceptQueueCall(String callSid) async {
- _pendingAutoAnswer = true;
- _callInfo = _callInfo.copyWith(state: CallState.connecting);
- notifyListeners();
- try {
- await _voiceService.acceptQueueCall(callSid);
- } catch (e) {
- debugPrint('CallProvider.acceptQueueCall error: $e');
- _pendingAutoAnswer = false;
- _callInfo = const CallInfo();
- notifyListeners();
- }
- }
-
- @override
- void dispose() {
- _stopDurationTimer();
- _eventSub?.cancel();
- super.dispose();
- }
-}
diff --git a/mobile/lib/screens/active_call_screen.dart b/mobile/lib/screens/active_call_screen.dart
deleted file mode 100644
index e8eb129..0000000
--- a/mobile/lib/screens/active_call_screen.dart
+++ /dev/null
@@ -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 createState() => _ActiveCallScreenState();
-}
-
-class _ActiveCallScreenState extends State {
- bool _showDialpad = false;
-
- @override
- Widget build(BuildContext context) {
- final call = context.watch();
- 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'),
- ),
- ],
- ),
- );
- }
-}
diff --git a/mobile/lib/screens/dashboard_screen.dart b/mobile/lib/screens/dashboard_screen.dart
deleted file mode 100644
index 1fd740e..0000000
--- a/mobile/lib/screens/dashboard_screen.dart
+++ /dev/null
@@ -1,374 +0,0 @@
-import 'dart:io';
-import 'package:flutter/foundation.dart';
-import 'package:flutter/material.dart';
-import 'package:flutter_local_notifications/flutter_local_notifications.dart';
-import 'package:provider/provider.dart';
-import 'package:twilio_voice/twilio_voice.dart';
-import '../models/queue_state.dart';
-import '../providers/agent_provider.dart';
-import '../providers/auth_provider.dart';
-import '../providers/call_provider.dart';
-import '../widgets/agent_status_toggle.dart';
-import '../widgets/dialpad.dart';
-import '../widgets/queue_card.dart';
-import 'settings_screen.dart';
-
-class DashboardScreen extends StatefulWidget {
- const DashboardScreen({super.key});
-
- @override
- State createState() => _DashboardScreenState();
-}
-
-class _DashboardScreenState extends State {
- bool _phoneAccountEnabled = true; // assume true until checked
-
- @override
- void initState() {
- super.initState();
- WidgetsBinding.instance.addPostFrameCallback((_) {
- context.read().refresh();
- _checkPhoneAccount();
- });
- }
-
- Future _checkPhoneAccount() async {
- if (!kIsWeb && Platform.isAndroid) {
- final enabled = await TwilioVoice.instance.isPhoneAccountEnabled();
- if (mounted && !enabled) {
- setState(() => _phoneAccountEnabled = false);
- _showPhoneAccountDialog();
- } else if (mounted) {
- setState(() => _phoneAccountEnabled = true);
- }
- }
- }
-
- void _showPhoneAccountDialog() {
- showDialog(
- context: context,
- barrierDismissible: false,
- builder: (ctx) => AlertDialog(
- title: const Text('Enable Phone Account'),
- content: const Text(
- 'TWP Softphone needs to be enabled as a calling account to make and receive calls.\n\n'
- 'Tap "Open Settings" below, then find "TWP Softphone" in the list and toggle it ON.',
- ),
- actions: [
- TextButton(
- onPressed: () => Navigator.pop(ctx),
- child: const Text('Later'),
- ),
- FilledButton(
- onPressed: () async {
- Navigator.pop(ctx);
- await TwilioVoice.instance.openPhoneAccountSettings();
- // Poll until enabled or user comes back
- for (int i = 0; i < 30; i++) {
- await Future.delayed(const Duration(seconds: 1));
- if (!mounted) return;
- final enabled = await TwilioVoice.instance.isPhoneAccountEnabled();
- if (enabled) {
- setState(() => _phoneAccountEnabled = true);
- return;
- }
- }
- // Re-check one more time when coming back
- _checkPhoneAccount();
- },
- child: const Text('Open Settings'),
- ),
- ],
- ),
- );
- }
-
- void _showDialer(BuildContext context) {
- final numberController = TextEditingController();
- final phoneNumbers = context.read().phoneNumbers;
- // Auto-select first phone number as caller ID
- String? selectedCallerId =
- phoneNumbers.isNotEmpty ? phoneNumbers.first.phoneNumber : null;
-
- showModalBottomSheet(
- context: context,
- isScrollControlled: true,
- shape: const RoundedRectangleBorder(
- borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
- ),
- builder: (ctx) {
- 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 (only if multiple numbers)
- if (phoneNumbers.length > 1) ...[
- const SizedBox(height: 12),
- DropdownButtonFormField(
- initialValue: selectedCallerId,
- decoration: const InputDecoration(
- labelText: 'Caller ID',
- isDense: true,
- contentPadding: EdgeInsets.symmetric(horizontal: 12, vertical: 8),
- ),
- items: phoneNumbers.map((p) => DropdownMenuItem(
- value: p.phoneNumber,
- child: Text('${p.friendlyName} (${p.phoneNumber})'),
- )).toList(),
- onChanged: (value) {
- setSheetState(() {
- selectedCallerId = value;
- });
- },
- ),
- ] else if (phoneNumbers.length == 1) ...[
- const SizedBox(height: 8),
- Text(
- 'Caller ID: ${phoneNumbers.first.phoneNumber}',
- style: Theme.of(ctx).textTheme.bodySmall?.copyWith(
- color: Theme.of(ctx).colorScheme.onSurfaceVariant,
- ),
- ),
- ],
- 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.isEmpty) return;
- if (selectedCallerId == null) {
- ScaffoldMessenger.of(context).showSnackBar(
- const SnackBar(content: Text('No caller ID available. Add a phone number first.')),
- );
- return;
- }
- context.read().makeCall(number, callerId: selectedCallerId);
- Navigator.pop(ctx);
- },
- ),
- const SizedBox(height: 16),
- ],
- ),
- );
- },
- );
- },
- );
- }
-
- void _showQueueCalls(BuildContext context, QueueInfo queue) {
- final voiceService = context.read().voiceService;
- final callProvider = context.read();
-
- showModalBottomSheet(
- context: context,
- shape: const RoundedRectangleBorder(
- borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
- ),
- builder: (ctx) {
- return FutureBuilder>>(
- future: voiceService.getQueueCalls(queue.id),
- builder: (context, snapshot) {
- if (snapshot.connectionState == ConnectionState.waiting) {
- return const Padding(
- padding: EdgeInsets.all(32),
- child: Center(child: CircularProgressIndicator()),
- );
- }
-
- if (snapshot.hasError) {
- return Padding(
- padding: const EdgeInsets.all(24),
- child: Center(
- child: Text('Error loading calls: ${snapshot.error}'),
- ),
- );
- }
-
- final calls = (snapshot.data ?? [])
- .map((c) => QueueCall.fromJson(c))
- .toList();
-
- if (calls.isEmpty) {
- return const Padding(
- padding: EdgeInsets.all(24),
- child: Center(child: Text('No calls waiting')),
- );
- }
-
- return Padding(
- padding: const EdgeInsets.symmetric(vertical: 16),
- child: Column(
- mainAxisSize: MainAxisSize.min,
- crossAxisAlignment: CrossAxisAlignment.start,
- children: [
- Padding(
- padding: const EdgeInsets.symmetric(horizontal: 16),
- child: Text(
- '${queue.name} - Waiting Calls',
- style: Theme.of(context).textTheme.titleMedium,
- ),
- ),
- const SizedBox(height: 8),
- ...calls.map((call) => ListTile(
- leading: const CircleAvatar(
- child: Icon(Icons.phone_in_talk),
- ),
- title: Text(call.fromNumber),
- subtitle: Text('Waiting ${_formatWaitTime(call.waitTime)}'),
- trailing: FilledButton.icon(
- icon: const Icon(Icons.call, size: 18),
- label: const Text('Accept'),
- onPressed: () {
- Navigator.pop(ctx);
- callProvider.acceptQueueCall(call.callSid);
- // Cancel queue alert notification
- FlutterLocalNotificationsPlugin().cancel(9001);
- },
- ),
- )),
- ],
- ),
- );
- },
- );
- },
- );
- }
-
- String _formatWaitTime(int seconds) {
- if (seconds < 60) return '${seconds}s';
- final minutes = seconds ~/ 60;
- final secs = seconds % 60;
- return '${minutes}m ${secs}s';
- }
-
- @override
- Widget build(BuildContext context) {
- final agent = context.watch();
-
- // Android Telecom framework handles the call UI via the native InCallUI,
- // so we don't navigate to our own ActiveCallScreen.
-
- 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: [
- if (!_phoneAccountEnabled)
- Card(
- color: Colors.orange.shade50,
- child: ListTile(
- leading: Icon(Icons.warning, color: Colors.orange.shade700),
- title: const Text('Phone Account Not Enabled'),
- subtitle: const Text('Tap to enable calling in settings'),
- trailing: const Icon(Icons.chevron_right),
- onTap: () => _showPhoneAccountDialog(),
- ),
- ),
- if (!_phoneAccountEnabled) const SizedBox(height: 8),
- 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,
- onTap: q.waitingCount > 0
- ? () => _showQueueCalls(context, q)
- : null,
- ),
- )),
- ],
- ),
- ),
- );
- }
-}
diff --git a/mobile/lib/screens/login_screen.dart b/mobile/lib/screens/login_screen.dart
index 13f4c27..a29338e 100644
--- a/mobile/lib/screens/login_screen.dart
+++ b/mobile/lib/screens/login_screen.dart
@@ -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 createState() => _LoginScreenState();
}
class _LoginScreenState extends State {
+ static const _storage = FlutterSecureStorage();
+
final _formKey = GlobalKey();
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,40 +32,107 @@ class _LoginScreenState extends State {
}
Future _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().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 _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();
+ 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(
+ child: Form(
key: _formKey,
child: Column(
mainAxisSize: MainAxisSize.min,
@@ -87,42 +161,10 @@ class _LoginScreenState extends State {
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,23 +174,13 @@ class _LoginScreenState extends State {
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'),
),
),
],
),
),
- ),
),
),
),
@@ -158,8 +190,6 @@ class _LoginScreenState extends State {
@override
void dispose() {
_serverController.dispose();
- _usernameController.dispose();
- _passwordController.dispose();
super.dispose();
}
}
diff --git a/mobile/lib/screens/phone_screen.dart b/mobile/lib/screens/phone_screen.dart
new file mode 100644
index 0000000..acd3e96
--- /dev/null
+++ b/mobile/lib/screens/phone_screen.dart
@@ -0,0 +1,350 @@
+import 'package:flutter/material.dart';
+import 'package:permission_handler/permission_handler.dart';
+import 'package:wakelock_plus/wakelock_plus.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 createState() => _PhoneScreenState();
+}
+
+class _PhoneScreenState extends State 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 _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();
+ WakelockPlus.enable();
+ debugPrint('TWP: Wake lock enabled');
+ }
+
+ @override
+ void didChangeAppLifecycleState(AppLifecycleState state) {
+ if (state == AppLifecycleState.resumed) {
+ debugPrint('TWP: App resumed — re-enabling wake lock');
+ WakelockPlus.enable();
+ // Dispatch synthetic visibilitychange to trigger JS-side recovery
+ _controller.runJavaScript(
+ 'document.dispatchEvent(new Event("visibilitychange"));'
+ 'Object.defineProperty(document, "hidden", {value: false, configurable: true});',
+ );
+ }
+ }
+
+ @override
+ void dispose() {
+ WakelockPlus.disable();
+ debugPrint('TWP: Wake lock disabled');
+ WidgetsBinding.instance.removeObserver(this);
+ super.dispose();
+ }
+
+ Future _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 _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 _retry() async {
+ setState(() {
+ _hasError = false;
+ _loading = true;
+ });
+ final phoneUrl = '${widget.serverUrl}/twp-phone/';
+ await _controller.loadRequest(Uri.parse(phoneUrl));
+ }
+
+ Future _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(
+ 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(
+ 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(
+ 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'),
+ ),
+ ],
+ ),
+ ),
+ );
+ }
+}
diff --git a/mobile/lib/screens/settings_screen.dart b/mobile/lib/screens/settings_screen.dart
deleted file mode 100644
index 9c0e950..0000000
--- a/mobile/lib/screens/settings_screen.dart
+++ /dev/null
@@ -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 createState() => _SettingsScreenState();
-}
-
-class _SettingsScreenState extends State {
- String? _serverUrl;
-
- @override
- void initState() {
- super.initState();
- _loadServerUrl();
- }
-
- Future _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();
-
- 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);
- }
- },
- ),
- ],
- ),
- );
- }
-}
diff --git a/mobile/lib/services/api_client.dart b/mobile/lib/services/api_client.dart
deleted file mode 100644
index 59d519f..0000000
--- a/mobile/lib/services/api_client.dart
+++ /dev/null
@@ -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 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 restoreBaseUrl() async {
- final url = await _storage.read(key: 'server_url');
- if (url != null) {
- dio.options.baseUrl = '$url/wp-json/twilio-mobile/v1';
- }
- }
-
- Future _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();
diff --git a/mobile/lib/services/auth_service.dart b/mobile/lib/services/auth_service.dart
deleted file mode 100644
index ee08fc9..0000000
--- a/mobile/lib/services/auth_service.dart
+++ /dev/null
@@ -1,108 +0,0 @@
-import 'dart:async';
-import 'dart:convert';
-import 'package:dio/dio.dart';
-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 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,
- },
- options: Options(receiveTimeout: const Duration(seconds: 60)),
- );
-
- 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']);
- await _storage.write(key: 'user_data', value: jsonEncode(data['user']));
-
- _scheduleRefresh(data['expires_in'] as int? ?? 3600);
-
- return User.fromJson(data['user']);
- }
-
- Future tryRestoreSession() async {
- final token = await _storage.read(key: 'access_token');
- if (token == null) return null;
-
- await _api.restoreBaseUrl();
- if (_api.dio.options.baseUrl.isEmpty) return null;
-
- try {
- final response = await _api.dio.get('/agent/status');
- if (response.statusCode != 200) return null;
-
- final userData = await _storage.read(key: 'user_data');
- if (userData != null) {
- return User.fromJson(jsonDecode(userData) as Map);
- }
- return null;
- } catch (_) {
- return null;
- }
- }
-
- Future 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 logout() async {
- _refreshTimer?.cancel();
- try {
- await _api.dio.post('/auth/logout');
- } catch (_) {}
- await _storage.deleteAll();
- }
-
- void dispose() {
- _refreshTimer?.cancel();
- }
-}
diff --git a/mobile/lib/services/push_notification_service.dart b/mobile/lib/services/push_notification_service.dart
index d5a468a..2da6ad6 100644
--- a/mobile/lib/services/push_notification_service.dart
+++ b/mobile/lib/services/push_notification_service.dart
@@ -3,12 +3,11 @@ 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.
+/// Background handler -- must be top-level function.
@pragma('vm:entry-point')
Future _firebaseMessagingBackgroundHandler(RemoteMessage message) async {
await Firebase.initializeApp();
@@ -21,7 +20,6 @@ Future _firebaseMessagingBackgroundHandler(RemoteMessage message) async {
final plugin = FlutterLocalNotificationsPlugin();
await plugin.cancel(_queueAlertNotificationId);
}
- // VoIP pushes handled natively by twilio_voice plugin.
}
/// Show an insistent queue alert notification (works from background handler too).
@@ -57,8 +55,12 @@ Future _showQueueAlertNotification(Map data) async {
);
}
+/// 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();
@@ -66,8 +68,6 @@ class PushNotificationService {
String? get fcmToken => _fcmToken;
- PushNotificationService(this._api);
-
Future initialize() async {
FirebaseMessaging.onBackgroundMessage(_firebaseMessagingBackgroundHandler);
@@ -84,43 +84,37 @@ 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"}');
+ debugPrint(
+ 'FCM token: ${token != null ? "${token.substring(0, 20)}..." : "NULL"}');
if (token != null) {
_fcmToken = token;
- await _registerToken(token);
} else {
- debugPrint('FCM: Failed to get token - Firebase may not be configured correctly');
+ 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 _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
+ // Queue alert -- show insistent notification
if (type == 'queue_alert') {
_showQueueAlertNotification(data);
return;
}
- // Queue alert cancel — dismiss notification
+ // Queue alert cancel -- dismiss notification
if (type == 'queue_alert_cancel') {
_localNotifications.cancel(_queueAlertNotificationId);
return;
@@ -142,7 +136,7 @@ class PushNotificationService {
);
}
- /// Cancel any active queue alert (called when agent accepts a call in-app).
+ /// Cancel any active queue alert.
void cancelQueueAlert() {
_localNotifications.cancel(_queueAlertNotificationId);
}
diff --git a/mobile/lib/services/sse_service.dart b/mobile/lib/services/sse_service.dart
deleted file mode 100644
index 5fa9ddf..0000000
--- a/mobile/lib/services/sse_service.dart
+++ /dev/null
@@ -1,238 +0,0 @@
-import 'dart:async';
-import 'dart:convert';
-import 'dart:math';
-import 'package:dio/dio.dart';
-import 'package:flutter/foundation.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 data;
-
- SseEvent({required this.event, required this.data});
-}
-
-class SseService {
- final ApiClient _api;
- final FlutterSecureStorage _storage = const FlutterSecureStorage();
- final StreamController _eventController =
- StreamController.broadcast();
- final StreamController _connectionController =
- StreamController.broadcast();
-
- CancelToken? _cancelToken;
- Timer? _reconnectTimer;
- int _reconnectAttempt = 0;
- bool _shouldReconnect = true;
- int _sseFailures = 0;
- Timer? _pollTimer;
- Map? _previousPollState;
-
- Stream get events => _eventController.stream;
- Stream get connectionState => _connectionController.stream;
-
- SseService(this._api);
-
- Future connect() async {
- _shouldReconnect = true;
- _reconnectAttempt = 0;
- _sseFailures = 0;
- await _doConnect();
- }
-
- Future _doConnect() async {
- // After 2 SSE failures, fall back to polling
- if (_sseFailures >= 2) {
- debugPrint('SSE: falling back to polling after $_sseFailures failures');
- _startPolling();
- return;
- }
-
- _cancelToken?.cancel();
- _cancelToken = CancelToken();
-
- // Timer to detect if SSE stream never delivers data (Apache buffering)
- Timer? firstDataTimer;
- bool gotData = false;
-
- try {
- final token = await _storage.read(key: 'access_token');
- debugPrint('SSE: connecting via stream (attempt ${_sseFailures + 1})');
-
- firstDataTimer = Timer(const Duration(seconds: 8), () {
- if (!gotData) {
- debugPrint('SSE: no data received in 8s, cancelling');
- _cancelToken?.cancel();
- }
- });
-
- final response = await _api.dio.get(
- '/stream/events',
- options: Options(
- headers: {'Authorization': 'Bearer $token'},
- responseType: ResponseType.stream,
- receiveTimeout: Duration.zero,
- ),
- cancelToken: _cancelToken,
- );
-
- debugPrint('SSE: connected, status=${response.statusCode}');
- _connectionController.add(true);
- _reconnectAttempt = 0;
- _sseFailures = 0;
-
- final stream = response.data.stream as Stream>;
- String buffer = '';
-
- await for (final chunk in stream) {
- if (!gotData) {
- gotData = true;
- firstDataTimer.cancel();
- debugPrint('SSE: first data received');
- }
- buffer += utf8.decode(chunk);
- final lines = buffer.split('\n');
- buffer = lines.removeLast();
-
- 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;
- _eventController.add(SseEvent(event: eventName, data: data));
- } catch (_) {}
- eventName = null;
- dataStr = null;
- }
- }
- }
- } catch (e) {
- firstDataTimer?.cancel();
- // Distinguish user-initiated cancel from timeout cancel
- if (e is DioException && e.type == DioExceptionType.cancel) {
- if (!gotData && _shouldReconnect) {
- // Cancelled by our firstDataTimer — count as SSE failure
- debugPrint('SSE: stream timed out (no data), failure ${_sseFailures + 1}');
- _sseFailures++;
- _connectionController.add(false);
- } else {
- return; // User-initiated disconnect
- }
- } else {
- debugPrint('SSE: stream error: $e');
- _sseFailures++;
- _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);
- }
-
- // Polling fallback when SSE streaming doesn't work
- void _startPolling() {
- _pollTimer?.cancel();
- _previousPollState = null;
- _poll();
- _pollTimer = Timer.periodic(const Duration(seconds: 5), (_) => _poll());
- }
-
- Future _poll() async {
- if (!_shouldReconnect) return;
- try {
- final response = await _api.dio.get('/stream/poll');
- final data = Map.from(response.data);
- _connectionController.add(true);
-
- if (_previousPollState != null) {
- _diffAndEmit(_previousPollState!, data);
- }
- _previousPollState = data;
- } catch (e) {
- debugPrint('SSE poll error: $e');
- _connectionController.add(false);
- }
- }
-
- void _diffAndEmit(Map prev, Map curr) {
- final prevStatus = prev['agent_status']?.toString();
- final currStatus = curr['agent_status']?.toString();
- if (prevStatus != currStatus) {
- _eventController.add(SseEvent(
- event: 'agent_status_changed',
- data: (curr['agent_status'] as Map?) ?? {},
- ));
- }
-
- final prevQueues = prev['queues'] as Map? ?? {};
- final currQueues = curr['queues'] as Map? ?? {};
- for (final entry in currQueues.entries) {
- final currQueue = Map.from(entry.value);
- final prevQueue = prevQueues[entry.key] as Map?;
- if (prevQueue == null) {
- _eventController.add(SseEvent(event: 'queue_added', data: currQueue));
- continue;
- }
- final currCount = currQueue['waiting_count'] as int? ?? 0;
- final prevCount = prevQueue['waiting_count'] as int? ?? 0;
- if (currCount > prevCount) {
- _eventController.add(SseEvent(event: 'call_enqueued', data: currQueue));
- } else if (currCount < prevCount) {
- _eventController.add(SseEvent(event: 'call_dequeued', data: currQueue));
- }
- }
-
- final prevCall = prev['current_call']?.toString();
- final currCall = curr['current_call']?.toString();
- if (prevCall != currCall) {
- if (curr['current_call'] != null && prev['current_call'] == null) {
- _eventController.add(SseEvent(
- event: 'call_started',
- data: curr['current_call'] as Map,
- ));
- } else if (curr['current_call'] == null && prev['current_call'] != null) {
- _eventController.add(SseEvent(
- event: 'call_ended',
- data: prev['current_call'] as Map,
- ));
- }
- }
- }
-
- void disconnect() {
- _shouldReconnect = false;
- _reconnectTimer?.cancel();
- _pollTimer?.cancel();
- _pollTimer = null;
- _cancelToken?.cancel();
- _connectionController.add(false);
- }
-
- void dispose() {
- disconnect();
- _eventController.close();
- _connectionController.close();
- }
-}
diff --git a/mobile/lib/services/voice_service.dart b/mobile/lib/services/voice_service.dart
deleted file mode 100644
index 53e0155..0000000
--- a/mobile/lib/services/voice_service.dart
+++ /dev/null
@@ -1,146 +0,0 @@
-import 'dart:async';
-import 'dart:io';
-import 'package:dio/dio.dart';
-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;
- String? _deviceToken;
- StreamSubscription? _eventSubscription;
-
- final StreamController _callEventController =
- StreamController.broadcast();
- Stream get callEvents => _callEventController.stream;
-
- VoiceService(this._api);
-
- Future initialize({String? deviceToken}) async {
- _deviceToken = deviceToken;
- debugPrint('VoiceService.initialize: deviceToken=${deviceToken != null ? "present (${deviceToken.length} chars)" : "NULL"}');
-
- // Request permissions (Android telecom requires these)
- await TwilioVoice.instance.requestMicAccess();
- if (!kIsWeb && Platform.isAndroid) {
- await TwilioVoice.instance.requestReadPhoneStatePermission();
- await TwilioVoice.instance.requestReadPhoneNumbersPermission();
- await TwilioVoice.instance.requestCallPhonePermission();
- await TwilioVoice.instance.requestManageOwnCallsPermission();
- // Register phone account with Android telecom
- // (enabling is handled by dashboard UI with a user-friendly dialog)
- await TwilioVoice.instance.registerPhoneAccount();
- }
-
- // Fetch token and register
- await _fetchAndRegisterToken();
-
- // Listen for call events (only once)
- _eventSubscription ??= TwilioVoice.instance.callEventsListener.listen((event) {
- if (!_callEventController.isClosed) {
- _callEventController.add(event);
- }
- });
-
- // Refresh token every 50 minutes
- _tokenRefreshTimer?.cancel();
- _tokenRefreshTimer = Timer.periodic(
- const Duration(minutes: 50),
- (_) => _fetchAndRegisterToken(),
- );
- }
-
- Future _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,
- deviceToken: _deviceToken ?? 'no-fcm',
- );
- } catch (e) {
- debugPrint('VoiceService._fetchAndRegisterToken error: $e');
- if (e is DioException) debugPrint(' response: ${e.response?.data}');
- }
- }
-
- String? get identity => _identity;
-
- Future answer() async {
- await TwilioVoice.instance.call.answer();
- }
-
- Future reject() async {
- await TwilioVoice.instance.call.hangUp();
- }
-
- Future hangUp() async {
- await TwilioVoice.instance.call.hangUp();
- }
-
- Future toggleMute(bool mute) async {
- await TwilioVoice.instance.call.toggleMute(mute);
- }
-
- Future toggleSpeaker(bool speaker) async {
- await TwilioVoice.instance.call.toggleSpeaker(speaker);
- }
-
- Future makeCall(String to, {String? callerId}) async {
- try {
- final extraOptions = {};
- if (callerId != null && callerId.isNotEmpty) {
- extraOptions['CallerId'] = callerId;
- }
- debugPrint('VoiceService.makeCall: to=$to, from=$_identity, extras=$extraOptions');
- final result = await TwilioVoice.instance.call.place(
- to: to,
- from: _identity ?? '',
- extraOptions: extraOptions,
- ) ?? false;
- debugPrint('VoiceService.makeCall: result=$result');
- return result;
- } catch (e) {
- debugPrint('VoiceService.makeCall error: $e');
- return false;
- }
- }
-
- Future sendDigits(String digits) async {
- await TwilioVoice.instance.call.sendDigits(digits);
- }
-
- Future>> getQueueCalls(int queueId) async {
- final response = await _api.dio.get('/queues/$queueId/calls');
- return List