Add TWP Softphone Flutter app and complete mobile backend API
All checks were successful
Create Release / build (push) Successful in 4s
All checks were successful
Create Release / build (push) Successful in 4s
Backend: Add /voice/token endpoint with AccessToken + VoiceGrant for mobile VoIP, implement unhold_call() with call leg detection, wire FCM push notifications into call queue and webhook missed call handlers, add data-only FCM message support for Android background wake, and add Twilio API Key / Push Credential settings fields. Flutter app: Full softphone with Twilio Voice SDK integration, JWT auth with auto-refresh, SSE real-time queue updates, FCM push notifications, Material 3 UI with dashboard, active call screen, dialpad, and call controls (mute/speaker/hold/transfer). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
65
mobile/lib/app.dart
Normal file
65
mobile/lib/app.dart
Normal file
@@ -0,0 +1,65 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'services/api_client.dart';
|
||||
import 'providers/auth_provider.dart';
|
||||
import 'providers/agent_provider.dart';
|
||||
import 'providers/call_provider.dart';
|
||||
import 'screens/login_screen.dart';
|
||||
import 'screens/dashboard_screen.dart';
|
||||
|
||||
class TwpSoftphoneApp extends StatefulWidget {
|
||||
const TwpSoftphoneApp({super.key});
|
||||
|
||||
@override
|
||||
State<TwpSoftphoneApp> createState() => _TwpSoftphoneAppState();
|
||||
}
|
||||
|
||||
class _TwpSoftphoneAppState extends State<TwpSoftphoneApp> {
|
||||
final _apiClient = ApiClient();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ChangeNotifierProvider(
|
||||
create: (_) {
|
||||
final auth = AuthProvider(_apiClient);
|
||||
auth.tryRestoreSession();
|
||||
return auth;
|
||||
},
|
||||
child: MaterialApp(
|
||||
title: 'TWP Softphone',
|
||||
debugShowCheckedModeBanner: false,
|
||||
theme: ThemeData(
|
||||
colorSchemeSeed: Colors.blue,
|
||||
useMaterial3: true,
|
||||
brightness: Brightness.light,
|
||||
),
|
||||
darkTheme: ThemeData(
|
||||
colorSchemeSeed: Colors.blue,
|
||||
useMaterial3: true,
|
||||
brightness: Brightness.dark,
|
||||
),
|
||||
home: Consumer<AuthProvider>(
|
||||
builder: (context, auth, _) {
|
||||
if (auth.state == AuthState.authenticated) {
|
||||
return MultiProvider(
|
||||
providers: [
|
||||
ChangeNotifierProvider(
|
||||
create: (_) => AgentProvider(
|
||||
auth.apiClient,
|
||||
auth.sseService,
|
||||
)..refresh(),
|
||||
),
|
||||
ChangeNotifierProvider(
|
||||
create: (_) => CallProvider(auth.voiceService),
|
||||
),
|
||||
],
|
||||
child: const DashboardScreen(),
|
||||
);
|
||||
}
|
||||
return const LoginScreen();
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
8
mobile/lib/config/app_config.dart
Normal file
8
mobile/lib/config/app_config.dart
Normal file
@@ -0,0 +1,8 @@
|
||||
class AppConfig {
|
||||
static const String appName = 'TWP Softphone';
|
||||
static const Duration tokenRefreshInterval = Duration(minutes: 50);
|
||||
static const Duration sseReconnectBase = Duration(seconds: 2);
|
||||
static const Duration sseMaxReconnect = Duration(seconds: 60);
|
||||
static const int sseServerTimeout = 300; // server closes after 5 min
|
||||
static const String defaultScheme = 'https';
|
||||
}
|
||||
9
mobile/lib/main.dart
Normal file
9
mobile/lib/main.dart
Normal file
@@ -0,0 +1,9 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:firebase_core/firebase_core.dart';
|
||||
import 'app.dart';
|
||||
|
||||
void main() async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
await Firebase.initializeApp();
|
||||
runApp(const TwpSoftphoneApp());
|
||||
}
|
||||
38
mobile/lib/models/agent_status.dart
Normal file
38
mobile/lib/models/agent_status.dart
Normal file
@@ -0,0 +1,38 @@
|
||||
enum AgentStatusValue { available, busy, offline }
|
||||
|
||||
class AgentStatus {
|
||||
final AgentStatusValue status;
|
||||
final bool isLoggedIn;
|
||||
final String? currentCallSid;
|
||||
final String? lastActivity;
|
||||
final bool availableForQueues;
|
||||
|
||||
AgentStatus({
|
||||
required this.status,
|
||||
required this.isLoggedIn,
|
||||
this.currentCallSid,
|
||||
this.lastActivity,
|
||||
this.availableForQueues = true,
|
||||
});
|
||||
|
||||
factory AgentStatus.fromJson(Map<String, dynamic> json) {
|
||||
return AgentStatus(
|
||||
status: _parseStatus(json['status'] as String),
|
||||
isLoggedIn: json['is_logged_in'] as bool,
|
||||
currentCallSid: json['current_call_sid'] as String?,
|
||||
lastActivity: json['last_activity'] as String?,
|
||||
availableForQueues: json['available_for_queues'] as bool? ?? true,
|
||||
);
|
||||
}
|
||||
|
||||
static AgentStatusValue _parseStatus(String s) {
|
||||
switch (s) {
|
||||
case 'available':
|
||||
return AgentStatusValue.available;
|
||||
case 'busy':
|
||||
return AgentStatusValue.busy;
|
||||
default:
|
||||
return AgentStatusValue.offline;
|
||||
}
|
||||
}
|
||||
}
|
||||
46
mobile/lib/models/call_info.dart
Normal file
46
mobile/lib/models/call_info.dart
Normal file
@@ -0,0 +1,46 @@
|
||||
enum CallState { idle, ringing, connecting, connected, disconnected }
|
||||
|
||||
class CallInfo {
|
||||
final CallState state;
|
||||
final String? callSid;
|
||||
final String? callerNumber;
|
||||
final Duration duration;
|
||||
final bool isMuted;
|
||||
final bool isSpeakerOn;
|
||||
final bool isOnHold;
|
||||
|
||||
const CallInfo({
|
||||
this.state = CallState.idle,
|
||||
this.callSid,
|
||||
this.callerNumber,
|
||||
this.duration = Duration.zero,
|
||||
this.isMuted = false,
|
||||
this.isSpeakerOn = false,
|
||||
this.isOnHold = false,
|
||||
});
|
||||
|
||||
CallInfo copyWith({
|
||||
CallState? state,
|
||||
String? callSid,
|
||||
String? callerNumber,
|
||||
Duration? duration,
|
||||
bool? isMuted,
|
||||
bool? isSpeakerOn,
|
||||
bool? isOnHold,
|
||||
}) {
|
||||
return CallInfo(
|
||||
state: state ?? this.state,
|
||||
callSid: callSid ?? this.callSid,
|
||||
callerNumber: callerNumber ?? this.callerNumber,
|
||||
duration: duration ?? this.duration,
|
||||
isMuted: isMuted ?? this.isMuted,
|
||||
isSpeakerOn: isSpeakerOn ?? this.isSpeakerOn,
|
||||
isOnHold: isOnHold ?? this.isOnHold,
|
||||
);
|
||||
}
|
||||
|
||||
bool get isActive =>
|
||||
state == CallState.ringing ||
|
||||
state == CallState.connecting ||
|
||||
state == CallState.connected;
|
||||
}
|
||||
54
mobile/lib/models/queue_state.dart
Normal file
54
mobile/lib/models/queue_state.dart
Normal file
@@ -0,0 +1,54 @@
|
||||
class QueueInfo {
|
||||
final int id;
|
||||
final String name;
|
||||
final String type;
|
||||
final String? extension;
|
||||
final int waitingCount;
|
||||
|
||||
QueueInfo({
|
||||
required this.id,
|
||||
required this.name,
|
||||
required this.type,
|
||||
this.extension,
|
||||
required this.waitingCount,
|
||||
});
|
||||
|
||||
factory QueueInfo.fromJson(Map<String, dynamic> json) {
|
||||
return QueueInfo(
|
||||
id: json['id'] as int,
|
||||
name: json['name'] as String,
|
||||
type: json['type'] as String,
|
||||
extension: json['extension'] as String?,
|
||||
waitingCount: json['waiting_count'] as int,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class QueueCall {
|
||||
final String callSid;
|
||||
final String fromNumber;
|
||||
final String toNumber;
|
||||
final int position;
|
||||
final String status;
|
||||
final int waitTime;
|
||||
|
||||
QueueCall({
|
||||
required this.callSid,
|
||||
required this.fromNumber,
|
||||
required this.toNumber,
|
||||
required this.position,
|
||||
required this.status,
|
||||
required this.waitTime,
|
||||
});
|
||||
|
||||
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,
|
||||
);
|
||||
}
|
||||
}
|
||||
22
mobile/lib/models/user.dart
Normal file
22
mobile/lib/models/user.dart
Normal file
@@ -0,0 +1,22 @@
|
||||
class User {
|
||||
final int id;
|
||||
final String login;
|
||||
final String displayName;
|
||||
final String? email;
|
||||
|
||||
User({
|
||||
required this.id,
|
||||
required this.login,
|
||||
required this.displayName,
|
||||
this.email,
|
||||
});
|
||||
|
||||
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,
|
||||
email: json['email'] as String?,
|
||||
);
|
||||
}
|
||||
}
|
||||
88
mobile/lib/providers/agent_provider.dart
Normal file
88
mobile/lib/providers/agent_provider.dart
Normal file
@@ -0,0 +1,88 @@
|
||||
import 'dart:async';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import '../models/agent_status.dart';
|
||||
import '../models/queue_state.dart';
|
||||
import '../services/api_client.dart';
|
||||
import '../services/sse_service.dart';
|
||||
|
||||
class AgentProvider extends ChangeNotifier {
|
||||
final ApiClient _api;
|
||||
final SseService _sse;
|
||||
|
||||
AgentStatus? _status;
|
||||
List<QueueInfo> _queues = [];
|
||||
bool _sseConnected = false;
|
||||
StreamSubscription? _sseSub;
|
||||
StreamSubscription? _connSub;
|
||||
|
||||
AgentStatus? get status => _status;
|
||||
List<QueueInfo> get queues => _queues;
|
||||
bool get sseConnected => _sseConnected;
|
||||
|
||||
AgentProvider(this._api, this._sse) {
|
||||
_connSub = _sse.connectionState.listen((connected) {
|
||||
_sseConnected = connected;
|
||||
notifyListeners();
|
||||
});
|
||||
|
||||
_sseSub = _sse.events.listen(_handleSseEvent);
|
||||
}
|
||||
|
||||
Future<void> fetchStatus() async {
|
||||
try {
|
||||
final response = await _api.dio.get('/agent/status');
|
||||
_status = AgentStatus.fromJson(response.data);
|
||||
notifyListeners();
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
Future<void> updateStatus(AgentStatusValue newStatus) async {
|
||||
final statusStr = newStatus.name;
|
||||
try {
|
||||
await _api.dio.post('/agent/status', data: {
|
||||
'status': statusStr,
|
||||
'is_logged_in': true,
|
||||
});
|
||||
_status = AgentStatus(
|
||||
status: newStatus,
|
||||
isLoggedIn: true,
|
||||
currentCallSid: _status?.currentCallSid,
|
||||
);
|
||||
notifyListeners();
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
Future<void> fetchQueues() async {
|
||||
try {
|
||||
final response = await _api.dio.get('/queues/state');
|
||||
final data = response.data;
|
||||
_queues = (data['queues'] as List)
|
||||
.map((q) => QueueInfo.fromJson(q as Map<String, dynamic>))
|
||||
.toList();
|
||||
notifyListeners();
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
Future<void> refresh() async {
|
||||
await Future.wait([fetchStatus(), fetchQueues()]);
|
||||
}
|
||||
|
||||
void _handleSseEvent(SseEvent event) {
|
||||
switch (event.event) {
|
||||
case 'call_enqueued':
|
||||
case 'call_dequeued':
|
||||
fetchQueues();
|
||||
break;
|
||||
case 'agent_status_changed':
|
||||
fetchStatus();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_sseSub?.cancel();
|
||||
_connSub?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
107
mobile/lib/providers/auth_provider.dart
Normal file
107
mobile/lib/providers/auth_provider.dart
Normal file
@@ -0,0 +1,107 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import '../models/user.dart';
|
||||
import '../services/api_client.dart';
|
||||
import '../services/auth_service.dart';
|
||||
import '../services/voice_service.dart';
|
||||
import '../services/push_notification_service.dart';
|
||||
import '../services/sse_service.dart';
|
||||
|
||||
enum AuthState { unauthenticated, authenticating, authenticated }
|
||||
|
||||
class AuthProvider extends ChangeNotifier {
|
||||
final ApiClient _apiClient;
|
||||
late final AuthService _authService;
|
||||
late final VoiceService _voiceService;
|
||||
late final PushNotificationService _pushService;
|
||||
late final SseService _sseService;
|
||||
|
||||
AuthState _state = AuthState.unauthenticated;
|
||||
User? _user;
|
||||
String? _error;
|
||||
|
||||
AuthState get state => _state;
|
||||
User? get user => _user;
|
||||
String? get error => _error;
|
||||
VoiceService get voiceService => _voiceService;
|
||||
SseService get sseService => _sseService;
|
||||
ApiClient get apiClient => _apiClient;
|
||||
|
||||
AuthProvider(this._apiClient) {
|
||||
_authService = AuthService(_apiClient);
|
||||
_voiceService = VoiceService(_apiClient);
|
||||
_pushService = PushNotificationService(_apiClient);
|
||||
_sseService = SseService(_apiClient);
|
||||
|
||||
_apiClient.onForceLogout = _handleForceLogout;
|
||||
}
|
||||
|
||||
Future<void> tryRestoreSession() async {
|
||||
final restored = await _authService.tryRestoreSession();
|
||||
if (restored) {
|
||||
_state = AuthState.authenticated;
|
||||
await _initializeServices();
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> login(String serverUrl, String username, String password) async {
|
||||
_state = AuthState.authenticating;
|
||||
_error = null;
|
||||
notifyListeners();
|
||||
|
||||
try {
|
||||
_user = await _authService.login(serverUrl, username, password);
|
||||
_state = AuthState.authenticated;
|
||||
await _initializeServices();
|
||||
} catch (e) {
|
||||
_state = AuthState.unauthenticated;
|
||||
_error = e.toString().replaceFirst('Exception: ', '');
|
||||
}
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> _initializeServices() async {
|
||||
try {
|
||||
await _pushService.initialize();
|
||||
} catch (_) {}
|
||||
try {
|
||||
await _voiceService.initialize();
|
||||
} catch (_) {}
|
||||
try {
|
||||
await _sseService.connect();
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
Future<void> logout() async {
|
||||
_voiceService.dispose();
|
||||
_sseService.disconnect();
|
||||
await _authService.logout();
|
||||
|
||||
_state = AuthState.unauthenticated;
|
||||
_user = null;
|
||||
_error = null;
|
||||
|
||||
// Re-create services for potential re-login
|
||||
_voiceService = VoiceService(_apiClient);
|
||||
_pushService = PushNotificationService(_apiClient);
|
||||
_sseService = SseService(_apiClient);
|
||||
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void _handleForceLogout() {
|
||||
_state = AuthState.unauthenticated;
|
||||
_user = null;
|
||||
_error = 'Session expired. Please log in again.';
|
||||
_sseService.disconnect();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_authService.dispose();
|
||||
_voiceService.dispose();
|
||||
_sseService.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
134
mobile/lib/providers/call_provider.dart
Normal file
134
mobile/lib/providers/call_provider.dart
Normal file
@@ -0,0 +1,134 @@
|
||||
import 'dart:async';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:twilio_voice/twilio_voice.dart';
|
||||
import '../models/call_info.dart';
|
||||
import '../services/voice_service.dart';
|
||||
|
||||
class CallProvider extends ChangeNotifier {
|
||||
final VoiceService _voiceService;
|
||||
CallInfo _callInfo = const CallInfo();
|
||||
Timer? _durationTimer;
|
||||
StreamSubscription? _eventSub;
|
||||
DateTime? _connectedAt;
|
||||
|
||||
CallInfo get callInfo => _callInfo;
|
||||
|
||||
CallProvider(this._voiceService) {
|
||||
_eventSub = _voiceService.callEvents.listen(_handleCallEvent);
|
||||
}
|
||||
|
||||
void _handleCallEvent(CallEvent event) {
|
||||
switch (event) {
|
||||
case CallEvent.incoming:
|
||||
_callInfo = _callInfo.copyWith(
|
||||
state: CallState.ringing,
|
||||
);
|
||||
break;
|
||||
case CallEvent.ringing:
|
||||
_callInfo = _callInfo.copyWith(state: CallState.connecting);
|
||||
break;
|
||||
case CallEvent.connected:
|
||||
_connectedAt = DateTime.now();
|
||||
_callInfo = _callInfo.copyWith(state: CallState.connected);
|
||||
_startDurationTimer();
|
||||
break;
|
||||
case CallEvent.callEnded:
|
||||
_stopDurationTimer();
|
||||
_callInfo = const CallInfo(); // reset to idle
|
||||
break;
|
||||
case CallEvent.returningCall:
|
||||
_callInfo = _callInfo.copyWith(state: CallState.connecting);
|
||||
break;
|
||||
case CallEvent.reconnecting:
|
||||
break;
|
||||
case CallEvent.reconnected:
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
// Update caller info from active call
|
||||
final call = TwilioVoice.instance.call;
|
||||
final active = call.activeCall;
|
||||
if (active != null) {
|
||||
_callInfo = _callInfo.copyWith(
|
||||
callerNumber: active.from,
|
||||
);
|
||||
// Fetch SID asynchronously
|
||||
call.getSid().then((sid) {
|
||||
if (sid != null && sid != _callInfo.callSid) {
|
||||
_callInfo = _callInfo.copyWith(callSid: sid);
|
||||
notifyListeners();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void _startDurationTimer() {
|
||||
_durationTimer?.cancel();
|
||||
_durationTimer = Timer.periodic(const Duration(seconds: 1), (_) {
|
||||
if (_connectedAt != null) {
|
||||
_callInfo = _callInfo.copyWith(
|
||||
duration: DateTime.now().difference(_connectedAt!),
|
||||
);
|
||||
notifyListeners();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void _stopDurationTimer() {
|
||||
_durationTimer?.cancel();
|
||||
_connectedAt = null;
|
||||
}
|
||||
|
||||
Future<void> answer() => _voiceService.answer();
|
||||
Future<void> reject() => _voiceService.reject();
|
||||
Future<void> hangUp() => _voiceService.hangUp();
|
||||
|
||||
Future<void> toggleMute() async {
|
||||
final newMuted = !_callInfo.isMuted;
|
||||
await _voiceService.toggleMute(newMuted);
|
||||
_callInfo = _callInfo.copyWith(isMuted: newMuted);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> toggleSpeaker() async {
|
||||
final newSpeaker = !_callInfo.isSpeakerOn;
|
||||
await _voiceService.toggleSpeaker(newSpeaker);
|
||||
_callInfo = _callInfo.copyWith(isSpeakerOn: newSpeaker);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> sendDigits(String digits) => _voiceService.sendDigits(digits);
|
||||
|
||||
Future<void> holdCall() async {
|
||||
final sid = _callInfo.callSid;
|
||||
if (sid == null) return;
|
||||
await _voiceService.holdCall(sid);
|
||||
_callInfo = _callInfo.copyWith(isOnHold: true);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> unholdCall() async {
|
||||
final sid = _callInfo.callSid;
|
||||
if (sid == null) return;
|
||||
await _voiceService.unholdCall(sid);
|
||||
_callInfo = _callInfo.copyWith(isOnHold: false);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> transferCall(String target) async {
|
||||
final sid = _callInfo.callSid;
|
||||
if (sid == null) return;
|
||||
await _voiceService.transferCall(sid, target);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_stopDurationTimer();
|
||||
_eventSub?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
137
mobile/lib/screens/active_call_screen.dart
Normal file
137
mobile/lib/screens/active_call_screen.dart
Normal file
@@ -0,0 +1,137 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../providers/call_provider.dart';
|
||||
import '../models/call_info.dart';
|
||||
import '../widgets/call_controls.dart';
|
||||
import '../widgets/dialpad.dart';
|
||||
|
||||
class ActiveCallScreen extends StatefulWidget {
|
||||
const ActiveCallScreen({super.key});
|
||||
|
||||
@override
|
||||
State<ActiveCallScreen> createState() => _ActiveCallScreenState();
|
||||
}
|
||||
|
||||
class _ActiveCallScreenState extends State<ActiveCallScreen> {
|
||||
bool _showDialpad = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final call = context.watch<CallProvider>();
|
||||
final info = call.callInfo;
|
||||
|
||||
// Pop back when call ends
|
||||
if (info.state == CallState.idle) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted) Navigator.of(context).pop();
|
||||
});
|
||||
}
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: Theme.of(context).colorScheme.surfaceContainerHighest,
|
||||
body: SafeArea(
|
||||
child: Column(
|
||||
children: [
|
||||
const Spacer(flex: 2),
|
||||
// Caller info
|
||||
Text(
|
||||
info.callerNumber ?? 'Unknown',
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.headlineMedium
|
||||
?.copyWith(fontWeight: FontWeight.bold),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
_stateLabel(info.state),
|
||||
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
if (info.state == CallState.connected)
|
||||
Text(
|
||||
_formatDuration(info.duration),
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
const Spacer(flex: 2),
|
||||
// Dialpad overlay
|
||||
if (_showDialpad)
|
||||
Dialpad(
|
||||
onDigit: (d) => call.sendDigits(d),
|
||||
onClose: () => setState(() => _showDialpad = false),
|
||||
),
|
||||
// Controls
|
||||
if (!_showDialpad)
|
||||
CallControls(
|
||||
callInfo: info,
|
||||
onMute: () => call.toggleMute(),
|
||||
onSpeaker: () => call.toggleSpeaker(),
|
||||
onHold: () =>
|
||||
info.isOnHold ? call.unholdCall() : call.holdCall(),
|
||||
onDialpad: () => setState(() => _showDialpad = true),
|
||||
onTransfer: () => _showTransferDialog(context, call),
|
||||
onHangUp: () => call.hangUp(),
|
||||
),
|
||||
const Spacer(),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _stateLabel(CallState state) {
|
||||
switch (state) {
|
||||
case CallState.ringing:
|
||||
return 'Ringing...';
|
||||
case CallState.connecting:
|
||||
return 'Connecting...';
|
||||
case CallState.connected:
|
||||
return 'Connected';
|
||||
case CallState.disconnected:
|
||||
return 'Disconnected';
|
||||
case CallState.idle:
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
String _formatDuration(Duration d) {
|
||||
final minutes = d.inMinutes.toString().padLeft(2, '0');
|
||||
final seconds = (d.inSeconds % 60).toString().padLeft(2, '0');
|
||||
return '$minutes:$seconds';
|
||||
}
|
||||
|
||||
void _showTransferDialog(BuildContext context, CallProvider call) {
|
||||
final controller = TextEditingController();
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: const Text('Transfer Call'),
|
||||
content: TextField(
|
||||
controller: controller,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Extension or Queue ID',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
keyboardType: TextInputType.number,
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () {
|
||||
final target = controller.text.trim();
|
||||
if (target.isNotEmpty) {
|
||||
call.transferCall(target);
|
||||
Navigator.pop(ctx);
|
||||
}
|
||||
},
|
||||
child: const Text('Transfer'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
88
mobile/lib/screens/dashboard_screen.dart
Normal file
88
mobile/lib/screens/dashboard_screen.dart
Normal file
@@ -0,0 +1,88 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../providers/agent_provider.dart';
|
||||
import '../providers/call_provider.dart';
|
||||
import '../widgets/agent_status_toggle.dart';
|
||||
import '../widgets/queue_card.dart';
|
||||
import 'active_call_screen.dart';
|
||||
import 'settings_screen.dart';
|
||||
|
||||
class DashboardScreen extends StatefulWidget {
|
||||
const DashboardScreen({super.key});
|
||||
|
||||
@override
|
||||
State<DashboardScreen> createState() => _DashboardScreenState();
|
||||
}
|
||||
|
||||
class _DashboardScreenState extends State<DashboardScreen> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
context.read<AgentProvider>().refresh();
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final agent = context.watch<AgentProvider>();
|
||||
final call = context.watch<CallProvider>();
|
||||
|
||||
// Navigate to active call screen when a call comes in
|
||||
if (call.callInfo.isActive) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
Navigator.of(context).pushAndRemoveUntil(
|
||||
MaterialPageRoute(builder: (_) => const ActiveCallScreen()),
|
||||
(route) => route.isFirst,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('TWP Softphone'),
|
||||
actions: [
|
||||
// SSE connection indicator
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: 8),
|
||||
child: Icon(
|
||||
Icons.circle,
|
||||
size: 12,
|
||||
color: agent.sseConnected ? Colors.green : Colors.red,
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.settings),
|
||||
onPressed: () => Navigator.push(context,
|
||||
MaterialPageRoute(builder: (_) => const SettingsScreen())),
|
||||
),
|
||||
],
|
||||
),
|
||||
body: RefreshIndicator(
|
||||
onRefresh: () => agent.refresh(),
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
const AgentStatusToggle(),
|
||||
const SizedBox(height: 24),
|
||||
Text('Queues',
|
||||
style: Theme.of(context).textTheme.titleMedium),
|
||||
const SizedBox(height: 8),
|
||||
if (agent.queues.isEmpty)
|
||||
const Card(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(24),
|
||||
child: Center(child: Text('No queues assigned')),
|
||||
),
|
||||
)
|
||||
else
|
||||
...agent.queues.map((q) => Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8),
|
||||
child: QueueCard(queue: q),
|
||||
)),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
158
mobile/lib/screens/login_screen.dart
Normal file
158
mobile/lib/screens/login_screen.dart
Normal file
@@ -0,0 +1,158 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../providers/auth_provider.dart';
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
|
||||
class LoginScreen extends StatefulWidget {
|
||||
const LoginScreen({super.key});
|
||||
|
||||
@override
|
||||
State<LoginScreen> createState() => _LoginScreenState();
|
||||
}
|
||||
|
||||
class _LoginScreenState extends State<LoginScreen> {
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
final _serverController = TextEditingController();
|
||||
final _usernameController = TextEditingController();
|
||||
final _passwordController = TextEditingController();
|
||||
bool _obscurePassword = true;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadSavedServer();
|
||||
}
|
||||
|
||||
Future<void> _loadSavedServer() async {
|
||||
const storage = FlutterSecureStorage();
|
||||
final saved = await storage.read(key: 'server_url');
|
||||
if (saved != null && mounted) {
|
||||
_serverController.text = saved;
|
||||
}
|
||||
}
|
||||
|
||||
void _submit() {
|
||||
if (!_formKey.currentState!.validate()) return;
|
||||
|
||||
var serverUrl = _serverController.text.trim();
|
||||
if (!serverUrl.startsWith('http')) {
|
||||
serverUrl = 'https://$serverUrl';
|
||||
}
|
||||
|
||||
context.read<AuthProvider>().login(
|
||||
serverUrl,
|
||||
_usernameController.text.trim(),
|
||||
_passwordController.text,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final auth = context.watch<AuthProvider>();
|
||||
|
||||
return Scaffold(
|
||||
body: SafeArea(
|
||||
child: Center(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Form(
|
||||
key: _formKey,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.phone_in_talk,
|
||||
size: 64,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'TWP Softphone',
|
||||
style: Theme.of(context).textTheme.headlineMedium,
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
TextFormField(
|
||||
controller: _serverController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Server URL',
|
||||
hintText: 'https://your-site.com',
|
||||
prefixIcon: Icon(Icons.dns),
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
keyboardType: TextInputType.url,
|
||||
validator: (v) =>
|
||||
v == null || v.trim().isEmpty ? 'Required' : null,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextFormField(
|
||||
controller: _usernameController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Username',
|
||||
prefixIcon: Icon(Icons.person),
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
validator: (v) =>
|
||||
v == null || v.trim().isEmpty ? 'Required' : null,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextFormField(
|
||||
controller: _passwordController,
|
||||
decoration: InputDecoration(
|
||||
labelText: 'Password',
|
||||
prefixIcon: const Icon(Icons.lock),
|
||||
border: const OutlineInputBorder(),
|
||||
suffixIcon: IconButton(
|
||||
icon: Icon(_obscurePassword
|
||||
? Icons.visibility_off
|
||||
: Icons.visibility),
|
||||
onPressed: () =>
|
||||
setState(() => _obscurePassword = !_obscurePassword),
|
||||
),
|
||||
),
|
||||
obscureText: _obscurePassword,
|
||||
validator: (v) =>
|
||||
v == null || v.isEmpty ? 'Required' : null,
|
||||
),
|
||||
if (auth.error != null) ...[
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
auth.error!,
|
||||
style: TextStyle(
|
||||
color: Theme.of(context).colorScheme.error),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 24),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
height: 48,
|
||||
child: FilledButton(
|
||||
onPressed: auth.state == AuthState.authenticating
|
||||
? null
|
||||
: _submit,
|
||||
child: auth.state == AuthState.authenticating
|
||||
? const SizedBox(
|
||||
width: 24,
|
||||
height: 24,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2, color: Colors.white),
|
||||
)
|
||||
: const Text('Connect'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_serverController.dispose();
|
||||
_usernameController.dispose();
|
||||
_passwordController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
68
mobile/lib/screens/settings_screen.dart
Normal file
68
mobile/lib/screens/settings_screen.dart
Normal file
@@ -0,0 +1,68 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
import '../providers/auth_provider.dart';
|
||||
|
||||
class SettingsScreen extends StatefulWidget {
|
||||
const SettingsScreen({super.key});
|
||||
|
||||
@override
|
||||
State<SettingsScreen> createState() => _SettingsScreenState();
|
||||
}
|
||||
|
||||
class _SettingsScreenState extends State<SettingsScreen> {
|
||||
String? _serverUrl;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadServerUrl();
|
||||
}
|
||||
|
||||
Future<void> _loadServerUrl() async {
|
||||
const storage = FlutterSecureStorage();
|
||||
final url = await storage.read(key: 'server_url');
|
||||
if (mounted) setState(() => _serverUrl = url);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final auth = context.watch<AuthProvider>();
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('Settings')),
|
||||
body: ListView(
|
||||
children: [
|
||||
ListTile(
|
||||
leading: const Icon(Icons.dns),
|
||||
title: const Text('Server'),
|
||||
subtitle: Text(_serverUrl ?? 'Not configured'),
|
||||
),
|
||||
if (auth.user != null) ...[
|
||||
ListTile(
|
||||
leading: const Icon(Icons.person),
|
||||
title: const Text('User'),
|
||||
subtitle: Text(auth.user!.displayName),
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.badge),
|
||||
title: const Text('Login'),
|
||||
subtitle: Text(auth.user!.login),
|
||||
),
|
||||
],
|
||||
const Divider(),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.logout, color: Colors.red),
|
||||
title: const Text('Logout', style: TextStyle(color: Colors.red)),
|
||||
onTap: () async {
|
||||
await auth.logout();
|
||||
if (context.mounted) {
|
||||
Navigator.of(context).popUntil((route) => route.isFirst);
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
85
mobile/lib/services/api_client.dart
Normal file
85
mobile/lib/services/api_client.dart
Normal file
@@ -0,0 +1,85 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
|
||||
class ApiClient {
|
||||
late final Dio dio;
|
||||
final FlutterSecureStorage _storage = const FlutterSecureStorage();
|
||||
VoidCallback? onForceLogout;
|
||||
|
||||
ApiClient() {
|
||||
dio = Dio(BaseOptions(
|
||||
connectTimeout: const Duration(seconds: 15),
|
||||
receiveTimeout: const Duration(seconds: 30),
|
||||
));
|
||||
|
||||
dio.interceptors.add(InterceptorsWrapper(
|
||||
onRequest: (options, handler) async {
|
||||
final token = await _storage.read(key: 'access_token');
|
||||
if (token != null) {
|
||||
options.headers['Authorization'] = 'Bearer $token';
|
||||
}
|
||||
handler.next(options);
|
||||
},
|
||||
onError: (error, handler) async {
|
||||
if (error.response?.statusCode == 401) {
|
||||
final refreshed = await _tryRefreshToken();
|
||||
if (refreshed) {
|
||||
final opts = error.requestOptions;
|
||||
final token = await _storage.read(key: 'access_token');
|
||||
opts.headers['Authorization'] = 'Bearer $token';
|
||||
try {
|
||||
final response = await dio.fetch(opts);
|
||||
return handler.resolve(response);
|
||||
} catch (e) {
|
||||
return handler.next(error);
|
||||
}
|
||||
} else {
|
||||
onForceLogout?.call();
|
||||
}
|
||||
}
|
||||
handler.next(error);
|
||||
},
|
||||
));
|
||||
}
|
||||
|
||||
Future<void> setBaseUrl(String serverUrl) async {
|
||||
final url = serverUrl.endsWith('/')
|
||||
? serverUrl.substring(0, serverUrl.length - 1)
|
||||
: serverUrl;
|
||||
dio.options.baseUrl = '$url/wp-json/twilio-mobile/v1';
|
||||
await _storage.write(key: 'server_url', value: url);
|
||||
}
|
||||
|
||||
Future<void> restoreBaseUrl() async {
|
||||
final url = await _storage.read(key: 'server_url');
|
||||
if (url != null) {
|
||||
dio.options.baseUrl = '$url/wp-json/twilio-mobile/v1';
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> _tryRefreshToken() async {
|
||||
try {
|
||||
final refreshToken = await _storage.read(key: 'refresh_token');
|
||||
if (refreshToken == null) return false;
|
||||
|
||||
final response = await dio.post(
|
||||
'/auth/refresh',
|
||||
data: {'refresh_token': refreshToken},
|
||||
options: Options(headers: {'Authorization': ''}),
|
||||
);
|
||||
|
||||
if (response.statusCode == 200 && response.data['success'] == true) {
|
||||
await _storage.write(
|
||||
key: 'access_token', value: response.data['access_token']);
|
||||
if (response.data['refresh_token'] != null) {
|
||||
await _storage.write(
|
||||
key: 'refresh_token', value: response.data['refresh_token']);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
} catch (_) {}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
typedef VoidCallback = void Function();
|
||||
95
mobile/lib/services/auth_service.dart
Normal file
95
mobile/lib/services/auth_service.dart
Normal file
@@ -0,0 +1,95 @@
|
||||
import 'dart:async';
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
import '../models/user.dart';
|
||||
import 'api_client.dart';
|
||||
|
||||
class AuthService {
|
||||
final ApiClient _api;
|
||||
final FlutterSecureStorage _storage = const FlutterSecureStorage();
|
||||
Timer? _refreshTimer;
|
||||
|
||||
AuthService(this._api);
|
||||
|
||||
Future<User> login(String serverUrl, String username, String password,
|
||||
{String? fcmToken}) async {
|
||||
await _api.setBaseUrl(serverUrl);
|
||||
|
||||
final response = await _api.dio.post('/auth/login', data: {
|
||||
'username': username,
|
||||
'password': password,
|
||||
if (fcmToken != null) 'fcm_token': fcmToken,
|
||||
});
|
||||
|
||||
final data = response.data;
|
||||
if (data['success'] != true) {
|
||||
throw Exception(data['message'] ?? 'Login failed');
|
||||
}
|
||||
|
||||
await _storage.write(key: 'access_token', value: data['access_token']);
|
||||
await _storage.write(key: 'refresh_token', value: data['refresh_token']);
|
||||
|
||||
_scheduleRefresh(data['expires_in'] as int? ?? 3600);
|
||||
|
||||
return User.fromJson(data['user']);
|
||||
}
|
||||
|
||||
Future<bool> tryRestoreSession() async {
|
||||
final token = await _storage.read(key: 'access_token');
|
||||
if (token == null) return false;
|
||||
|
||||
await _api.restoreBaseUrl();
|
||||
if (_api.dio.options.baseUrl.isEmpty) return false;
|
||||
|
||||
try {
|
||||
final response = await _api.dio.get('/agent/status');
|
||||
return response.statusCode == 200;
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> refreshToken() async {
|
||||
final refreshToken = await _storage.read(key: 'refresh_token');
|
||||
if (refreshToken == null) throw Exception('No refresh token');
|
||||
|
||||
final response = await _api.dio.post('/auth/refresh', data: {
|
||||
'refresh_token': refreshToken,
|
||||
});
|
||||
|
||||
final data = response.data;
|
||||
if (data['success'] != true) {
|
||||
throw Exception('Token refresh failed');
|
||||
}
|
||||
|
||||
await _storage.write(key: 'access_token', value: data['access_token']);
|
||||
if (data['refresh_token'] != null) {
|
||||
await _storage.write(key: 'refresh_token', value: data['refresh_token']);
|
||||
}
|
||||
|
||||
_scheduleRefresh(data['expires_in'] as int? ?? 3600);
|
||||
}
|
||||
|
||||
void _scheduleRefresh(int expiresInSeconds) {
|
||||
_refreshTimer?.cancel();
|
||||
// Refresh 2 minutes before expiry
|
||||
final refreshIn = Duration(seconds: expiresInSeconds - 120);
|
||||
if (refreshIn.isNegative) return;
|
||||
_refreshTimer = Timer(refreshIn, () async {
|
||||
try {
|
||||
await refreshToken();
|
||||
} catch (_) {}
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> logout() async {
|
||||
_refreshTimer?.cancel();
|
||||
try {
|
||||
await _api.dio.post('/auth/logout');
|
||||
} catch (_) {}
|
||||
await _storage.deleteAll();
|
||||
}
|
||||
|
||||
void dispose() {
|
||||
_refreshTimer?.cancel();
|
||||
}
|
||||
}
|
||||
78
mobile/lib/services/push_notification_service.dart
Normal file
78
mobile/lib/services/push_notification_service.dart
Normal file
@@ -0,0 +1,78 @@
|
||||
import 'package:firebase_core/firebase_core.dart';
|
||||
import 'package:firebase_messaging/firebase_messaging.dart';
|
||||
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
|
||||
import 'api_client.dart';
|
||||
|
||||
@pragma('vm:entry-point')
|
||||
Future<void> _firebaseMessagingBackgroundHandler(RemoteMessage message) async {
|
||||
await Firebase.initializeApp();
|
||||
// VoIP pushes are handled natively by twilio_voice plugin.
|
||||
// Other data messages can show a local notification if needed.
|
||||
}
|
||||
|
||||
class PushNotificationService {
|
||||
final ApiClient _api;
|
||||
final FirebaseMessaging _messaging = FirebaseMessaging.instance;
|
||||
final FlutterLocalNotificationsPlugin _localNotifications =
|
||||
FlutterLocalNotificationsPlugin();
|
||||
|
||||
PushNotificationService(this._api);
|
||||
|
||||
Future<void> initialize() async {
|
||||
FirebaseMessaging.onBackgroundMessage(_firebaseMessagingBackgroundHandler);
|
||||
|
||||
await _messaging.requestPermission(
|
||||
alert: true,
|
||||
badge: true,
|
||||
sound: true,
|
||||
criticalAlert: true,
|
||||
);
|
||||
|
||||
// Initialize local notifications
|
||||
const androidSettings =
|
||||
AndroidInitializationSettings('@mipmap/ic_launcher');
|
||||
const initSettings = InitializationSettings(android: androidSettings);
|
||||
await _localNotifications.initialize(initSettings);
|
||||
|
||||
// Get and register FCM token
|
||||
final token = await _messaging.getToken();
|
||||
if (token != null) {
|
||||
await _registerToken(token);
|
||||
}
|
||||
|
||||
// Listen for token refresh
|
||||
_messaging.onTokenRefresh.listen(_registerToken);
|
||||
|
||||
// Handle foreground messages (non-VoIP)
|
||||
FirebaseMessaging.onMessage.listen(_handleForegroundMessage);
|
||||
}
|
||||
|
||||
Future<void> _registerToken(String token) async {
|
||||
try {
|
||||
await _api.dio.post('/fcm/register', data: {'fcm_token': token});
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
void _handleForegroundMessage(RemoteMessage message) {
|
||||
final data = message.data;
|
||||
final type = data['type'];
|
||||
|
||||
// VoIP incoming_call is handled by twilio_voice natively
|
||||
if (type == 'incoming_call') return;
|
||||
|
||||
// Show local notification for other types (missed call, queue alert, etc.)
|
||||
_localNotifications.show(
|
||||
message.hashCode,
|
||||
data['title'] ?? 'TWP Softphone',
|
||||
data['body'] ?? '',
|
||||
const NotificationDetails(
|
||||
android: AndroidNotificationDetails(
|
||||
'twp_general',
|
||||
'General Notifications',
|
||||
importance: Importance.high,
|
||||
priority: Priority.high,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
119
mobile/lib/services/sse_service.dart
Normal file
119
mobile/lib/services/sse_service.dart
Normal file
@@ -0,0 +1,119 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:math';
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
import '../config/app_config.dart';
|
||||
import 'api_client.dart';
|
||||
|
||||
class SseEvent {
|
||||
final String event;
|
||||
final Map<String, dynamic> data;
|
||||
|
||||
SseEvent({required this.event, required this.data});
|
||||
}
|
||||
|
||||
class SseService {
|
||||
final ApiClient _api;
|
||||
final FlutterSecureStorage _storage = const FlutterSecureStorage();
|
||||
final StreamController<SseEvent> _eventController =
|
||||
StreamController<SseEvent>.broadcast();
|
||||
final StreamController<bool> _connectionController =
|
||||
StreamController<bool>.broadcast();
|
||||
|
||||
CancelToken? _cancelToken;
|
||||
Timer? _reconnectTimer;
|
||||
int _reconnectAttempt = 0;
|
||||
bool _shouldReconnect = true;
|
||||
|
||||
Stream<SseEvent> get events => _eventController.stream;
|
||||
Stream<bool> get connectionState => _connectionController.stream;
|
||||
|
||||
SseService(this._api);
|
||||
|
||||
Future<void> connect() async {
|
||||
_shouldReconnect = true;
|
||||
_reconnectAttempt = 0;
|
||||
await _doConnect();
|
||||
}
|
||||
|
||||
Future<void> _doConnect() async {
|
||||
_cancelToken?.cancel();
|
||||
_cancelToken = CancelToken();
|
||||
|
||||
try {
|
||||
final token = await _storage.read(key: 'access_token');
|
||||
final response = await _api.dio.get(
|
||||
'/stream/events',
|
||||
options: Options(
|
||||
headers: {'Authorization': 'Bearer $token'},
|
||||
responseType: ResponseType.stream,
|
||||
),
|
||||
cancelToken: _cancelToken,
|
||||
);
|
||||
|
||||
_connectionController.add(true);
|
||||
_reconnectAttempt = 0;
|
||||
|
||||
final stream = response.data.stream as Stream<List<int>>;
|
||||
String buffer = '';
|
||||
|
||||
await for (final chunk in stream) {
|
||||
buffer += utf8.decode(chunk);
|
||||
final lines = buffer.split('\n');
|
||||
buffer = lines.removeLast(); // keep incomplete line in buffer
|
||||
|
||||
String? eventName;
|
||||
String? dataStr;
|
||||
|
||||
for (final line in lines) {
|
||||
if (line.startsWith('event:')) {
|
||||
eventName = line.substring(6).trim();
|
||||
} else if (line.startsWith('data:')) {
|
||||
dataStr = line.substring(5).trim();
|
||||
} else if (line.isEmpty && eventName != null && dataStr != null) {
|
||||
try {
|
||||
final data = jsonDecode(dataStr) as Map<String, dynamic>;
|
||||
_eventController.add(SseEvent(event: eventName, data: data));
|
||||
} catch (_) {}
|
||||
eventName = null;
|
||||
dataStr = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
if (e is DioException && e.type == DioExceptionType.cancel) return;
|
||||
_connectionController.add(false);
|
||||
}
|
||||
|
||||
if (_shouldReconnect) {
|
||||
_scheduleReconnect();
|
||||
}
|
||||
}
|
||||
|
||||
void _scheduleReconnect() {
|
||||
_reconnectTimer?.cancel();
|
||||
final delay = Duration(
|
||||
milliseconds: min(
|
||||
AppConfig.sseMaxReconnect.inMilliseconds,
|
||||
AppConfig.sseReconnectBase.inMilliseconds *
|
||||
pow(2, _reconnectAttempt).toInt(),
|
||||
),
|
||||
);
|
||||
_reconnectAttempt++;
|
||||
_reconnectTimer = Timer(delay, _doConnect);
|
||||
}
|
||||
|
||||
void disconnect() {
|
||||
_shouldReconnect = false;
|
||||
_reconnectTimer?.cancel();
|
||||
_cancelToken?.cancel();
|
||||
_connectionController.add(false);
|
||||
}
|
||||
|
||||
void dispose() {
|
||||
disconnect();
|
||||
_eventController.close();
|
||||
_connectionController.close();
|
||||
}
|
||||
}
|
||||
85
mobile/lib/services/voice_service.dart
Normal file
85
mobile/lib/services/voice_service.dart
Normal file
@@ -0,0 +1,85 @@
|
||||
import 'dart:async';
|
||||
import 'package:twilio_voice/twilio_voice.dart';
|
||||
import 'api_client.dart';
|
||||
|
||||
class VoiceService {
|
||||
final ApiClient _api;
|
||||
Timer? _tokenRefreshTimer;
|
||||
String? _identity;
|
||||
|
||||
final StreamController<CallEvent> _callEventController =
|
||||
StreamController<CallEvent>.broadcast();
|
||||
Stream<CallEvent> get callEvents => _callEventController.stream;
|
||||
|
||||
VoiceService(this._api);
|
||||
|
||||
Future<void> initialize() async {
|
||||
await _fetchAndRegisterToken();
|
||||
|
||||
TwilioVoice.instance.callEventsListener.listen((event) {
|
||||
_callEventController.add(event);
|
||||
});
|
||||
|
||||
// Refresh token every 50 minutes
|
||||
_tokenRefreshTimer?.cancel();
|
||||
_tokenRefreshTimer = Timer.periodic(
|
||||
const Duration(minutes: 50),
|
||||
(_) => _fetchAndRegisterToken(),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _fetchAndRegisterToken() async {
|
||||
try {
|
||||
final response = await _api.dio.get('/voice/token');
|
||||
final data = response.data;
|
||||
final token = data['token'] as String;
|
||||
_identity = data['identity'] as String;
|
||||
await TwilioVoice.instance.setTokens(accessToken: token);
|
||||
} catch (e) {
|
||||
// Token fetch failed - will retry on next interval
|
||||
}
|
||||
}
|
||||
|
||||
String? get identity => _identity;
|
||||
|
||||
Future<void> answer() async {
|
||||
await TwilioVoice.instance.call.answer();
|
||||
}
|
||||
|
||||
Future<void> reject() async {
|
||||
await TwilioVoice.instance.call.hangUp();
|
||||
}
|
||||
|
||||
Future<void> hangUp() async {
|
||||
await TwilioVoice.instance.call.hangUp();
|
||||
}
|
||||
|
||||
Future<void> toggleMute(bool mute) async {
|
||||
await TwilioVoice.instance.call.toggleMute(mute);
|
||||
}
|
||||
|
||||
Future<void> toggleSpeaker(bool speaker) async {
|
||||
await TwilioVoice.instance.call.toggleSpeaker(speaker);
|
||||
}
|
||||
|
||||
Future<void> sendDigits(String digits) async {
|
||||
await TwilioVoice.instance.call.sendDigits(digits);
|
||||
}
|
||||
|
||||
Future<void> holdCall(String callSid) async {
|
||||
await _api.dio.post('/calls/$callSid/hold');
|
||||
}
|
||||
|
||||
Future<void> unholdCall(String callSid) async {
|
||||
await _api.dio.post('/calls/$callSid/unhold');
|
||||
}
|
||||
|
||||
Future<void> transferCall(String callSid, String target) async {
|
||||
await _api.dio.post('/calls/$callSid/transfer', data: {'target': target});
|
||||
}
|
||||
|
||||
void dispose() {
|
||||
_tokenRefreshTimer?.cancel();
|
||||
_callEventController.close();
|
||||
}
|
||||
}
|
||||
51
mobile/lib/widgets/agent_status_toggle.dart
Normal file
51
mobile/lib/widgets/agent_status_toggle.dart
Normal file
@@ -0,0 +1,51 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../models/agent_status.dart';
|
||||
import '../providers/agent_provider.dart';
|
||||
|
||||
class AgentStatusToggle extends StatelessWidget {
|
||||
const AgentStatusToggle({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final agent = context.watch<AgentProvider>();
|
||||
final current = agent.status?.status ?? AgentStatusValue.offline;
|
||||
|
||||
return Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('Agent Status',
|
||||
style: Theme.of(context).textTheme.titleSmall),
|
||||
const SizedBox(height: 12),
|
||||
SegmentedButton<AgentStatusValue>(
|
||||
segments: const [
|
||||
ButtonSegment(
|
||||
value: AgentStatusValue.available,
|
||||
label: Text('Available'),
|
||||
icon: Icon(Icons.circle, color: Colors.green, size: 12),
|
||||
),
|
||||
ButtonSegment(
|
||||
value: AgentStatusValue.busy,
|
||||
label: Text('Busy'),
|
||||
icon: Icon(Icons.circle, color: Colors.orange, size: 12),
|
||||
),
|
||||
ButtonSegment(
|
||||
value: AgentStatusValue.offline,
|
||||
label: Text('Offline'),
|
||||
icon: Icon(Icons.circle, color: Colors.red, size: 12),
|
||||
),
|
||||
],
|
||||
selected: {current},
|
||||
onSelectionChanged: (selection) {
|
||||
agent.updateStatus(selection.first);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
118
mobile/lib/widgets/call_controls.dart
Normal file
118
mobile/lib/widgets/call_controls.dart
Normal file
@@ -0,0 +1,118 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../models/call_info.dart';
|
||||
|
||||
class CallControls extends StatelessWidget {
|
||||
final CallInfo callInfo;
|
||||
final VoidCallback onMute;
|
||||
final VoidCallback onSpeaker;
|
||||
final VoidCallback onHold;
|
||||
final VoidCallback onDialpad;
|
||||
final VoidCallback onTransfer;
|
||||
final VoidCallback onHangUp;
|
||||
|
||||
const CallControls({
|
||||
super.key,
|
||||
required this.callInfo,
|
||||
required this.onMute,
|
||||
required this.onSpeaker,
|
||||
required this.onHold,
|
||||
required this.onDialpad,
|
||||
required this.onTransfer,
|
||||
required this.onHangUp,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24),
|
||||
child: Column(
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||
children: [
|
||||
_ControlButton(
|
||||
icon: callInfo.isMuted ? Icons.mic_off : Icons.mic,
|
||||
label: 'Mute',
|
||||
active: callInfo.isMuted,
|
||||
onTap: onMute,
|
||||
),
|
||||
_ControlButton(
|
||||
icon: callInfo.isSpeakerOn
|
||||
? Icons.volume_up
|
||||
: Icons.volume_down,
|
||||
label: 'Speaker',
|
||||
active: callInfo.isSpeakerOn,
|
||||
onTap: onSpeaker,
|
||||
),
|
||||
_ControlButton(
|
||||
icon: callInfo.isOnHold ? Icons.play_arrow : Icons.pause,
|
||||
label: callInfo.isOnHold ? 'Resume' : 'Hold',
|
||||
active: callInfo.isOnHold,
|
||||
onTap: onHold,
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||
children: [
|
||||
_ControlButton(
|
||||
icon: Icons.dialpad,
|
||||
label: 'Dialpad',
|
||||
onTap: onDialpad,
|
||||
),
|
||||
_ControlButton(
|
||||
icon: Icons.phone_forwarded,
|
||||
label: 'Transfer',
|
||||
onTap: onTransfer,
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
FloatingActionButton.large(
|
||||
onPressed: onHangUp,
|
||||
backgroundColor: Colors.red,
|
||||
child: const Icon(Icons.call_end, color: Colors.white, size: 36),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ControlButton extends StatelessWidget {
|
||||
final IconData icon;
|
||||
final String label;
|
||||
final bool active;
|
||||
final VoidCallback onTap;
|
||||
|
||||
const _ControlButton({
|
||||
required this.icon,
|
||||
required this.label,
|
||||
this.active = false,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
IconButton.filled(
|
||||
onPressed: onTap,
|
||||
icon: Icon(icon),
|
||||
style: IconButton.styleFrom(
|
||||
backgroundColor: active
|
||||
? Theme.of(context).colorScheme.primaryContainer
|
||||
: Theme.of(context).colorScheme.surfaceContainerHighest,
|
||||
foregroundColor: active
|
||||
? Theme.of(context).colorScheme.primary
|
||||
: Theme.of(context).colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(label, style: Theme.of(context).textTheme.labelSmall),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
55
mobile/lib/widgets/dialpad.dart
Normal file
55
mobile/lib/widgets/dialpad.dart
Normal file
@@ -0,0 +1,55 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class Dialpad extends StatelessWidget {
|
||||
final void Function(String digit) onDigit;
|
||||
final VoidCallback onClose;
|
||||
|
||||
const Dialpad({super.key, required this.onDigit, required this.onClose});
|
||||
|
||||
static const _keys = [
|
||||
['1', '2', '3'],
|
||||
['4', '5', '6'],
|
||||
['7', '8', '9'],
|
||||
['*', '0', '#'],
|
||||
];
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 48),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
..._keys.map((row) => Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||
children: row
|
||||
.map((key) => Padding(
|
||||
padding: const EdgeInsets.all(4),
|
||||
child: InkWell(
|
||||
onTap: () => onDigit(key),
|
||||
borderRadius: BorderRadius.circular(40),
|
||||
child: Container(
|
||||
width: 64,
|
||||
height: 64,
|
||||
alignment: Alignment.center,
|
||||
child: Text(
|
||||
key,
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.headlineSmall,
|
||||
),
|
||||
),
|
||||
),
|
||||
))
|
||||
.toList(),
|
||||
)),
|
||||
const SizedBox(height: 8),
|
||||
TextButton(
|
||||
onPressed: onClose,
|
||||
child: const Text('Close'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
37
mobile/lib/widgets/queue_card.dart
Normal file
37
mobile/lib/widgets/queue_card.dart
Normal file
@@ -0,0 +1,37 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../models/queue_state.dart';
|
||||
|
||||
class QueueCard extends StatelessWidget {
|
||||
final QueueInfo queue;
|
||||
|
||||
const QueueCard({super.key, required this.queue});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Card(
|
||||
child: ListTile(
|
||||
leading: CircleAvatar(
|
||||
backgroundColor: queue.waitingCount > 0
|
||||
? Colors.orange.shade100
|
||||
: Colors.green.shade100,
|
||||
child: Text(
|
||||
'${queue.waitingCount}',
|
||||
style: TextStyle(
|
||||
color: queue.waitingCount > 0 ? Colors.orange : Colors.green,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
title: Text(queue.name),
|
||||
subtitle: Text(
|
||||
queue.waitingCount > 0
|
||||
? '${queue.waitingCount} waiting'
|
||||
: 'No calls waiting',
|
||||
),
|
||||
trailing: queue.extension != null
|
||||
? Chip(label: Text('Ext ${queue.extension}'))
|
||||
: null,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user