Replace native Twilio Voice SDK with WebView-based softphone
Rewrites the mobile app from a native Twilio Voice SDK integration (Android Telecom/ConnectionService) to a thin WebView shell that loads a standalone browser phone page from WordPress. This eliminates the buggy Android phone account registration, fixes frequent logouts by using 7-day WP session cookies instead of JWT tokens, and maintains all existing call features (dialpad, queues, hold, transfer, requeue, recording, caller ID, agent status). Server-side: - Add class-twp-mobile-phone-page.php: standalone /twp-phone/ endpoint with mobile-optimized UI, dark mode, tab navigation, and Flutter WebView JS bridge - Extend auth cookie to 7 days for phone agents - Add WP AJAX handler for FCM token registration (cookie auth) Flutter app (v2.0.0): - Replace 18 native files with 5-file WebView shell - Login via wp-login.php in WebView (auto-detect redirect on success) - Full-screen WebView with auto microphone grant for WebRTC - FCM push notifications preserved for queue alerts - Remove: twilio_voice, dio, provider, JWT auth, SSE, native call UI Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,11 +1,7 @@
|
||||
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 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
import 'screens/login_screen.dart';
|
||||
import 'screens/dashboard_screen.dart';
|
||||
import 'screens/phone_screen.dart';
|
||||
|
||||
class TwpSoftphoneApp extends StatefulWidget {
|
||||
const TwpSoftphoneApp({super.key});
|
||||
@@ -15,51 +11,77 @@ class TwpSoftphoneApp extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _TwpSoftphoneAppState extends State<TwpSoftphoneApp> {
|
||||
final _apiClient = ApiClient();
|
||||
static const _storage = FlutterSecureStorage();
|
||||
String? _serverUrl;
|
||||
bool _loading = true;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_checkSavedSession();
|
||||
}
|
||||
|
||||
Future<void> _checkSavedSession() async {
|
||||
final url = await _storage.read(key: 'server_url');
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_serverUrl = url;
|
||||
_loading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void _onLoginSuccess(String serverUrl) {
|
||||
setState(() {
|
||||
_serverUrl = serverUrl;
|
||||
});
|
||||
}
|
||||
|
||||
void _onLogout() async {
|
||||
await _storage.delete(key: 'server_url');
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_serverUrl = null;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void _onSessionExpired() {
|
||||
// Server URL is still saved, but session cookie is gone.
|
||||
// Show login screen but keep the server URL pre-filled.
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_serverUrl = null;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@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();
|
||||
},
|
||||
),
|
||||
return 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: _loading
|
||||
? const Scaffold(
|
||||
body: Center(child: CircularProgressIndicator()),
|
||||
)
|
||||
: _serverUrl != null
|
||||
? PhoneScreen(
|
||||
serverUrl: _serverUrl!,
|
||||
onLogout: _onLogout,
|
||||
onSessionExpired: _onSessionExpired,
|
||||
)
|
||||
: LoginScreen(onLoginSuccess: _onLoginSuccess),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
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';
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
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'] ?? 'offline') as String),
|
||||
isLoggedIn: json['is_logged_in'] == true || json['is_logged_in'] == 1 || json['is_logged_in'] == '1',
|
||||
currentCallSid: json['current_call_sid'] as String?,
|
||||
lastActivity: json['last_activity'] as String?,
|
||||
availableForQueues: json['available_for_queues'] != false && json['available_for_queues'] != 0 && json['available_for_queues'] != '0',
|
||||
);
|
||||
}
|
||||
|
||||
static AgentStatusValue _parseStatus(String s) {
|
||||
switch (s) {
|
||||
case 'available':
|
||||
return AgentStatusValue.available;
|
||||
case 'busy':
|
||||
return AgentStatusValue.busy;
|
||||
default:
|
||||
return AgentStatusValue.offline;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
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;
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
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: _toInt(json['id']),
|
||||
name: (json['name'] ?? '') as String,
|
||||
type: (json['type'] ?? '') as String,
|
||||
extension: json['extension'] as String?,
|
||||
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 {
|
||||
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: _toInt(json['position']),
|
||||
status: (json['status'] ?? '') as String,
|
||||
waitTime: _toInt(json['wait_time']),
|
||||
);
|
||||
}
|
||||
|
||||
static int _toInt(dynamic value) {
|
||||
if (value is int) return value;
|
||||
if (value is String) return int.tryParse(value) ?? 0;
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
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: _toInt(json['user_id']),
|
||||
login: (json['user_login'] ?? '') as String,
|
||||
displayName: (json['display_name'] ?? '') as String,
|
||||
email: json['email'] as String?,
|
||||
);
|
||||
}
|
||||
|
||||
static int _toInt(dynamic value) {
|
||||
if (value is int) return value;
|
||||
if (value is String) return int.tryParse(value) ?? 0;
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
@@ -1,132 +0,0 @@
|
||||
import 'dart:async';
|
||||
import 'package:dio/dio.dart';
|
||||
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 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;
|
||||
|
||||
AgentStatus? _status;
|
||||
List<QueueInfo> _queues = [];
|
||||
bool _sseConnected = false;
|
||||
List<PhoneNumber> _phoneNumbers = [];
|
||||
StreamSubscription? _sseSub;
|
||||
StreamSubscription? _connSub;
|
||||
Timer? _refreshTimer;
|
||||
|
||||
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) {
|
||||
_sseConnected = connected;
|
||||
notifyListeners();
|
||||
});
|
||||
|
||||
_sseSub = _sse.events.listen(_handleSseEvent);
|
||||
|
||||
_refreshTimer = Timer.periodic(
|
||||
const Duration(seconds: 15),
|
||||
(_) => fetchQueues(),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> fetchStatus() async {
|
||||
try {
|
||||
final response = await _api.dio.get('/agent/status');
|
||||
_status = AgentStatus.fromJson(response.data);
|
||||
notifyListeners();
|
||||
} catch (e) {
|
||||
debugPrint('AgentProvider.fetchStatus error: $e');
|
||||
if (e is DioException) debugPrint(' response: ${e.response?.data}');
|
||||
}
|
||||
}
|
||||
|
||||
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 (e) {
|
||||
debugPrint('AgentProvider.updateStatus error: $e');
|
||||
if (e is DioException) {
|
||||
debugPrint('AgentProvider.updateStatus response: ${e.response?.data}');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 (e) {
|
||||
debugPrint('AgentProvider.fetchQueues error: $e');
|
||||
if (e is DioException) debugPrint(' response: ${e.response?.data}');
|
||||
}
|
||||
}
|
||||
|
||||
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(), fetchPhoneNumbers()]);
|
||||
}
|
||||
|
||||
void _handleSseEvent(SseEvent event) {
|
||||
switch (event.event) {
|
||||
case 'call_enqueued':
|
||||
case 'call_dequeued':
|
||||
fetchQueues();
|
||||
break;
|
||||
case 'agent_status_changed':
|
||||
fetchStatus();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_refreshTimer?.cancel();
|
||||
_sseSub?.cancel();
|
||||
_connSub?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
@@ -1,122 +0,0 @@
|
||||
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 AuthService _authService;
|
||||
late VoiceService _voiceService;
|
||||
late PushNotificationService _pushService;
|
||||
late 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 user = await _authService.tryRestoreSession();
|
||||
if (user != null) {
|
||||
_user = user;
|
||||
_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 (e) {
|
||||
debugPrint('AuthProvider: push service init error: $e');
|
||||
}
|
||||
try {
|
||||
await _voiceService.initialize(deviceToken: _pushService.fcmToken);
|
||||
} catch (e) {
|
||||
debugPrint('AuthProvider: voice service init error: $e');
|
||||
}
|
||||
try {
|
||||
await _sseService.connect();
|
||||
} catch (e) {
|
||||
debugPrint('AuthProvider: SSE connect error: $e');
|
||||
}
|
||||
}
|
||||
|
||||
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() {
|
||||
_voiceService.dispose();
|
||||
_sseService.disconnect();
|
||||
|
||||
_state = AuthState.unauthenticated;
|
||||
_user = null;
|
||||
_error = 'Session expired. Please log in again.';
|
||||
|
||||
// Re-create services for potential re-login
|
||||
_voiceService = VoiceService(_apiClient);
|
||||
_pushService = PushNotificationService(_apiClient);
|
||||
_sseService = SseService(_apiClient);
|
||||
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_authService.dispose();
|
||||
_voiceService.dispose();
|
||||
_sseService.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
@@ -1,180 +0,0 @@
|
||||
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;
|
||||
bool _pendingAutoAnswer = false;
|
||||
|
||||
CallInfo get callInfo => _callInfo;
|
||||
|
||||
CallProvider(this._voiceService) {
|
||||
_eventSub = _voiceService.callEvents.listen(_handleCallEvent);
|
||||
}
|
||||
|
||||
void _handleCallEvent(CallEvent event) {
|
||||
switch (event) {
|
||||
case CallEvent.incoming:
|
||||
if (_pendingAutoAnswer) {
|
||||
_pendingAutoAnswer = false;
|
||||
_callInfo = _callInfo.copyWith(state: CallState.connecting);
|
||||
_voiceService.answer();
|
||||
} else {
|
||||
_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 (skip if call just ended)
|
||||
if (_callInfo.state != CallState.idle) {
|
||||
final call = TwilioVoice.instance.call;
|
||||
final active = call.activeCall;
|
||||
if (active != null) {
|
||||
if (_callInfo.callerNumber == null) {
|
||||
_callInfo = _callInfo.copyWith(
|
||||
callerNumber: active.from,
|
||||
);
|
||||
}
|
||||
// Fetch SID asynchronously
|
||||
call.getSid().then((sid) {
|
||||
if (sid != null && sid != _callInfo.callSid && _callInfo.isActive) {
|
||||
_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() async {
|
||||
await _voiceService.hangUp();
|
||||
// If SDK didn't fire callEnded (e.g. no active SDK call), reset manually
|
||||
if (_callInfo.state != CallState.idle) {
|
||||
_stopDurationTimer();
|
||||
_callInfo = const CallInfo();
|
||||
_pendingAutoAnswer = false;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
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> makeCall(String number, {String? callerId}) async {
|
||||
_callInfo = _callInfo.copyWith(
|
||||
state: CallState.connecting,
|
||||
callerNumber: number,
|
||||
);
|
||||
notifyListeners();
|
||||
final success = await _voiceService.makeCall(number, callerId: callerId);
|
||||
if (!success) {
|
||||
debugPrint('CallProvider.makeCall: call.place() returned false');
|
||||
_callInfo = const CallInfo(); // reset to idle
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
Future<void> acceptQueueCall(String callSid) async {
|
||||
_pendingAutoAnswer = true;
|
||||
_callInfo = _callInfo.copyWith(state: CallState.connecting);
|
||||
notifyListeners();
|
||||
try {
|
||||
await _voiceService.acceptQueueCall(callSid);
|
||||
} catch (e) {
|
||||
debugPrint('CallProvider.acceptQueueCall error: $e');
|
||||
_pendingAutoAnswer = false;
|
||||
_callInfo = const CallInfo();
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_stopDurationTimer();
|
||||
_eventSub?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
@@ -1,137 +0,0 @@
|
||||
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'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,374 +0,0 @@
|
||||
import 'dart:io';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:twilio_voice/twilio_voice.dart';
|
||||
import '../models/queue_state.dart';
|
||||
import '../providers/agent_provider.dart';
|
||||
import '../providers/auth_provider.dart';
|
||||
import '../providers/call_provider.dart';
|
||||
import '../widgets/agent_status_toggle.dart';
|
||||
import '../widgets/dialpad.dart';
|
||||
import '../widgets/queue_card.dart';
|
||||
import 'settings_screen.dart';
|
||||
|
||||
class DashboardScreen extends StatefulWidget {
|
||||
const DashboardScreen({super.key});
|
||||
|
||||
@override
|
||||
State<DashboardScreen> createState() => _DashboardScreenState();
|
||||
}
|
||||
|
||||
class _DashboardScreenState extends State<DashboardScreen> {
|
||||
bool _phoneAccountEnabled = true; // assume true until checked
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
context.read<AgentProvider>().refresh();
|
||||
_checkPhoneAccount();
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _checkPhoneAccount() async {
|
||||
if (!kIsWeb && Platform.isAndroid) {
|
||||
final enabled = await TwilioVoice.instance.isPhoneAccountEnabled();
|
||||
if (mounted && !enabled) {
|
||||
setState(() => _phoneAccountEnabled = false);
|
||||
_showPhoneAccountDialog();
|
||||
} else if (mounted) {
|
||||
setState(() => _phoneAccountEnabled = true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _showPhoneAccountDialog() {
|
||||
showDialog(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: const Text('Enable Phone Account'),
|
||||
content: const Text(
|
||||
'TWP Softphone needs to be enabled as a calling account to make and receive calls.\n\n'
|
||||
'Tap "Open Settings" below, then find "TWP Softphone" in the list and toggle it ON.',
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx),
|
||||
child: const Text('Later'),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () async {
|
||||
Navigator.pop(ctx);
|
||||
await TwilioVoice.instance.openPhoneAccountSettings();
|
||||
// Poll until enabled or user comes back
|
||||
for (int i = 0; i < 30; i++) {
|
||||
await Future.delayed(const Duration(seconds: 1));
|
||||
if (!mounted) return;
|
||||
final enabled = await TwilioVoice.instance.isPhoneAccountEnabled();
|
||||
if (enabled) {
|
||||
setState(() => _phoneAccountEnabled = true);
|
||||
return;
|
||||
}
|
||||
}
|
||||
// Re-check one more time when coming back
|
||||
_checkPhoneAccount();
|
||||
},
|
||||
child: const Text('Open Settings'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showDialer(BuildContext context) {
|
||||
final numberController = TextEditingController();
|
||||
final phoneNumbers = context.read<AgentProvider>().phoneNumbers;
|
||||
// Auto-select first phone number as caller ID
|
||||
String? selectedCallerId =
|
||||
phoneNumbers.isNotEmpty ? phoneNumbers.first.phoneNumber : null;
|
||||
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
|
||||
),
|
||||
builder: (ctx) {
|
||||
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 (only if multiple numbers)
|
||||
if (phoneNumbers.length > 1) ...[
|
||||
const SizedBox(height: 12),
|
||||
DropdownButtonFormField<String>(
|
||||
initialValue: selectedCallerId,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Caller ID',
|
||||
isDense: true,
|
||||
contentPadding: EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
),
|
||||
items: phoneNumbers.map((p) => DropdownMenuItem<String>(
|
||||
value: p.phoneNumber,
|
||||
child: Text('${p.friendlyName} (${p.phoneNumber})'),
|
||||
)).toList(),
|
||||
onChanged: (value) {
|
||||
setSheetState(() {
|
||||
selectedCallerId = value;
|
||||
});
|
||||
},
|
||||
),
|
||||
] else if (phoneNumbers.length == 1) ...[
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Caller ID: ${phoneNumbers.first.phoneNumber}',
|
||||
style: Theme.of(ctx).textTheme.bodySmall?.copyWith(
|
||||
color: Theme.of(ctx).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
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.isEmpty) return;
|
||||
if (selectedCallerId == null) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('No caller ID available. Add a phone number first.')),
|
||||
);
|
||||
return;
|
||||
}
|
||||
context.read<CallProvider>().makeCall(number, callerId: selectedCallerId);
|
||||
Navigator.pop(ctx);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
void _showQueueCalls(BuildContext context, QueueInfo queue) {
|
||||
final voiceService = context.read<AuthProvider>().voiceService;
|
||||
final callProvider = context.read<CallProvider>();
|
||||
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
|
||||
),
|
||||
builder: (ctx) {
|
||||
return FutureBuilder<List<Map<String, dynamic>>>(
|
||||
future: voiceService.getQueueCalls(queue.id),
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.connectionState == ConnectionState.waiting) {
|
||||
return const Padding(
|
||||
padding: EdgeInsets.all(32),
|
||||
child: Center(child: CircularProgressIndicator()),
|
||||
);
|
||||
}
|
||||
|
||||
if (snapshot.hasError) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Center(
|
||||
child: Text('Error loading calls: ${snapshot.error}'),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final calls = (snapshot.data ?? [])
|
||||
.map((c) => QueueCall.fromJson(c))
|
||||
.toList();
|
||||
|
||||
if (calls.isEmpty) {
|
||||
return const Padding(
|
||||
padding: EdgeInsets.all(24),
|
||||
child: Center(child: Text('No calls waiting')),
|
||||
);
|
||||
}
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Text(
|
||||
'${queue.name} - Waiting Calls',
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
...calls.map((call) => ListTile(
|
||||
leading: const CircleAvatar(
|
||||
child: Icon(Icons.phone_in_talk),
|
||||
),
|
||||
title: Text(call.fromNumber),
|
||||
subtitle: Text('Waiting ${_formatWaitTime(call.waitTime)}'),
|
||||
trailing: FilledButton.icon(
|
||||
icon: const Icon(Icons.call, size: 18),
|
||||
label: const Text('Accept'),
|
||||
onPressed: () {
|
||||
Navigator.pop(ctx);
|
||||
callProvider.acceptQueueCall(call.callSid);
|
||||
// Cancel queue alert notification
|
||||
FlutterLocalNotificationsPlugin().cancel(9001);
|
||||
},
|
||||
),
|
||||
)),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
String _formatWaitTime(int seconds) {
|
||||
if (seconds < 60) return '${seconds}s';
|
||||
final minutes = seconds ~/ 60;
|
||||
final secs = seconds % 60;
|
||||
return '${minutes}m ${secs}s';
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final agent = context.watch<AgentProvider>();
|
||||
|
||||
// Android Telecom framework handles the call UI via the native InCallUI,
|
||||
// so we don't navigate to our own ActiveCallScreen.
|
||||
|
||||
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())),
|
||||
),
|
||||
],
|
||||
),
|
||||
floatingActionButton: FloatingActionButton(
|
||||
onPressed: () => _showDialer(context),
|
||||
child: const Icon(Icons.phone),
|
||||
),
|
||||
body: RefreshIndicator(
|
||||
onRefresh: () => agent.refresh(),
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
if (!_phoneAccountEnabled)
|
||||
Card(
|
||||
color: Colors.orange.shade50,
|
||||
child: ListTile(
|
||||
leading: Icon(Icons.warning, color: Colors.orange.shade700),
|
||||
title: const Text('Phone Account Not Enabled'),
|
||||
subtitle: const Text('Tap to enable calling in settings'),
|
||||
trailing: const Icon(Icons.chevron_right),
|
||||
onTap: () => _showPhoneAccountDialog(),
|
||||
),
|
||||
),
|
||||
if (!_phoneAccountEnabled) const SizedBox(height: 8),
|
||||
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,
|
||||
onTap: q.waitingCount > 0
|
||||
? () => _showQueueCalls(context, q)
|
||||
: null,
|
||||
),
|
||||
)),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,22 +1,29 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../providers/auth_provider.dart';
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
import 'package:webview_flutter/webview_flutter.dart';
|
||||
|
||||
/// Login screen that loads wp-login.php in a WebView.
|
||||
///
|
||||
/// When the user successfully logs in, WordPress redirects to /twp-phone/.
|
||||
/// We detect that URL change and report login success to the parent.
|
||||
class LoginScreen extends StatefulWidget {
|
||||
const LoginScreen({super.key});
|
||||
final void Function(String serverUrl) onLoginSuccess;
|
||||
|
||||
const LoginScreen({super.key, required this.onLoginSuccess});
|
||||
|
||||
@override
|
||||
State<LoginScreen> createState() => _LoginScreenState();
|
||||
}
|
||||
|
||||
class _LoginScreenState extends State<LoginScreen> {
|
||||
static const _storage = FlutterSecureStorage();
|
||||
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
final _serverController = TextEditingController();
|
||||
final _usernameController = TextEditingController();
|
||||
final _passwordController = TextEditingController();
|
||||
bool _obscurePassword = true;
|
||||
bool _showWebView = false;
|
||||
bool _webViewLoading = true;
|
||||
String? _error;
|
||||
late WebViewController _webViewController;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -25,40 +32,107 @@ class _LoginScreenState extends State<LoginScreen> {
|
||||
}
|
||||
|
||||
Future<void> _loadSavedServer() async {
|
||||
const storage = FlutterSecureStorage();
|
||||
final saved = await storage.read(key: 'server_url');
|
||||
final saved = await _storage.read(key: 'server_url');
|
||||
if (saved != null && mounted) {
|
||||
_serverController.text = saved;
|
||||
}
|
||||
}
|
||||
|
||||
void _submit() {
|
||||
void _startLogin() {
|
||||
if (!_formKey.currentState!.validate()) return;
|
||||
|
||||
var serverUrl = _serverController.text.trim();
|
||||
if (!serverUrl.startsWith('http')) {
|
||||
serverUrl = 'https://$serverUrl';
|
||||
}
|
||||
// Remove trailing slash
|
||||
serverUrl = serverUrl.replaceAll(RegExp(r'/+$'), '');
|
||||
|
||||
TextInput.finishAutofillContext();
|
||||
context.read<AuthProvider>().login(
|
||||
serverUrl,
|
||||
_usernameController.text.trim(),
|
||||
_passwordController.text,
|
||||
);
|
||||
setState(() {
|
||||
_showWebView = true;
|
||||
_webViewLoading = true;
|
||||
_error = null;
|
||||
});
|
||||
|
||||
final loginUrl =
|
||||
'$serverUrl/wp-login.php?redirect_to=${Uri.encodeComponent('$serverUrl/twp-phone/')}';
|
||||
|
||||
_webViewController = WebViewController()
|
||||
..setJavaScriptMode(JavaScriptMode.unrestricted)
|
||||
..setNavigationDelegate(
|
||||
NavigationDelegate(
|
||||
onPageStarted: (url) {
|
||||
// Check if we've been redirected to the phone page (login success)
|
||||
if (url.contains('/twp-phone/') || url.endsWith('/twp-phone')) {
|
||||
_onLoginComplete(serverUrl);
|
||||
}
|
||||
},
|
||||
onPageFinished: (url) {
|
||||
if (mounted) {
|
||||
setState(() => _webViewLoading = false);
|
||||
}
|
||||
// Also check on page finish in case redirect was instant
|
||||
if (url.contains('/twp-phone/') || url.endsWith('/twp-phone')) {
|
||||
_onLoginComplete(serverUrl);
|
||||
}
|
||||
},
|
||||
onWebResourceError: (error) {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_showWebView = false;
|
||||
_error =
|
||||
'Could not connect to server: ${error.description}';
|
||||
});
|
||||
}
|
||||
},
|
||||
),
|
||||
)
|
||||
..setUserAgent('TWPMobile/2.0 (Android; WebView)')
|
||||
..loadRequest(Uri.parse(loginUrl));
|
||||
}
|
||||
|
||||
Future<void> _onLoginComplete(String serverUrl) async {
|
||||
// Save server URL for next launch
|
||||
await _storage.write(key: 'server_url', value: serverUrl);
|
||||
if (mounted) {
|
||||
widget.onLoginSuccess(serverUrl);
|
||||
}
|
||||
}
|
||||
|
||||
void _cancelLogin() {
|
||||
setState(() {
|
||||
_showWebView = false;
|
||||
_error = null;
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final auth = context.watch<AuthProvider>();
|
||||
if (_showWebView) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.arrow_back),
|
||||
onPressed: _cancelLogin,
|
||||
),
|
||||
title: const Text('Sign In'),
|
||||
),
|
||||
body: Stack(
|
||||
children: [
|
||||
WebViewWidget(controller: _webViewController),
|
||||
if (_webViewLoading)
|
||||
const Center(child: CircularProgressIndicator()),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return Scaffold(
|
||||
body: SafeArea(
|
||||
child: Center(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: AutofillGroup(
|
||||
child: Form(
|
||||
child: Form(
|
||||
key: _formKey,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
@@ -87,42 +161,10 @@ class _LoginScreenState extends State<LoginScreen> {
|
||||
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(),
|
||||
),
|
||||
autofillHints: const [AutofillHints.username],
|
||||
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,
|
||||
autofillHints: const [AutofillHints.password],
|
||||
validator: (v) =>
|
||||
v == null || v.isEmpty ? 'Required' : null,
|
||||
),
|
||||
if (auth.error != null) ...[
|
||||
if (_error != null) ...[
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
auth.error!,
|
||||
_error!,
|
||||
style: TextStyle(
|
||||
color: Theme.of(context).colorScheme.error),
|
||||
),
|
||||
@@ -132,23 +174,13 @@ class _LoginScreenState extends State<LoginScreen> {
|
||||
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'),
|
||||
onPressed: _startLogin,
|
||||
child: const Text('Connect'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -158,8 +190,6 @@ class _LoginScreenState extends State<LoginScreen> {
|
||||
@override
|
||||
void dispose() {
|
||||
_serverController.dispose();
|
||||
_usernameController.dispose();
|
||||
_passwordController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
314
mobile/lib/screens/phone_screen.dart
Normal file
314
mobile/lib/screens/phone_screen.dart
Normal file
@@ -0,0 +1,314 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:webview_flutter/webview_flutter.dart';
|
||||
import 'package:webview_flutter_android/webview_flutter_android.dart';
|
||||
import '../services/push_notification_service.dart';
|
||||
|
||||
/// Full-screen WebView that loads the TWP phone page.
|
||||
///
|
||||
/// Handles:
|
||||
/// - Microphone permission grants for WebRTC
|
||||
/// - JavaScript bridge (TwpMobile channel) for native communication
|
||||
/// - Session expiry detection (redirect to wp-login.php)
|
||||
/// - Back button confirmation to prevent accidental exit
|
||||
/// - Network error retry UI
|
||||
class PhoneScreen extends StatefulWidget {
|
||||
final String serverUrl;
|
||||
final VoidCallback onLogout;
|
||||
final VoidCallback onSessionExpired;
|
||||
|
||||
const PhoneScreen({
|
||||
super.key,
|
||||
required this.serverUrl,
|
||||
required this.onLogout,
|
||||
required this.onSessionExpired,
|
||||
});
|
||||
|
||||
@override
|
||||
State<PhoneScreen> createState() => _PhoneScreenState();
|
||||
}
|
||||
|
||||
class _PhoneScreenState extends State<PhoneScreen> with WidgetsBindingObserver {
|
||||
late final WebViewController _controller;
|
||||
late final PushNotificationService _pushService;
|
||||
bool _loading = true;
|
||||
bool _hasError = false;
|
||||
String? _errorMessage;
|
||||
bool _sessionExpired = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addObserver(this);
|
||||
_pushService = PushNotificationService();
|
||||
_initWebView();
|
||||
_initPush();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
WidgetsBinding.instance.removeObserver(this);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _initPush() async {
|
||||
await _pushService.initialize();
|
||||
}
|
||||
|
||||
void _initWebView() {
|
||||
_controller = WebViewController()
|
||||
..setJavaScriptMode(JavaScriptMode.unrestricted)
|
||||
..setUserAgent('TWPMobile/2.0 (Android; WebView)')
|
||||
..setNavigationDelegate(
|
||||
NavigationDelegate(
|
||||
onPageStarted: (url) {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_loading = true;
|
||||
_hasError = false;
|
||||
});
|
||||
}
|
||||
// Detect session expiry: if we get redirected to wp-login.php
|
||||
if (url.contains('/wp-login.php')) {
|
||||
_sessionExpired = true;
|
||||
}
|
||||
},
|
||||
onPageFinished: (url) {
|
||||
if (mounted) {
|
||||
setState(() => _loading = false);
|
||||
}
|
||||
if (_sessionExpired && url.contains('/wp-login.php')) {
|
||||
widget.onSessionExpired();
|
||||
return;
|
||||
}
|
||||
_sessionExpired = false;
|
||||
|
||||
// Inject the FCM token into the page if available
|
||||
_injectFcmToken();
|
||||
},
|
||||
onWebResourceError: (error) {
|
||||
// Only handle main frame errors
|
||||
if (error.isForMainFrame ?? true) {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_loading = false;
|
||||
_hasError = true;
|
||||
_errorMessage = error.description;
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
onNavigationRequest: (request) {
|
||||
// Allow all navigation within our server
|
||||
if (request.url.startsWith(widget.serverUrl)) {
|
||||
return NavigationDecision.navigate;
|
||||
}
|
||||
// Allow blob: and data: URLs (for downloads, etc.)
|
||||
if (request.url.startsWith('blob:') ||
|
||||
request.url.startsWith('data:')) {
|
||||
return NavigationDecision.navigate;
|
||||
}
|
||||
// Block external navigation
|
||||
return NavigationDecision.prevent;
|
||||
},
|
||||
),
|
||||
)
|
||||
..addJavaScriptChannel(
|
||||
'TwpMobile',
|
||||
onMessageReceived: _handleJsMessage,
|
||||
);
|
||||
|
||||
// Configure Android-specific settings
|
||||
final androidController =
|
||||
_controller.platform as AndroidWebViewController;
|
||||
// Auto-grant microphone permission for WebRTC calls
|
||||
androidController.setOnPlatformPermissionRequest(
|
||||
(PlatformWebViewPermissionRequest request) {
|
||||
request.grant();
|
||||
},
|
||||
);
|
||||
// Allow media playback without user gesture (for ringtones)
|
||||
androidController.setMediaPlaybackRequiresUserGesture(false);
|
||||
|
||||
// Load the phone page
|
||||
final phoneUrl = '${widget.serverUrl}/twp-phone/';
|
||||
_controller.loadRequest(Uri.parse(phoneUrl));
|
||||
}
|
||||
|
||||
void _handleJsMessage(JavaScriptMessage message) {
|
||||
final msg = message.message;
|
||||
|
||||
if (msg == 'onSessionExpired') {
|
||||
widget.onSessionExpired();
|
||||
} else if (msg == 'requestFcmToken') {
|
||||
_injectFcmToken();
|
||||
} else if (msg == 'onPageReady') {
|
||||
// Phone page loaded successfully
|
||||
_injectFcmToken();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _injectFcmToken() async {
|
||||
final token = _pushService.fcmToken;
|
||||
if (token != null) {
|
||||
// Send the FCM token to the web page via the TwpMobile bridge
|
||||
await _controller.runJavaScript(
|
||||
'if (window.TwpMobile && window.TwpMobile.setFcmToken) { window.TwpMobile.setFcmToken("$token"); }',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _retry() async {
|
||||
setState(() {
|
||||
_hasError = false;
|
||||
_loading = true;
|
||||
});
|
||||
final phoneUrl = '${widget.serverUrl}/twp-phone/';
|
||||
await _controller.loadRequest(Uri.parse(phoneUrl));
|
||||
}
|
||||
|
||||
Future<bool> _onWillPop() async {
|
||||
// Check if WebView can go back
|
||||
if (await _controller.canGoBack()) {
|
||||
await _controller.goBack();
|
||||
return false;
|
||||
}
|
||||
// Show confirmation dialog
|
||||
if (!mounted) return true;
|
||||
final result = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('Exit'),
|
||||
content: const Text('Are you sure you want to exit the phone?'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(false),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(true),
|
||||
child: const Text('Exit'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
return result ?? false;
|
||||
}
|
||||
|
||||
void _showMenu() {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
builder: (context) => SafeArea(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
ListTile(
|
||||
leading: const Icon(Icons.refresh),
|
||||
title: const Text('Reload'),
|
||||
onTap: () {
|
||||
Navigator.pop(context);
|
||||
_controller.reload();
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.logout),
|
||||
title: const Text('Logout'),
|
||||
onTap: () {
|
||||
Navigator.pop(context);
|
||||
_confirmLogout();
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _confirmLogout() async {
|
||||
final result = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('Logout'),
|
||||
content: const Text(
|
||||
'This will clear your session. You will need to sign in again.'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(false),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(true),
|
||||
child: const Text('Logout'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
if (result == true) {
|
||||
// Clear WebView cookies
|
||||
await WebViewCookieManager().clearCookies();
|
||||
widget.onLogout();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// ignore: deprecated_member_use
|
||||
return WillPopScope(
|
||||
onWillPop: _onWillPop,
|
||||
child: Scaffold(
|
||||
body: SafeArea(
|
||||
child: Stack(
|
||||
children: [
|
||||
if (!_hasError) WebViewWidget(controller: _controller),
|
||||
if (_hasError) _buildErrorView(),
|
||||
if (_loading && !_hasError)
|
||||
const Center(child: CircularProgressIndicator()),
|
||||
],
|
||||
),
|
||||
),
|
||||
floatingActionButton: (!_hasError && !_loading)
|
||||
? FloatingActionButton.small(
|
||||
onPressed: _showMenu,
|
||||
child: const Icon(Icons.more_vert),
|
||||
)
|
||||
: null,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildErrorView() {
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(Icons.wifi_off, size: 64, color: Colors.grey),
|
||||
const SizedBox(height: 16),
|
||||
const Text(
|
||||
'Connection Error',
|
||||
style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
_errorMessage ?? 'Could not load the phone page.',
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(color: Colors.grey),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
FilledButton.icon(
|
||||
onPressed: _retry,
|
||||
icon: const Icon(Icons.refresh),
|
||||
label: const Text('Retry'),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextButton(
|
||||
onPressed: widget.onLogout,
|
||||
child: const Text('Change Server'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,68 +0,0 @@
|
||||
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);
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,85 +0,0 @@
|
||||
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();
|
||||
@@ -1,108 +0,0 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'package:dio/dio.dart';
|
||||
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,
|
||||
},
|
||||
options: Options(receiveTimeout: const Duration(seconds: 60)),
|
||||
);
|
||||
|
||||
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']);
|
||||
await _storage.write(key: 'user_data', value: jsonEncode(data['user']));
|
||||
|
||||
_scheduleRefresh(data['expires_in'] as int? ?? 3600);
|
||||
|
||||
return User.fromJson(data['user']);
|
||||
}
|
||||
|
||||
Future<User?> tryRestoreSession() async {
|
||||
final token = await _storage.read(key: 'access_token');
|
||||
if (token == null) return null;
|
||||
|
||||
await _api.restoreBaseUrl();
|
||||
if (_api.dio.options.baseUrl.isEmpty) return null;
|
||||
|
||||
try {
|
||||
final response = await _api.dio.get('/agent/status');
|
||||
if (response.statusCode != 200) return null;
|
||||
|
||||
final userData = await _storage.read(key: 'user_data');
|
||||
if (userData != null) {
|
||||
return User.fromJson(jsonDecode(userData) as Map<String, dynamic>);
|
||||
}
|
||||
return null;
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -3,12 +3,11 @@ import 'package:firebase_core/firebase_core.dart';
|
||||
import 'package:firebase_messaging/firebase_messaging.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
|
||||
import 'api_client.dart';
|
||||
|
||||
/// Notification ID for queue alerts (fixed so we can cancel it).
|
||||
const int _queueAlertNotificationId = 9001;
|
||||
|
||||
/// Background handler — must be top-level function.
|
||||
/// Background handler -- must be top-level function.
|
||||
@pragma('vm:entry-point')
|
||||
Future<void> _firebaseMessagingBackgroundHandler(RemoteMessage message) async {
|
||||
await Firebase.initializeApp();
|
||||
@@ -21,7 +20,6 @@ Future<void> _firebaseMessagingBackgroundHandler(RemoteMessage message) async {
|
||||
final plugin = FlutterLocalNotificationsPlugin();
|
||||
await plugin.cancel(_queueAlertNotificationId);
|
||||
}
|
||||
// VoIP pushes handled natively by twilio_voice plugin.
|
||||
}
|
||||
|
||||
/// Show an insistent queue alert notification (works from background handler too).
|
||||
@@ -57,8 +55,12 @@ Future<void> _showQueueAlertNotification(Map<String, dynamic> data) async {
|
||||
);
|
||||
}
|
||||
|
||||
/// Push notification service for queue alerts and general notifications.
|
||||
///
|
||||
/// FCM token registration is handled via the WebView JavaScript bridge
|
||||
/// instead of a REST API call. The token is exposed via [fcmToken] and
|
||||
/// injected into the web page by [PhoneScreen].
|
||||
class PushNotificationService {
|
||||
final ApiClient _api;
|
||||
final FirebaseMessaging _messaging = FirebaseMessaging.instance;
|
||||
final FlutterLocalNotificationsPlugin _localNotifications =
|
||||
FlutterLocalNotificationsPlugin();
|
||||
@@ -66,8 +68,6 @@ class PushNotificationService {
|
||||
|
||||
String? get fcmToken => _fcmToken;
|
||||
|
||||
PushNotificationService(this._api);
|
||||
|
||||
Future<void> initialize() async {
|
||||
FirebaseMessaging.onBackgroundMessage(_firebaseMessagingBackgroundHandler);
|
||||
|
||||
@@ -84,43 +84,37 @@ class PushNotificationService {
|
||||
const initSettings = InitializationSettings(android: androidSettings);
|
||||
await _localNotifications.initialize(initSettings);
|
||||
|
||||
// Get and register FCM token
|
||||
// Get FCM token
|
||||
final token = await _messaging.getToken();
|
||||
debugPrint('FCM token: ${token != null ? "${token.substring(0, 20)}..." : "NULL"}');
|
||||
debugPrint(
|
||||
'FCM token: ${token != null ? "${token.substring(0, 20)}..." : "NULL"}');
|
||||
if (token != null) {
|
||||
_fcmToken = token;
|
||||
await _registerToken(token);
|
||||
} else {
|
||||
debugPrint('FCM: Failed to get token - Firebase may not be configured correctly');
|
||||
debugPrint(
|
||||
'FCM: Failed to get token - Firebase may not be configured correctly');
|
||||
}
|
||||
|
||||
// Listen for token refresh
|
||||
_messaging.onTokenRefresh.listen(_registerToken);
|
||||
_messaging.onTokenRefresh.listen((token) {
|
||||
_fcmToken = token;
|
||||
});
|
||||
|
||||
// Handle foreground messages (non-VoIP)
|
||||
// Handle foreground messages
|
||||
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;
|
||||
|
||||
// Queue alert — show insistent notification
|
||||
// Queue alert -- show insistent notification
|
||||
if (type == 'queue_alert') {
|
||||
_showQueueAlertNotification(data);
|
||||
return;
|
||||
}
|
||||
|
||||
// Queue alert cancel — dismiss notification
|
||||
// Queue alert cancel -- dismiss notification
|
||||
if (type == 'queue_alert_cancel') {
|
||||
_localNotifications.cancel(_queueAlertNotificationId);
|
||||
return;
|
||||
@@ -142,7 +136,7 @@ class PushNotificationService {
|
||||
);
|
||||
}
|
||||
|
||||
/// Cancel any active queue alert (called when agent accepts a call in-app).
|
||||
/// Cancel any active queue alert.
|
||||
void cancelQueueAlert() {
|
||||
_localNotifications.cancel(_queueAlertNotificationId);
|
||||
}
|
||||
|
||||
@@ -1,238 +0,0 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:math';
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter/foundation.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;
|
||||
int _sseFailures = 0;
|
||||
Timer? _pollTimer;
|
||||
Map<String, dynamic>? _previousPollState;
|
||||
|
||||
Stream<SseEvent> get events => _eventController.stream;
|
||||
Stream<bool> get connectionState => _connectionController.stream;
|
||||
|
||||
SseService(this._api);
|
||||
|
||||
Future<void> connect() async {
|
||||
_shouldReconnect = true;
|
||||
_reconnectAttempt = 0;
|
||||
_sseFailures = 0;
|
||||
await _doConnect();
|
||||
}
|
||||
|
||||
Future<void> _doConnect() async {
|
||||
// After 2 SSE failures, fall back to polling
|
||||
if (_sseFailures >= 2) {
|
||||
debugPrint('SSE: falling back to polling after $_sseFailures failures');
|
||||
_startPolling();
|
||||
return;
|
||||
}
|
||||
|
||||
_cancelToken?.cancel();
|
||||
_cancelToken = CancelToken();
|
||||
|
||||
// Timer to detect if SSE stream never delivers data (Apache buffering)
|
||||
Timer? firstDataTimer;
|
||||
bool gotData = false;
|
||||
|
||||
try {
|
||||
final token = await _storage.read(key: 'access_token');
|
||||
debugPrint('SSE: connecting via stream (attempt ${_sseFailures + 1})');
|
||||
|
||||
firstDataTimer = Timer(const Duration(seconds: 8), () {
|
||||
if (!gotData) {
|
||||
debugPrint('SSE: no data received in 8s, cancelling');
|
||||
_cancelToken?.cancel();
|
||||
}
|
||||
});
|
||||
|
||||
final response = await _api.dio.get(
|
||||
'/stream/events',
|
||||
options: Options(
|
||||
headers: {'Authorization': 'Bearer $token'},
|
||||
responseType: ResponseType.stream,
|
||||
receiveTimeout: Duration.zero,
|
||||
),
|
||||
cancelToken: _cancelToken,
|
||||
);
|
||||
|
||||
debugPrint('SSE: connected, status=${response.statusCode}');
|
||||
_connectionController.add(true);
|
||||
_reconnectAttempt = 0;
|
||||
_sseFailures = 0;
|
||||
|
||||
final stream = response.data.stream as Stream<List<int>>;
|
||||
String buffer = '';
|
||||
|
||||
await for (final chunk in stream) {
|
||||
if (!gotData) {
|
||||
gotData = true;
|
||||
firstDataTimer.cancel();
|
||||
debugPrint('SSE: first data received');
|
||||
}
|
||||
buffer += utf8.decode(chunk);
|
||||
final lines = buffer.split('\n');
|
||||
buffer = lines.removeLast();
|
||||
|
||||
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) {
|
||||
firstDataTimer?.cancel();
|
||||
// Distinguish user-initiated cancel from timeout cancel
|
||||
if (e is DioException && e.type == DioExceptionType.cancel) {
|
||||
if (!gotData && _shouldReconnect) {
|
||||
// Cancelled by our firstDataTimer — count as SSE failure
|
||||
debugPrint('SSE: stream timed out (no data), failure ${_sseFailures + 1}');
|
||||
_sseFailures++;
|
||||
_connectionController.add(false);
|
||||
} else {
|
||||
return; // User-initiated disconnect
|
||||
}
|
||||
} else {
|
||||
debugPrint('SSE: stream error: $e');
|
||||
_sseFailures++;
|
||||
_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);
|
||||
}
|
||||
|
||||
// Polling fallback when SSE streaming doesn't work
|
||||
void _startPolling() {
|
||||
_pollTimer?.cancel();
|
||||
_previousPollState = null;
|
||||
_poll();
|
||||
_pollTimer = Timer.periodic(const Duration(seconds: 5), (_) => _poll());
|
||||
}
|
||||
|
||||
Future<void> _poll() async {
|
||||
if (!_shouldReconnect) return;
|
||||
try {
|
||||
final response = await _api.dio.get('/stream/poll');
|
||||
final data = Map<String, dynamic>.from(response.data);
|
||||
_connectionController.add(true);
|
||||
|
||||
if (_previousPollState != null) {
|
||||
_diffAndEmit(_previousPollState!, data);
|
||||
}
|
||||
_previousPollState = data;
|
||||
} catch (e) {
|
||||
debugPrint('SSE poll error: $e');
|
||||
_connectionController.add(false);
|
||||
}
|
||||
}
|
||||
|
||||
void _diffAndEmit(Map<String, dynamic> prev, Map<String, dynamic> curr) {
|
||||
final prevStatus = prev['agent_status']?.toString();
|
||||
final currStatus = curr['agent_status']?.toString();
|
||||
if (prevStatus != currStatus) {
|
||||
_eventController.add(SseEvent(
|
||||
event: 'agent_status_changed',
|
||||
data: (curr['agent_status'] as Map<String, dynamic>?) ?? {},
|
||||
));
|
||||
}
|
||||
|
||||
final prevQueues = prev['queues'] as Map<String, dynamic>? ?? {};
|
||||
final currQueues = curr['queues'] as Map<String, dynamic>? ?? {};
|
||||
for (final entry in currQueues.entries) {
|
||||
final currQueue = Map<String, dynamic>.from(entry.value);
|
||||
final prevQueue = prevQueues[entry.key] as Map<String, dynamic>?;
|
||||
if (prevQueue == null) {
|
||||
_eventController.add(SseEvent(event: 'queue_added', data: currQueue));
|
||||
continue;
|
||||
}
|
||||
final currCount = currQueue['waiting_count'] as int? ?? 0;
|
||||
final prevCount = prevQueue['waiting_count'] as int? ?? 0;
|
||||
if (currCount > prevCount) {
|
||||
_eventController.add(SseEvent(event: 'call_enqueued', data: currQueue));
|
||||
} else if (currCount < prevCount) {
|
||||
_eventController.add(SseEvent(event: 'call_dequeued', data: currQueue));
|
||||
}
|
||||
}
|
||||
|
||||
final prevCall = prev['current_call']?.toString();
|
||||
final currCall = curr['current_call']?.toString();
|
||||
if (prevCall != currCall) {
|
||||
if (curr['current_call'] != null && prev['current_call'] == null) {
|
||||
_eventController.add(SseEvent(
|
||||
event: 'call_started',
|
||||
data: curr['current_call'] as Map<String, dynamic>,
|
||||
));
|
||||
} else if (curr['current_call'] == null && prev['current_call'] != null) {
|
||||
_eventController.add(SseEvent(
|
||||
event: 'call_ended',
|
||||
data: prev['current_call'] as Map<String, dynamic>,
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void disconnect() {
|
||||
_shouldReconnect = false;
|
||||
_reconnectTimer?.cancel();
|
||||
_pollTimer?.cancel();
|
||||
_pollTimer = null;
|
||||
_cancelToken?.cancel();
|
||||
_connectionController.add(false);
|
||||
}
|
||||
|
||||
void dispose() {
|
||||
disconnect();
|
||||
_eventController.close();
|
||||
_connectionController.close();
|
||||
}
|
||||
}
|
||||
@@ -1,146 +0,0 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:twilio_voice/twilio_voice.dart';
|
||||
import 'api_client.dart';
|
||||
|
||||
class VoiceService {
|
||||
final ApiClient _api;
|
||||
Timer? _tokenRefreshTimer;
|
||||
String? _identity;
|
||||
String? _deviceToken;
|
||||
StreamSubscription? _eventSubscription;
|
||||
|
||||
final StreamController<CallEvent> _callEventController =
|
||||
StreamController<CallEvent>.broadcast();
|
||||
Stream<CallEvent> get callEvents => _callEventController.stream;
|
||||
|
||||
VoiceService(this._api);
|
||||
|
||||
Future<void> initialize({String? deviceToken}) async {
|
||||
_deviceToken = deviceToken;
|
||||
debugPrint('VoiceService.initialize: deviceToken=${deviceToken != null ? "present (${deviceToken.length} chars)" : "NULL"}');
|
||||
|
||||
// Request permissions (Android telecom requires these)
|
||||
await TwilioVoice.instance.requestMicAccess();
|
||||
if (!kIsWeb && Platform.isAndroid) {
|
||||
await TwilioVoice.instance.requestReadPhoneStatePermission();
|
||||
await TwilioVoice.instance.requestReadPhoneNumbersPermission();
|
||||
await TwilioVoice.instance.requestCallPhonePermission();
|
||||
await TwilioVoice.instance.requestManageOwnCallsPermission();
|
||||
// Register phone account with Android telecom
|
||||
// (enabling is handled by dashboard UI with a user-friendly dialog)
|
||||
await TwilioVoice.instance.registerPhoneAccount();
|
||||
}
|
||||
|
||||
// Fetch token and register
|
||||
await _fetchAndRegisterToken();
|
||||
|
||||
// Listen for call events (only once)
|
||||
_eventSubscription ??= TwilioVoice.instance.callEventsListener.listen((event) {
|
||||
if (!_callEventController.isClosed) {
|
||||
_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,
|
||||
deviceToken: _deviceToken ?? 'no-fcm',
|
||||
);
|
||||
} catch (e) {
|
||||
debugPrint('VoiceService._fetchAndRegisterToken error: $e');
|
||||
if (e is DioException) debugPrint(' response: ${e.response?.data}');
|
||||
}
|
||||
}
|
||||
|
||||
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<bool> makeCall(String to, {String? callerId}) async {
|
||||
try {
|
||||
final extraOptions = <String, dynamic>{};
|
||||
if (callerId != null && callerId.isNotEmpty) {
|
||||
extraOptions['CallerId'] = callerId;
|
||||
}
|
||||
debugPrint('VoiceService.makeCall: to=$to, from=$_identity, extras=$extraOptions');
|
||||
final result = await TwilioVoice.instance.call.place(
|
||||
to: to,
|
||||
from: _identity ?? '',
|
||||
extraOptions: extraOptions,
|
||||
) ?? false;
|
||||
debugPrint('VoiceService.makeCall: result=$result');
|
||||
return result;
|
||||
} catch (e) {
|
||||
debugPrint('VoiceService.makeCall error: $e');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> sendDigits(String digits) async {
|
||||
await TwilioVoice.instance.call.sendDigits(digits);
|
||||
}
|
||||
|
||||
Future<List<Map<String, dynamic>>> getQueueCalls(int queueId) async {
|
||||
final response = await _api.dio.get('/queues/$queueId/calls');
|
||||
return List<Map<String, dynamic>>.from(response.data['calls'] ?? []);
|
||||
}
|
||||
|
||||
Future<void> acceptQueueCall(String callSid) async {
|
||||
await _api.dio.post('/calls/$callSid/accept', data: {
|
||||
'client_identity': _identity,
|
||||
});
|
||||
}
|
||||
|
||||
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();
|
||||
_eventSubscription?.cancel();
|
||||
_eventSubscription = null;
|
||||
_callEventController.close();
|
||||
}
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
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);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,118 +0,0 @@
|
||||
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),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
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'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../models/queue_state.dart';
|
||||
|
||||
class QueueCard extends StatelessWidget {
|
||||
final QueueInfo queue;
|
||||
final VoidCallback? onTap;
|
||||
|
||||
const QueueCard({super.key, required this.queue, this.onTap});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Card(
|
||||
child: ListTile(
|
||||
onTap: onTap,
|
||||
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