From 8cc6fa8c3cee1fbe3cdf843b07c6dfbc111022eb Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 6 Mar 2026 15:32:22 -0800 Subject: [PATCH] Fix queue loading, null-safe models, autofill, and add outbound dialer - Fix queue queries in mobile API and SSE to use twp_group_members (matching browser phone) instead of twp_queue_assignments - Auto-create personal queues if user has no extension - Make all model JSON parsing null-safe (handle null, string ints, bools) - Add AutofillGroup and autofill hints to login form - Add outbound calling with dialpad bottom sheet on dashboard Co-Authored-By: Claude Opus 4.6 --- includes/class-twp-mobile-api.php | 45 ++++++------ includes/class-twp-mobile-sse.php | 40 ++++++----- mobile/lib/models/agent_status.dart | 6 +- mobile/lib/models/queue_state.dart | 32 ++++++--- mobile/lib/models/user.dart | 12 +++- mobile/lib/providers/call_provider.dart | 9 +++ mobile/lib/screens/dashboard_screen.dart | 89 ++++++++++++++++++++++++ mobile/lib/screens/login_screen.dart | 9 ++- mobile/lib/services/voice_service.dart | 4 ++ 9 files changed, 186 insertions(+), 60 deletions(-) diff --git a/includes/class-twp-mobile-api.php b/includes/class-twp-mobile-api.php index fbfcbf8..902db17 100644 --- a/includes/class-twp-mobile-api.php +++ b/includes/class-twp-mobile-api.php @@ -211,44 +211,41 @@ class TWP_Mobile_API { global $wpdb; $queues_table = $wpdb->prefix . 'twp_call_queues'; $calls_table = $wpdb->prefix . 'twp_queued_calls'; - $assignments_table = $wpdb->prefix . 'twp_queue_assignments'; + $groups_table = $wpdb->prefix . 'twp_group_members'; - // Get queues assigned to this user - $queue_ids = $wpdb->get_col($wpdb->prepare( - "SELECT queue_id FROM $assignments_table WHERE user_id = %d", + // Auto-create personal queues if they don't exist + $extensions_table = $wpdb->prefix . 'twp_user_extensions'; + $existing_extension = $wpdb->get_row($wpdb->prepare( + "SELECT extension FROM $extensions_table WHERE user_id = %d", $user_id )); - // Also include personal queues - $personal_queue_ids = $wpdb->get_col($wpdb->prepare( - "SELECT id FROM $queues_table WHERE user_id = %d", - $user_id - )); - - $all_queue_ids = array_unique(array_merge($queue_ids, $personal_queue_ids)); - - if (empty($all_queue_ids)) { - return new WP_REST_Response(array( - 'success' => true, - 'queues' => array() - ), 200); + if (!$existing_extension) { + TWP_User_Queue_Manager::create_user_queues($user_id); } - $queue_ids_str = implode(',', array_map('intval', $all_queue_ids)); - - // Get queue information with call counts - $queues = $wpdb->get_results(" - SELECT + // Get queues where user is a member of the assigned agent group OR personal/hold queues + $queues = $wpdb->get_results($wpdb->prepare(" + SELECT DISTINCT q.id, q.queue_name, q.queue_type, q.extension, COUNT(c.id) as waiting_count FROM $queues_table q + LEFT JOIN $groups_table gm ON gm.group_id = q.agent_group_id LEFT JOIN $calls_table c ON q.id = c.queue_id AND c.status = 'waiting' - WHERE q.id IN ($queue_ids_str) + WHERE (gm.user_id = %d AND gm.is_active = 1) + OR (q.user_id = %d AND q.queue_type IN ('personal', 'hold')) GROUP BY q.id - "); + ORDER BY + CASE + WHEN q.queue_type = 'personal' THEN 1 + WHEN q.queue_type = 'hold' THEN 2 + ELSE 3 + END, + q.queue_name ASC + ", $user_id, $user_id)); $result = array(); foreach ($queues as $queue) { diff --git a/includes/class-twp-mobile-sse.php b/includes/class-twp-mobile-sse.php index 33c5ddf..b85c3a3 100644 --- a/includes/class-twp-mobile-sse.php +++ b/includes/class-twp-mobile-sse.php @@ -142,38 +142,40 @@ class TWP_Mobile_SSE { global $wpdb; $queues_table = $wpdb->prefix . 'twp_call_queues'; $calls_table = $wpdb->prefix . 'twp_queued_calls'; - $assignments_table = $wpdb->prefix . 'twp_queue_assignments'; + $groups_table = $wpdb->prefix . 'twp_group_members'; - // Get queue IDs - $queue_ids = $wpdb->get_col($wpdb->prepare( - "SELECT queue_id FROM $assignments_table WHERE user_id = %d", + // Auto-create personal queues if they don't exist + $extensions_table = $wpdb->prefix . 'twp_user_extensions'; + $existing_extension = $wpdb->get_row($wpdb->prepare( + "SELECT extension FROM $extensions_table WHERE user_id = %d", $user_id )); - $personal_queue_ids = $wpdb->get_col($wpdb->prepare( - "SELECT id FROM $queues_table WHERE user_id = %d", - $user_id - )); - - $all_queue_ids = array_unique(array_merge($queue_ids, $personal_queue_ids)); - - if (empty($all_queue_ids)) { - return array(); + if (!$existing_extension) { + TWP_User_Queue_Manager::create_user_queues($user_id); } - $queue_ids_str = implode(',', array_map('intval', $all_queue_ids)); - - $queues = $wpdb->get_results(" - SELECT + // Get queues where user is a member of the assigned agent group OR personal/hold queues + $queues = $wpdb->get_results($wpdb->prepare(" + SELECT DISTINCT q.id, q.queue_name, COUNT(c.id) as waiting_count, MIN(c.enqueued_at) as oldest_call_time FROM $queues_table q + LEFT JOIN $groups_table gm ON gm.group_id = q.agent_group_id LEFT JOIN $calls_table c ON q.id = c.queue_id AND c.status = 'waiting' - WHERE q.id IN ($queue_ids_str) + WHERE (gm.user_id = %d AND gm.is_active = 1) + OR (q.user_id = %d AND q.queue_type IN ('personal', 'hold')) GROUP BY q.id - "); + ORDER BY + CASE + WHEN q.queue_type = 'personal' THEN 1 + WHEN q.queue_type = 'hold' THEN 2 + ELSE 3 + END, + q.queue_name ASC + ", $user_id, $user_id)); $result = array(); foreach ($queues as $queue) { diff --git a/mobile/lib/models/agent_status.dart b/mobile/lib/models/agent_status.dart index 90451e0..0f58aee 100644 --- a/mobile/lib/models/agent_status.dart +++ b/mobile/lib/models/agent_status.dart @@ -17,11 +17,11 @@ class AgentStatus { factory AgentStatus.fromJson(Map json) { return AgentStatus( - status: _parseStatus(json['status'] as String), - isLoggedIn: json['is_logged_in'] as bool, + 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'] as bool? ?? true, + availableForQueues: json['available_for_queues'] != false && json['available_for_queues'] != 0 && json['available_for_queues'] != '0', ); } diff --git a/mobile/lib/models/queue_state.dart b/mobile/lib/models/queue_state.dart index d45179e..748674e 100644 --- a/mobile/lib/models/queue_state.dart +++ b/mobile/lib/models/queue_state.dart @@ -15,13 +15,19 @@ class QueueInfo { factory QueueInfo.fromJson(Map json) { return QueueInfo( - id: json['id'] as int, - name: json['name'] as String, - type: json['type'] as String, + id: _toInt(json['id']), + name: (json['name'] ?? '') as String, + type: (json['type'] ?? '') as String, extension: json['extension'] as String?, - waitingCount: json['waiting_count'] as int, + 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 { @@ -43,12 +49,18 @@ class QueueCall { 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: json['position'] as int, - status: json['status'] as String, - waitTime: json['wait_time'] as int, + 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 index 6be6ed2..d535ca3 100644 --- a/mobile/lib/models/user.dart +++ b/mobile/lib/models/user.dart @@ -13,10 +13,16 @@ class User { factory User.fromJson(Map json) { return User( - id: json['user_id'] as int, - login: json['user_login'] as String, - displayName: json['display_name'] as String, + 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/call_provider.dart b/mobile/lib/providers/call_provider.dart index e5a323d..92f755f 100644 --- a/mobile/lib/providers/call_provider.dart +++ b/mobile/lib/providers/call_provider.dart @@ -103,6 +103,15 @@ class CallProvider extends ChangeNotifier { Future sendDigits(String digits) => _voiceService.sendDigits(digits); + Future makeCall(String number) async { + _callInfo = _callInfo.copyWith( + state: CallState.connecting, + callerNumber: number, + ); + notifyListeners(); + await _voiceService.makeCall(number); + } + Future holdCall() async { final sid = _callInfo.callSid; if (sid == null) return; diff --git a/mobile/lib/screens/dashboard_screen.dart b/mobile/lib/screens/dashboard_screen.dart index 15cf210..a83ad30 100644 --- a/mobile/lib/screens/dashboard_screen.dart +++ b/mobile/lib/screens/dashboard_screen.dart @@ -3,6 +3,7 @@ import 'package:provider/provider.dart'; import '../providers/agent_provider.dart'; import '../providers/call_provider.dart'; import '../widgets/agent_status_toggle.dart'; +import '../widgets/dialpad.dart'; import '../widgets/queue_card.dart'; import 'active_call_screen.dart'; import 'settings_screen.dart'; @@ -23,6 +24,90 @@ class _DashboardScreenState extends State { }); } + void _showDialer(BuildContext context) { + final numberController = TextEditingController(); + + showModalBottomSheet( + context: context, + isScrollControlled: true, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(16)), + ), + builder: (ctx) { + 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), + ); + } + }, + ), + ), + ), + const SizedBox(height: 16), + // Dialpad + Dialpad( + onDigit: (digit) { + numberController.text += digit; + numberController.selection = TextSelection.fromPosition( + TextPosition(offset: numberController.text.length), + ); + }, + onClose: () => Navigator.pop(ctx), + ), + const SizedBox(height: 8), + // Call button + ElevatedButton.icon( + style: ElevatedButton.styleFrom( + backgroundColor: Colors.green, + foregroundColor: Colors.white, + minimumSize: const Size(double.infinity, 48), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(24), + ), + ), + icon: const Icon(Icons.call), + label: const Text('Call'), + onPressed: () { + final number = numberController.text.trim(); + if (number.isNotEmpty) { + context.read().makeCall(number); + Navigator.pop(ctx); + } + }, + ), + const SizedBox(height: 16), + ], + ), + ); + }, + ); + } + @override Widget build(BuildContext context) { final agent = context.watch(); @@ -58,6 +143,10 @@ class _DashboardScreenState extends State { ), ], ), + floatingActionButton: FloatingActionButton( + onPressed: () => _showDialer(context), + child: const Icon(Icons.phone), + ), body: RefreshIndicator( onRefresh: () => agent.refresh(), child: ListView( diff --git a/mobile/lib/screens/login_screen.dart b/mobile/lib/screens/login_screen.dart index 2d551c0..13f4c27 100644 --- a/mobile/lib/screens/login_screen.dart +++ b/mobile/lib/screens/login_screen.dart @@ -1,4 +1,5 @@ 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'; @@ -39,6 +40,7 @@ class _LoginScreenState extends State { serverUrl = 'https://$serverUrl'; } + TextInput.finishAutofillContext(); context.read().login( serverUrl, _usernameController.text.trim(), @@ -55,7 +57,8 @@ class _LoginScreenState extends State { child: Center( child: SingleChildScrollView( padding: const EdgeInsets.all(24), - child: Form( + child: AutofillGroup( + child: Form( key: _formKey, child: Column( mainAxisSize: MainAxisSize.min, @@ -80,6 +83,7 @@ class _LoginScreenState extends State { border: OutlineInputBorder(), ), keyboardType: TextInputType.url, + autofillHints: const [AutofillHints.url], validator: (v) => v == null || v.trim().isEmpty ? 'Required' : null, ), @@ -91,6 +95,7 @@ class _LoginScreenState extends State { prefixIcon: Icon(Icons.person), border: OutlineInputBorder(), ), + autofillHints: const [AutofillHints.username], validator: (v) => v == null || v.trim().isEmpty ? 'Required' : null, ), @@ -110,6 +115,7 @@ class _LoginScreenState extends State { ), ), obscureText: _obscurePassword, + autofillHints: const [AutofillHints.password], validator: (v) => v == null || v.isEmpty ? 'Required' : null, ), @@ -142,6 +148,7 @@ class _LoginScreenState extends State { ], ), ), + ), ), ), ), diff --git a/mobile/lib/services/voice_service.dart b/mobile/lib/services/voice_service.dart index da2c5ef..d6a353b 100644 --- a/mobile/lib/services/voice_service.dart +++ b/mobile/lib/services/voice_service.dart @@ -62,6 +62,10 @@ class VoiceService { await TwilioVoice.instance.call.toggleSpeaker(speaker); } + Future makeCall(String to) async { + return await TwilioVoice.instance.call.place(to: to, from: _identity ?? '') ?? false; + } + Future sendDigits(String digits) async { await TwilioVoice.instance.call.sendDigits(digits); }