Fix mobile app: AccessToken for voice, Agent Manager for status, caller ID support
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:
Claude
2026-03-06 18:05:54 -08:00
parent 8cc6fa8c3c
commit 7df6090554
7 changed files with 224 additions and 113 deletions

View File

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

View File

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

View File

@@ -103,13 +103,13 @@ class CallProvider extends ChangeNotifier {
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(
state: CallState.connecting,
callerNumber: number,
);
notifyListeners();
await _voiceService.makeCall(number);
await _voiceService.makeCall(number, callerId: callerId);
}
Future<void> holdCall() async {

View File

@@ -26,6 +26,7 @@ class _DashboardScreenState extends State<DashboardScreen> {
void _showDialer(BuildContext context) {
final numberController = TextEditingController();
String? selectedCallerId;
showModalBottomSheet(
context: context,
@@ -34,75 +35,107 @@ class _DashboardScreenState extends State<DashboardScreen> {
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),
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>(
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: () {
final text = numberController.text;
if (text.isNotEmpty) {
numberController.text =
text.substring(0, text.length - 1);
numberController.selection = TextSelection.fromPosition(
TextPosition(offset: numberController.text.length),
);
final number = numberController.text.trim();
if (number.isNotEmpty) {
context.read<CallProvider>().makeCall(number, callerId: selectedCallerId);
Navigator.pop(ctx);
}
},
),
),
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),
],
),
);
},
);
},
);

View File

@@ -1,4 +1,5 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:twilio_voice/twilio_voice.dart';
import 'api_client.dart';
@@ -36,7 +37,7 @@ class VoiceService {
_identity = data['identity'] as String;
await TwilioVoice.instance.setTokens(accessToken: token);
} 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);
}
Future<bool> makeCall(String to) async {
return await TwilioVoice.instance.call.place(to: to, from: _identity ?? '') ?? false;
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 {