Fix mobile app: AccessToken for voice, Agent Manager for status, caller ID support
All checks were successful
Create Release / build (push) Successful in 3s
All checks were successful
Create Release / build (push) Successful in 3s
- 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>
This commit is contained in:
@@ -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,
|
||||||
@@ -221,6 +205,7 @@ class TWP_Mobile_API {
|
|||||||
));
|
));
|
||||||
|
|
||||||
if (!$existing_extension) {
|
if (!$existing_extension) {
|
||||||
|
require_once plugin_dir_path(__FILE__) . 'class-twp-user-queue-manager.php';
|
||||||
TWP_User_Queue_Manager::create_user_queues($user_id);
|
TWP_User_Queue_Manager::create_user_queues($user_id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -693,18 +678,29 @@ class TWP_Mobile_API {
|
|||||||
$identity = 'agent' . $user_id . $clean_name;
|
$identity = 'agent' . $user_id . $clean_name;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// Ensure Twilio SDK autoloader is loaded
|
||||||
require_once plugin_dir_path(dirname(__FILE__)) . 'includes/class-twp-twilio-api.php';
|
require_once plugin_dir_path(dirname(__FILE__)) . 'includes/class-twp-twilio-api.php';
|
||||||
$twilio = new TWP_Twilio_API();
|
new TWP_Twilio_API();
|
||||||
$result = $twilio->generate_capability_token($identity);
|
|
||||||
|
|
||||||
if (!$result['success']) {
|
$account_sid = get_option('twp_twilio_account_sid');
|
||||||
return new WP_Error('token_error', $result['error'], array('status' => 500));
|
$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->setOutgoingApplicationSid($twiml_app_sid);
|
||||||
|
$voiceGrant->setIncomingAllow(true);
|
||||||
|
$token->addGrant($voiceGrant);
|
||||||
|
|
||||||
return new WP_REST_Response(array(
|
return new WP_REST_Response(array(
|
||||||
'token' => $result['data']['token'],
|
'token' => $token->toJWT(),
|
||||||
'identity' => $result['data']['client_name'],
|
'identity' => $identity,
|
||||||
'expires_in' => $result['data']['expires_in']
|
'expires_in' => 3600
|
||||||
), 200);
|
), 200);
|
||||||
|
|
||||||
} catch (Exception $e) {
|
} catch (Exception $e) {
|
||||||
@@ -712,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
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -103,13 +103,13 @@ 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) async {
|
Future<void> makeCall(String number, {String? callerId}) async {
|
||||||
_callInfo = _callInfo.copyWith(
|
_callInfo = _callInfo.copyWith(
|
||||||
state: CallState.connecting,
|
state: CallState.connecting,
|
||||||
callerNumber: number,
|
callerNumber: number,
|
||||||
);
|
);
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
await _voiceService.makeCall(number);
|
await _voiceService.makeCall(number, callerId: callerId);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> holdCall() async {
|
Future<void> holdCall() async {
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ class _DashboardScreenState extends State<DashboardScreen> {
|
|||||||
|
|
||||||
void _showDialer(BuildContext context) {
|
void _showDialer(BuildContext context) {
|
||||||
final numberController = TextEditingController();
|
final numberController = TextEditingController();
|
||||||
|
String? selectedCallerId;
|
||||||
|
|
||||||
showModalBottomSheet(
|
showModalBottomSheet(
|
||||||
context: context,
|
context: context,
|
||||||
@@ -34,75 +35,107 @@ class _DashboardScreenState extends State<DashboardScreen> {
|
|||||||
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
|
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
|
||||||
),
|
),
|
||||||
builder: (ctx) {
|
builder: (ctx) {
|
||||||
return Padding(
|
final phoneNumbers = context.read<AgentProvider>().phoneNumbers;
|
||||||
padding: EdgeInsets.only(
|
return StatefulBuilder(
|
||||||
bottom: MediaQuery.of(ctx).viewInsets.bottom,
|
builder: (ctx, setSheetState) {
|
||||||
top: 16,
|
return Padding(
|
||||||
left: 16,
|
padding: EdgeInsets.only(
|
||||||
right: 16,
|
bottom: MediaQuery.of(ctx).viewInsets.bottom,
|
||||||
),
|
top: 16,
|
||||||
child: Column(
|
left: 16,
|
||||||
mainAxisSize: MainAxisSize.min,
|
right: 16,
|
||||||
children: [
|
),
|
||||||
// Number display
|
child: Column(
|
||||||
TextField(
|
mainAxisSize: MainAxisSize.min,
|
||||||
controller: numberController,
|
children: [
|
||||||
keyboardType: TextInputType.phone,
|
// Number display
|
||||||
autofillHints: const [AutofillHints.telephoneNumber],
|
TextField(
|
||||||
textAlign: TextAlign.center,
|
controller: numberController,
|
||||||
style: Theme.of(ctx).textTheme.headlineSmall,
|
keyboardType: TextInputType.phone,
|
||||||
decoration: InputDecoration(
|
autofillHints: const [AutofillHints.telephoneNumber],
|
||||||
hintText: 'Enter phone number',
|
textAlign: TextAlign.center,
|
||||||
suffixIcon: IconButton(
|
style: Theme.of(ctx).textTheme.headlineSmall,
|
||||||
icon: const Icon(Icons.backspace_outlined),
|
decoration: InputDecoration(
|
||||||
|
hintText: 'Enter phone number',
|
||||||
|
suffixIcon: IconButton(
|
||||||
|
icon: const Icon(Icons.backspace_outlined),
|
||||||
|
onPressed: () {
|
||||||
|
final text = numberController.text;
|
||||||
|
if (text.isNotEmpty) {
|
||||||
|
numberController.text =
|
||||||
|
text.substring(0, text.length - 1);
|
||||||
|
numberController.selection = TextSelection.fromPosition(
|
||||||
|
TextPosition(offset: numberController.text.length),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
// Caller ID selector
|
||||||
|
if (phoneNumbers.isNotEmpty) ...[
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
DropdownButtonFormField<String>(
|
||||||
|
initialValue: selectedCallerId,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Caller ID',
|
||||||
|
isDense: true,
|
||||||
|
contentPadding: EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||||
|
),
|
||||||
|
items: [
|
||||||
|
const DropdownMenuItem<String>(
|
||||||
|
value: null,
|
||||||
|
child: Text('Default'),
|
||||||
|
),
|
||||||
|
...phoneNumbers.map((p) => DropdownMenuItem<String>(
|
||||||
|
value: p.phoneNumber,
|
||||||
|
child: Text('${p.friendlyName} (${p.phoneNumber})'),
|
||||||
|
)),
|
||||||
|
],
|
||||||
|
onChanged: (value) {
|
||||||
|
setSheetState(() {
|
||||||
|
selectedCallerId = value;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
// Dialpad
|
||||||
|
Dialpad(
|
||||||
|
onDigit: (digit) {
|
||||||
|
numberController.text += digit;
|
||||||
|
numberController.selection = TextSelection.fromPosition(
|
||||||
|
TextPosition(offset: numberController.text.length),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
onClose: () => Navigator.pop(ctx),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
// Call button
|
||||||
|
ElevatedButton.icon(
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
backgroundColor: Colors.green,
|
||||||
|
foregroundColor: Colors.white,
|
||||||
|
minimumSize: const Size(double.infinity, 48),
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(24),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
icon: const Icon(Icons.call),
|
||||||
|
label: const Text('Call'),
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
final text = numberController.text;
|
final number = numberController.text.trim();
|
||||||
if (text.isNotEmpty) {
|
if (number.isNotEmpty) {
|
||||||
numberController.text =
|
context.read<CallProvider>().makeCall(number, callerId: selectedCallerId);
|
||||||
text.substring(0, text.length - 1);
|
Navigator.pop(ctx);
|
||||||
numberController.selection = TextSelection.fromPosition(
|
|
||||||
TextPosition(offset: numberController.text.length),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
),
|
const SizedBox(height: 16),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
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),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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,8 +63,21 @@ class VoiceService {
|
|||||||
await TwilioVoice.instance.call.toggleSpeaker(speaker);
|
await TwilioVoice.instance.call.toggleSpeaker(speaker);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<bool> makeCall(String to) async {
|
Future<bool> makeCall(String to, {String? callerId}) async {
|
||||||
return await TwilioVoice.instance.call.place(to: to, from: _identity ?? '') ?? false;
|
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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user