Compare commits

...

3 Commits

Author SHA1 Message Date
Claude
0059c0d766 Fix mobile app: AccessToken for voice, Agent Manager for status, caller ID support
Some checks failed
Create Release / build (push) Successful in 3s
Update Plugin Version / update-version (release) Failing after 4s
- Voice token: use AccessToken + VoiceGrant instead of browser-only ClientToken
- Agent status: delegate to TWP_Agent_Manager matching browser phone behavior
- Queue loading: add missing require_once for TWP_User_Queue_Manager
- Add /phone-numbers endpoint for caller ID selection
- Webhook: support CallerId param from mobile extraOptions
- Flutter: caller ID dropdown in dialer, error logging in all catch blocks

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

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

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 14:50:53 -08:00
13 changed files with 341 additions and 151 deletions

View File

@@ -52,8 +52,6 @@ if (isset($_POST['twp_save_mobile_settings']) && check_admin_referer('twp_mobile
update_option('twp_auto_update_enabled', isset($_POST['twp_auto_update_enabled']) ? '1' : '0'); update_option('twp_auto_update_enabled', isset($_POST['twp_auto_update_enabled']) ? '1' : '0');
update_option('twp_gitea_repo', sanitize_text_field($_POST['twp_gitea_repo'])); update_option('twp_gitea_repo', sanitize_text_field($_POST['twp_gitea_repo']));
update_option('twp_gitea_token', sanitize_text_field($_POST['twp_gitea_token'])); update_option('twp_gitea_token', sanitize_text_field($_POST['twp_gitea_token']));
update_option('twp_twilio_api_key_sid', sanitize_text_field($_POST['twp_twilio_api_key_sid']));
update_option('twp_twilio_api_key_secret', sanitize_text_field($_POST['twp_twilio_api_key_secret']));
$settings_saved = true; $settings_saved = true;
} }
@@ -65,8 +63,6 @@ $fcm_sa_configured = !empty($fcm_service_account_json) && !empty($fcm_project_id
$auto_update_enabled = get_option('twp_auto_update_enabled', '1') === '1'; $auto_update_enabled = get_option('twp_auto_update_enabled', '1') === '1';
$gitea_repo = get_option('twp_gitea_repo', 'wp-plugins/twilio-wp-plugin'); $gitea_repo = get_option('twp_gitea_repo', 'wp-plugins/twilio-wp-plugin');
$gitea_token = get_option('twp_gitea_token', ''); $gitea_token = get_option('twp_gitea_token', '');
$twilio_api_key_sid = get_option('twp_twilio_api_key_sid', '');
$twilio_api_key_secret = get_option('twp_twilio_api_key_secret', '');
// Get update status // Get update status
require_once TWP_PLUGIN_DIR . 'includes/class-twp-auto-updater.php'; require_once TWP_PLUGIN_DIR . 'includes/class-twp-auto-updater.php';
@@ -175,37 +171,6 @@ $total_sessions = $wpdb->get_var("SELECT COUNT(*) FROM $sessions_table");
<?php endif; ?> <?php endif; ?>
</td> </td>
</tr> </tr>
<tr>
<th scope="row">
<label for="twp_twilio_api_key_sid">Twilio API Key SID</label>
</th>
<td>
<input type="text"
id="twp_twilio_api_key_sid"
name="twp_twilio_api_key_sid"
value="<?php echo esc_attr($twilio_api_key_sid); ?>"
class="regular-text"
placeholder="SK...">
<p class="description">
Create an API Key in Twilio Console &gt; Account &gt; API Keys. Required for mobile VoIP tokens.
</p>
</td>
</tr>
<tr>
<th scope="row">
<label for="twp_twilio_api_key_secret">Twilio API Key Secret</label>
</th>
<td>
<input type="password"
id="twp_twilio_api_key_secret"
name="twp_twilio_api_key_secret"
value="<?php echo esc_attr($twilio_api_key_secret); ?>"
class="regular-text">
<p class="description">
The secret associated with the API Key SID above. Shown only once when key is created.
</p>
</td>
</tr>
</table> </table>
<?php if ($fcm_sa_configured): ?> <?php if ($fcm_sa_configured): ?>

View File

@@ -106,6 +106,13 @@ class TWP_Mobile_API {
'callback' => array($this, 'get_voice_token'), 'callback' => array($this, 'get_voice_token'),
'permission_callback' => array($this->auth, 'verify_token') 'permission_callback' => array($this->auth, 'verify_token')
)); ));
// Phone numbers for caller ID
register_rest_route('twilio-mobile/v1', '/phone-numbers', array(
'methods' => 'GET',
'callback' => array($this, 'get_phone_numbers'),
'permission_callback' => array($this->auth, 'verify_token')
));
}); });
} }
@@ -162,39 +169,16 @@ class TWP_Mobile_API {
return new WP_Error('invalid_status', 'Status must be available, busy, or offline', array('status' => 400)); return new WP_Error('invalid_status', 'Status must be available, busy, or offline', array('status' => 400));
} }
global $wpdb; require_once plugin_dir_path(__FILE__) . 'class-twp-agent-manager.php';
$table = $wpdb->prefix . 'twp_agent_status'; require_once plugin_dir_path(__FILE__) . 'class-twp-user-queue-manager.php';
// Check if status exists
$exists = $wpdb->get_var($wpdb->prepare(
"SELECT COUNT(*) FROM $table WHERE user_id = %d",
$user_id
));
$data = array(
'status' => $new_status,
'last_activity' => current_time('mysql')
);
// Handle login status change first (matches browser phone behavior)
if ($is_logged_in !== null) { if ($is_logged_in !== null) {
$data['is_logged_in'] = $is_logged_in ? 1 : 0; TWP_Agent_Manager::set_agent_login_status($user_id, (bool)$is_logged_in);
if ($is_logged_in) {
$data['logged_in_at'] = current_time('mysql');
}
} }
if ($exists) { // Set agent status (handles auto_busy_at and all status fields)
$wpdb->update( TWP_Agent_Manager::set_agent_status($user_id, $new_status);
$table,
$data,
array('user_id' => $user_id),
array('%s', '%s'),
array('%d')
);
} else {
$data['user_id'] = $user_id;
$wpdb->insert($table, $data);
}
return new WP_REST_Response(array( return new WP_REST_Response(array(
'success' => true, 'success' => true,
@@ -211,44 +195,42 @@ class TWP_Mobile_API {
global $wpdb; global $wpdb;
$queues_table = $wpdb->prefix . 'twp_call_queues'; $queues_table = $wpdb->prefix . 'twp_call_queues';
$calls_table = $wpdb->prefix . 'twp_queued_calls'; $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 // Auto-create personal queues if they don't exist
$queue_ids = $wpdb->get_col($wpdb->prepare( $extensions_table = $wpdb->prefix . 'twp_user_extensions';
"SELECT queue_id FROM $assignments_table WHERE user_id = %d", $existing_extension = $wpdb->get_row($wpdb->prepare(
"SELECT extension FROM $extensions_table WHERE user_id = %d",
$user_id $user_id
)); ));
// Also include personal queues if (!$existing_extension) {
$personal_queue_ids = $wpdb->get_col($wpdb->prepare( require_once plugin_dir_path(__FILE__) . 'class-twp-user-queue-manager.php';
"SELECT id FROM $queues_table WHERE user_id = %d", TWP_User_Queue_Manager::create_user_queues($user_id);
$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);
} }
$queue_ids_str = implode(',', array_map('intval', $all_queue_ids)); // Get queues where user is a member of the assigned agent group OR personal/hold queues
$queues = $wpdb->get_results($wpdb->prepare("
// Get queue information with call counts SELECT DISTINCT
$queues = $wpdb->get_results("
SELECT
q.id, q.id,
q.queue_name, q.queue_name,
q.queue_type, q.queue_type,
q.extension, q.extension,
COUNT(c.id) as waiting_count COUNT(c.id) as waiting_count
FROM $queues_table q 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' 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 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(); $result = array();
foreach ($queues as $queue) { foreach ($queues as $queue) {
@@ -689,31 +671,30 @@ class TWP_Mobile_API {
public function get_voice_token($request) { public function get_voice_token($request) {
$user_id = $this->auth->get_current_user_id(); $user_id = $this->auth->get_current_user_id();
$user = get_userdata($user_id); $user = get_userdata($user_id);
$identity = 'agent' . $user_id . preg_replace('/[^a-zA-Z0-9]/', '', $user->user_login); $clean_name = preg_replace('/[^a-zA-Z0-9]/', '', $user->user_login);
if (empty($clean_name)) {
$account_sid = get_option('twp_twilio_account_sid'); $clean_name = 'user';
$auth_token = get_option('twp_twilio_auth_token');
$api_key_sid = get_option('twp_twilio_api_key_sid');
$api_key_secret = get_option('twp_twilio_api_key_secret');
$twiml_app_sid = get_option('twp_twiml_app_sid');
if (empty($api_key_sid) || empty($api_key_secret)) {
return new WP_Error('missing_api_key', 'Twilio API Key SID and Secret must be configured', array('status' => 500));
} }
$identity = 'agent' . $user_id . $clean_name;
try { try {
$token = new \Twilio\Jwt\AccessToken( // Ensure Twilio SDK autoloader is loaded
$account_sid, require_once plugin_dir_path(dirname(__FILE__)) . 'includes/class-twp-twilio-api.php';
$api_key_sid, new TWP_Twilio_API();
$api_key_secret,
3600,
$identity
);
$account_sid = get_option('twp_twilio_account_sid');
$auth_token = get_option('twp_twilio_auth_token');
$twiml_app_sid = get_option('twp_twiml_app_sid');
if (empty($account_sid) || empty($auth_token) || empty($twiml_app_sid)) {
return new WP_Error('token_error', 'Twilio credentials not configured', array('status' => 500));
}
// AccessToken for mobile Voice SDK (not ClientToken which is browser-only)
$token = new \Twilio\Jwt\AccessToken($account_sid, $account_sid, $auth_token, 3600, $identity);
$voiceGrant = new \Twilio\Jwt\Grants\VoiceGrant(); $voiceGrant = new \Twilio\Jwt\Grants\VoiceGrant();
$voiceGrant->setOutgoingApplicationSid($twiml_app_sid); $voiceGrant->setOutgoingApplicationSid($twiml_app_sid);
$voiceGrant->setIncomingAllow(true); $voiceGrant->setIncomingAllow(true);
$token->addGrant($voiceGrant); $token->addGrant($voiceGrant);
return new WP_REST_Response(array( return new WP_REST_Response(array(
@@ -727,6 +708,37 @@ class TWP_Mobile_API {
} }
} }
/**
* Get available Twilio phone numbers for caller ID
*/
public function get_phone_numbers($request) {
try {
require_once plugin_dir_path(dirname(__FILE__)) . 'includes/class-twp-twilio-api.php';
$twilio = new TWP_Twilio_API();
$result = $twilio->get_phone_numbers();
if (!$result['success']) {
return new WP_Error('twilio_error', $result['error'], array('status' => 500));
}
$phone_numbers = array();
foreach ($result['data']['incoming_phone_numbers'] as $number) {
$phone_numbers[] = array(
'phone_number' => $number['phone_number'],
'friendly_name' => $number['friendly_name'],
);
}
return new WP_REST_Response(array(
'success' => true,
'phone_numbers' => $phone_numbers
), 200);
} catch (Exception $e) {
return new WP_Error('twilio_error', $e->getMessage(), array('status' => 500));
}
}
/** /**
* Check if user has access to a queue * Check if user has access to a queue
*/ */

View File

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

View File

@@ -371,7 +371,13 @@ class TWP_Webhooks {
if (isset($params['To']) && !empty($params['To'])) { if (isset($params['To']) && !empty($params['To'])) {
$to_number = $params['To']; $to_number = $params['To'];
$from_number = isset($params['From']) ? $params['From'] : ''; // Mobile SDK sends CallerId via extraOptions; browser sends From as phone number
$from_number = '';
if (!empty($params['CallerId']) && strpos($params['CallerId'], 'client:') !== 0) {
$from_number = $params['CallerId'];
} elseif (!empty($params['From']) && strpos($params['From'], 'client:') !== 0) {
$from_number = $params['From'];
}
// If it's an outgoing call to a phone number // If it's an outgoing call to a phone number
if (strpos($to_number, 'client:') !== 0) { if (strpos($to_number, 'client:') !== 0) {

View File

@@ -17,11 +17,11 @@ class AgentStatus {
factory AgentStatus.fromJson(Map<String, dynamic> json) { factory AgentStatus.fromJson(Map<String, dynamic> json) {
return AgentStatus( return AgentStatus(
status: _parseStatus(json['status'] as String), status: _parseStatus((json['status'] ?? 'offline') as String),
isLoggedIn: json['is_logged_in'] as bool, isLoggedIn: json['is_logged_in'] == true || json['is_logged_in'] == 1 || json['is_logged_in'] == '1',
currentCallSid: json['current_call_sid'] as String?, currentCallSid: json['current_call_sid'] as String?,
lastActivity: json['last_activity'] 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) { factory QueueInfo.fromJson(Map<String, dynamic> json) {
return QueueInfo( return QueueInfo(
id: json['id'] as int, id: _toInt(json['id']),
name: json['name'] as String, name: (json['name'] ?? '') as String,
type: json['type'] as String, type: (json['type'] ?? '') as String,
extension: json['extension'] 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 { class QueueCall {
@@ -43,12 +49,18 @@ class QueueCall {
factory QueueCall.fromJson(Map<String, dynamic> json) { factory QueueCall.fromJson(Map<String, dynamic> json) {
return QueueCall( return QueueCall(
callSid: json['call_sid'] as String, callSid: (json['call_sid'] ?? '') as String,
fromNumber: json['from_number'] as String, fromNumber: (json['from_number'] ?? '') as String,
toNumber: json['to_number'] as String, toNumber: (json['to_number'] ?? '') as String,
position: json['position'] as int, position: _toInt(json['position']),
status: json['status'] as String, status: (json['status'] ?? '') as String,
waitTime: json['wait_time'] as int, 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) { factory User.fromJson(Map<String, dynamic> json) {
return User( return User(
id: json['user_id'] as int, id: _toInt(json['user_id']),
login: json['user_login'] as String, login: (json['user_login'] ?? '') as String,
displayName: json['display_name'] as String, displayName: (json['display_name'] ?? '') as String,
email: json['email'] 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

@@ -5,6 +5,16 @@ import '../models/queue_state.dart';
import '../services/api_client.dart'; import '../services/api_client.dart';
import '../services/sse_service.dart'; import '../services/sse_service.dart';
class PhoneNumber {
final String phoneNumber;
final String friendlyName;
PhoneNumber({required this.phoneNumber, required this.friendlyName});
factory PhoneNumber.fromJson(Map<String, dynamic> json) => PhoneNumber(
phoneNumber: json['phone_number'] as String,
friendlyName: json['friendly_name'] as String,
);
}
class AgentProvider extends ChangeNotifier { class AgentProvider extends ChangeNotifier {
final ApiClient _api; final ApiClient _api;
final SseService _sse; final SseService _sse;
@@ -12,12 +22,14 @@ class AgentProvider extends ChangeNotifier {
AgentStatus? _status; AgentStatus? _status;
List<QueueInfo> _queues = []; List<QueueInfo> _queues = [];
bool _sseConnected = false; bool _sseConnected = false;
List<PhoneNumber> _phoneNumbers = [];
StreamSubscription? _sseSub; StreamSubscription? _sseSub;
StreamSubscription? _connSub; StreamSubscription? _connSub;
AgentStatus? get status => _status; AgentStatus? get status => _status;
List<QueueInfo> get queues => _queues; List<QueueInfo> get queues => _queues;
bool get sseConnected => _sseConnected; bool get sseConnected => _sseConnected;
List<PhoneNumber> get phoneNumbers => _phoneNumbers;
AgentProvider(this._api, this._sse) { AgentProvider(this._api, this._sse) {
_connSub = _sse.connectionState.listen((connected) { _connSub = _sse.connectionState.listen((connected) {
@@ -33,7 +45,7 @@ class AgentProvider extends ChangeNotifier {
final response = await _api.dio.get('/agent/status'); final response = await _api.dio.get('/agent/status');
_status = AgentStatus.fromJson(response.data); _status = AgentStatus.fromJson(response.data);
notifyListeners(); notifyListeners();
} catch (_) {} } catch (e) { debugPrint('AgentProvider.fetchStatus error: $e'); }
} }
Future<void> updateStatus(AgentStatusValue newStatus) async { Future<void> updateStatus(AgentStatusValue newStatus) async {
@@ -49,7 +61,7 @@ class AgentProvider extends ChangeNotifier {
currentCallSid: _status?.currentCallSid, currentCallSid: _status?.currentCallSid,
); );
notifyListeners(); notifyListeners();
} catch (_) {} } catch (e) { debugPrint('AgentProvider.updateStatus error: $e'); }
} }
Future<void> fetchQueues() async { Future<void> fetchQueues() async {
@@ -60,11 +72,24 @@ class AgentProvider extends ChangeNotifier {
.map((q) => QueueInfo.fromJson(q as Map<String, dynamic>)) .map((q) => QueueInfo.fromJson(q as Map<String, dynamic>))
.toList(); .toList();
notifyListeners(); notifyListeners();
} catch (_) {} } catch (e) { debugPrint('AgentProvider.fetchQueues error: $e'); }
}
Future<void> fetchPhoneNumbers() async {
try {
final response = await _api.dio.get('/phone-numbers');
final data = response.data;
_phoneNumbers = (data['phone_numbers'] as List)
.map((p) => PhoneNumber.fromJson(p as Map<String, dynamic>))
.toList();
notifyListeners();
} catch (e) {
debugPrint('AgentProvider.fetchPhoneNumbers error: $e');
}
} }
Future<void> refresh() async { Future<void> refresh() async {
await Future.wait([fetchStatus(), fetchQueues()]); await Future.wait([fetchStatus(), fetchQueues(), fetchPhoneNumbers()]);
} }
void _handleSseEvent(SseEvent event) { void _handleSseEvent(SseEvent event) {

View File

@@ -63,13 +63,19 @@ class AuthProvider extends ChangeNotifier {
Future<void> _initializeServices() async { Future<void> _initializeServices() async {
try { try {
await _pushService.initialize(); await _pushService.initialize();
} catch (_) {} } catch (e) {
debugPrint('AuthProvider: push service init error: $e');
}
try { try {
await _voiceService.initialize(); await _voiceService.initialize();
} catch (_) {} } catch (e) {
debugPrint('AuthProvider: voice service init error: $e');
}
try { try {
await _sseService.connect(); await _sseService.connect();
} catch (_) {} } catch (e) {
debugPrint('AuthProvider: SSE connect error: $e');
}
} }
Future<void> logout() async { Future<void> logout() async {

View File

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

View File

@@ -3,6 +3,7 @@ import 'package:provider/provider.dart';
import '../providers/agent_provider.dart'; import '../providers/agent_provider.dart';
import '../providers/call_provider.dart'; import '../providers/call_provider.dart';
import '../widgets/agent_status_toggle.dart'; import '../widgets/agent_status_toggle.dart';
import '../widgets/dialpad.dart';
import '../widgets/queue_card.dart'; import '../widgets/queue_card.dart';
import 'active_call_screen.dart'; import 'active_call_screen.dart';
import 'settings_screen.dart'; import 'settings_screen.dart';
@@ -23,6 +24,123 @@ class _DashboardScreenState extends State<DashboardScreen> {
}); });
} }
void _showDialer(BuildContext context) {
final numberController = TextEditingController();
String? selectedCallerId;
showModalBottomSheet(
context: context,
isScrollControlled: true,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
),
builder: (ctx) {
final phoneNumbers = context.read<AgentProvider>().phoneNumbers;
return StatefulBuilder(
builder: (ctx, setSheetState) {
return Padding(
padding: EdgeInsets.only(
bottom: MediaQuery.of(ctx).viewInsets.bottom,
top: 16,
left: 16,
right: 16,
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// Number display
TextField(
controller: numberController,
keyboardType: TextInputType.phone,
autofillHints: const [AutofillHints.telephoneNumber],
textAlign: TextAlign.center,
style: Theme.of(ctx).textTheme.headlineSmall,
decoration: InputDecoration(
hintText: 'Enter phone number',
suffixIcon: IconButton(
icon: const Icon(Icons.backspace_outlined),
onPressed: () {
final text = numberController.text;
if (text.isNotEmpty) {
numberController.text =
text.substring(0, text.length - 1);
numberController.selection = TextSelection.fromPosition(
TextPosition(offset: numberController.text.length),
);
}
},
),
),
),
// Caller ID selector
if (phoneNumbers.isNotEmpty) ...[
const SizedBox(height: 12),
DropdownButtonFormField<String>(
value: selectedCallerId,
decoration: const InputDecoration(
labelText: 'Caller ID',
isDense: true,
contentPadding: EdgeInsets.symmetric(horizontal: 12, vertical: 8),
),
items: [
const DropdownMenuItem<String>(
value: null,
child: Text('Default'),
),
...phoneNumbers.map((p) => DropdownMenuItem<String>(
value: p.phoneNumber,
child: Text('${p.friendlyName} (${p.phoneNumber})'),
)),
],
onChanged: (value) {
setSheetState(() {
selectedCallerId = value;
});
},
),
],
const SizedBox(height: 16),
// Dialpad
Dialpad(
onDigit: (digit) {
numberController.text += digit;
numberController.selection = TextSelection.fromPosition(
TextPosition(offset: numberController.text.length),
);
},
onClose: () => Navigator.pop(ctx),
),
const SizedBox(height: 8),
// Call button
ElevatedButton.icon(
style: ElevatedButton.styleFrom(
backgroundColor: Colors.green,
foregroundColor: Colors.white,
minimumSize: const Size(double.infinity, 48),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(24),
),
),
icon: const Icon(Icons.call),
label: const Text('Call'),
onPressed: () {
final number = numberController.text.trim();
if (number.isNotEmpty) {
context.read<CallProvider>().makeCall(number, callerId: selectedCallerId);
Navigator.pop(ctx);
}
},
),
const SizedBox(height: 16),
],
),
);
},
);
},
);
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final agent = context.watch<AgentProvider>(); final agent = context.watch<AgentProvider>();
@@ -58,6 +176,10 @@ class _DashboardScreenState extends State<DashboardScreen> {
), ),
], ],
), ),
floatingActionButton: FloatingActionButton(
onPressed: () => _showDialer(context),
child: const Icon(Icons.phone),
),
body: RefreshIndicator( body: RefreshIndicator(
onRefresh: () => agent.refresh(), onRefresh: () => agent.refresh(),
child: ListView( child: ListView(

View File

@@ -1,4 +1,5 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import '../providers/auth_provider.dart'; import '../providers/auth_provider.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart';
@@ -39,6 +40,7 @@ class _LoginScreenState extends State<LoginScreen> {
serverUrl = 'https://$serverUrl'; serverUrl = 'https://$serverUrl';
} }
TextInput.finishAutofillContext();
context.read<AuthProvider>().login( context.read<AuthProvider>().login(
serverUrl, serverUrl,
_usernameController.text.trim(), _usernameController.text.trim(),
@@ -55,7 +57,8 @@ class _LoginScreenState extends State<LoginScreen> {
child: Center( child: Center(
child: SingleChildScrollView( child: SingleChildScrollView(
padding: const EdgeInsets.all(24), padding: const EdgeInsets.all(24),
child: Form( child: AutofillGroup(
child: Form(
key: _formKey, key: _formKey,
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
@@ -80,6 +83,7 @@ class _LoginScreenState extends State<LoginScreen> {
border: OutlineInputBorder(), border: OutlineInputBorder(),
), ),
keyboardType: TextInputType.url, keyboardType: TextInputType.url,
autofillHints: const [AutofillHints.url],
validator: (v) => validator: (v) =>
v == null || v.trim().isEmpty ? 'Required' : null, v == null || v.trim().isEmpty ? 'Required' : null,
), ),
@@ -91,6 +95,7 @@ class _LoginScreenState extends State<LoginScreen> {
prefixIcon: Icon(Icons.person), prefixIcon: Icon(Icons.person),
border: OutlineInputBorder(), border: OutlineInputBorder(),
), ),
autofillHints: const [AutofillHints.username],
validator: (v) => validator: (v) =>
v == null || v.trim().isEmpty ? 'Required' : null, v == null || v.trim().isEmpty ? 'Required' : null,
), ),
@@ -110,6 +115,7 @@ class _LoginScreenState extends State<LoginScreen> {
), ),
), ),
obscureText: _obscurePassword, obscureText: _obscurePassword,
autofillHints: const [AutofillHints.password],
validator: (v) => validator: (v) =>
v == null || v.isEmpty ? 'Required' : null, v == null || v.isEmpty ? 'Required' : null,
), ),
@@ -142,6 +148,7 @@ class _LoginScreenState extends State<LoginScreen> {
], ],
), ),
), ),
),
), ),
), ),
), ),

View File

@@ -1,4 +1,5 @@
import 'dart:async'; import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:twilio_voice/twilio_voice.dart'; import 'package:twilio_voice/twilio_voice.dart';
import 'api_client.dart'; import 'api_client.dart';
@@ -36,7 +37,7 @@ class VoiceService {
_identity = data['identity'] as String; _identity = data['identity'] as String;
await TwilioVoice.instance.setTokens(accessToken: token); await TwilioVoice.instance.setTokens(accessToken: token);
} catch (e) { } catch (e) {
// Token fetch failed - will retry on next interval debugPrint('VoiceService._fetchAndRegisterToken error: $e');
} }
} }
@@ -62,6 +63,23 @@ class VoiceService {
await TwilioVoice.instance.call.toggleSpeaker(speaker); await TwilioVoice.instance.call.toggleSpeaker(speaker);
} }
Future<bool> makeCall(String to, {String? callerId}) async {
try {
final extraOptions = <String, dynamic>{};
if (callerId != null && callerId.isNotEmpty) {
extraOptions['CallerId'] = callerId;
}
return await TwilioVoice.instance.call.place(
to: to,
from: _identity ?? '',
extraOptions: extraOptions,
) ?? false;
} catch (e) {
debugPrint('VoiceService.makeCall error: $e');
return false;
}
}
Future<void> sendDigits(String digits) async { Future<void> sendDigits(String digits) async {
await TwilioVoice.instance.call.sendDigits(digits); await TwilioVoice.instance.call.sendDigits(digits);
} }