Compare commits

..

1 Commits

Author SHA1 Message Date
Claude
8cc6fa8c3c Fix queue loading, null-safe models, autofill, and add outbound dialer
All checks were successful
Create Release / build (push) Successful in 4s
- Fix queue queries in mobile API and SSE to use twp_group_members
  (matching browser phone) instead of twp_queue_assignments
- Auto-create personal queues if user has no extension
- Make all model JSON parsing null-safe (handle null, string ints, bools)
- Add AutofillGroup and autofill hints to login form
- Add outbound calling with dialpad bottom sheet on dashboard

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 15:32:22 -08:00
9 changed files with 186 additions and 60 deletions

View File

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

View File

@@ -142,38 +142,40 @@ class TWP_Mobile_SSE {
global $wpdb;
$queues_table = $wpdb->prefix . 'twp_call_queues';
$calls_table = $wpdb->prefix . 'twp_queued_calls';
$assignments_table = $wpdb->prefix . 'twp_queue_assignments';
$groups_table = $wpdb->prefix . 'twp_group_members';
// Get queue IDs
$queue_ids = $wpdb->get_col($wpdb->prepare(
"SELECT queue_id FROM $assignments_table WHERE user_id = %d",
// Auto-create personal queues if they don't exist
$extensions_table = $wpdb->prefix . 'twp_user_extensions';
$existing_extension = $wpdb->get_row($wpdb->prepare(
"SELECT extension FROM $extensions_table WHERE user_id = %d",
$user_id
));
$personal_queue_ids = $wpdb->get_col($wpdb->prepare(
"SELECT id FROM $queues_table WHERE user_id = %d",
$user_id
));
$all_queue_ids = array_unique(array_merge($queue_ids, $personal_queue_ids));
if (empty($all_queue_ids)) {
return array();
if (!$existing_extension) {
TWP_User_Queue_Manager::create_user_queues($user_id);
}
$queue_ids_str = implode(',', array_map('intval', $all_queue_ids));
$queues = $wpdb->get_results("
SELECT
// Get queues where user is a member of the assigned agent group OR personal/hold queues
$queues = $wpdb->get_results($wpdb->prepare("
SELECT DISTINCT
q.id,
q.queue_name,
COUNT(c.id) as waiting_count,
MIN(c.enqueued_at) as oldest_call_time
FROM $queues_table q
LEFT JOIN $groups_table gm ON gm.group_id = q.agent_group_id
LEFT JOIN $calls_table c ON q.id = c.queue_id AND c.status = 'waiting'
WHERE q.id IN ($queue_ids_str)
WHERE (gm.user_id = %d AND gm.is_active = 1)
OR (q.user_id = %d AND q.queue_type IN ('personal', 'hold'))
GROUP BY q.id
");
ORDER BY
CASE
WHEN q.queue_type = 'personal' THEN 1
WHEN q.queue_type = 'hold' THEN 2
ELSE 3
END,
q.queue_name ASC
", $user_id, $user_id));
$result = array();
foreach ($queues as $queue) {

View File

@@ -17,11 +17,11 @@ class AgentStatus {
factory AgentStatus.fromJson(Map<String, dynamic> 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',
);
}

View File

@@ -15,13 +15,19 @@ class QueueInfo {
factory QueueInfo.fromJson(Map<String, dynamic> 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<String, dynamic> 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;
}
}

View File

@@ -13,10 +13,16 @@ class User {
factory User.fromJson(Map<String, dynamic> 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;
}
}

View File

@@ -103,6 +103,15 @@ class CallProvider extends ChangeNotifier {
Future<void> sendDigits(String digits) => _voiceService.sendDigits(digits);
Future<void> makeCall(String number) async {
_callInfo = _callInfo.copyWith(
state: CallState.connecting,
callerNumber: number,
);
notifyListeners();
await _voiceService.makeCall(number);
}
Future<void> holdCall() async {
final sid = _callInfo.callSid;
if (sid == null) return;

View File

@@ -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<DashboardScreen> {
});
}
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<CallProvider>().makeCall(number);
Navigator.pop(ctx);
}
},
),
const SizedBox(height: 16),
],
),
);
},
);
}
@override
Widget build(BuildContext context) {
final agent = context.watch<AgentProvider>();
@@ -58,6 +143,10 @@ class _DashboardScreenState extends State<DashboardScreen> {
),
],
),
floatingActionButton: FloatingActionButton(
onPressed: () => _showDialer(context),
child: const Icon(Icons.phone),
),
body: RefreshIndicator(
onRefresh: () => agent.refresh(),
child: ListView(

View File

@@ -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<LoginScreen> {
serverUrl = 'https://$serverUrl';
}
TextInput.finishAutofillContext();
context.read<AuthProvider>().login(
serverUrl,
_usernameController.text.trim(),
@@ -55,7 +57,8 @@ class _LoginScreenState extends State<LoginScreen> {
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<LoginScreen> {
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<LoginScreen> {
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<LoginScreen> {
),
),
obscureText: _obscurePassword,
autofillHints: const [AutofillHints.password],
validator: (v) =>
v == null || v.isEmpty ? 'Required' : null,
),
@@ -142,6 +148,7 @@ class _LoginScreenState extends State<LoginScreen> {
],
),
),
),
),
),
),

View File

@@ -62,6 +62,10 @@ class VoiceService {
await TwilioVoice.instance.call.toggleSpeaker(speaker);
}
Future<bool> makeCall(String to) async {
return await TwilioVoice.instance.call.place(to: to, from: _identity ?? '') ?? false;
}
Future<void> sendDigits(String digits) async {
await TwilioVoice.instance.call.sendDigits(digits);
}