diff --git a/includes/class-twp-core.php b/includes/class-twp-core.php
index 2cc9b26..96e1085 100644
--- a/includes/class-twp-core.php
+++ b/includes/class-twp-core.php
@@ -39,6 +39,7 @@ class TWP_Core {
require_once TWP_PLUGIN_DIR . 'includes/class-twp-mobile-api.php';
require_once TWP_PLUGIN_DIR . 'includes/class-twp-mobile-sse.php';
require_once TWP_PLUGIN_DIR . 'includes/class-twp-fcm.php';
+ require_once TWP_PLUGIN_DIR . 'includes/class-twp-mobile-phone-page.php';
require_once TWP_PLUGIN_DIR . 'includes/class-twp-auto-updater.php';
// Feature classes
@@ -254,7 +255,10 @@ class TWP_Core {
// Initialize Shortcodes
TWP_Shortcodes::init();
-
+
+ // Initialize standalone mobile phone page (/twp-phone/)
+ new TWP_Mobile_Phone_Page();
+
// Scheduled events
$scheduler = new TWP_Scheduler();
$this->loader->add_action('twp_check_schedules', $scheduler, 'check_active_schedules');
diff --git a/includes/class-twp-mobile-phone-page.php b/includes/class-twp-mobile-phone-page.php
new file mode 100644
index 0000000..bd71702
--- /dev/null
+++ b/includes/class-twp-mobile-phone-page.php
@@ -0,0 +1,1996 @@
+prefix . 'twp_mobile_sessions';
+
+ // Update existing session or insert new one
+ $existing = $wpdb->get_row($wpdb->prepare(
+ "SELECT id FROM $table WHERE user_id = %d AND fcm_token = %s AND is_active = 1",
+ $user_id, $fcm_token
+ ));
+
+ if (!$existing) {
+ $wpdb->insert($table, array(
+ 'user_id' => $user_id,
+ 'fcm_token' => $fcm_token,
+ 'device_info' => 'WebView Mobile App',
+ 'is_active' => 1,
+ 'created_at' => current_time('mysql'),
+ 'expires_at' => date('Y-m-d H:i:s', time() + 7 * DAY_IN_SECONDS),
+ ));
+ }
+
+ wp_send_json_success('FCM token registered');
+ }
+
+ /**
+ * Register custom rewrite rule.
+ */
+ public function register_rewrite() {
+ add_rewrite_rule(
+ '^' . self::ENDPOINT . '/?$',
+ 'index.php?twp_phone_page=1',
+ 'top'
+ );
+ }
+
+ /**
+ * Expose query variable.
+ *
+ * @param array $vars Existing query vars.
+ * @return array
+ */
+ public function add_query_var($vars) {
+ $vars[] = 'twp_phone_page';
+ return $vars;
+ }
+
+ /**
+ * Handle the request on template_redirect.
+ */
+ public function handle_request() {
+ if (!get_query_var('twp_phone_page')) {
+ return;
+ }
+
+ // Authentication check — redirect to login if not authenticated.
+ if (!is_user_logged_in()) {
+ $redirect_url = home_url('/' . self::ENDPOINT . '/');
+ wp_redirect(wp_login_url($redirect_url));
+ exit;
+ }
+
+ // Capability check.
+ if (!current_user_can('twp_access_browser_phone')) {
+ wp_die(
+ 'You do not have permission to access the browser phone.',
+ 'Access Denied',
+ array('response' => 403)
+ );
+ }
+
+ // Render the standalone page and exit.
+ $this->render_page();
+ exit;
+ }
+
+ /**
+ * Extend auth cookie to 7 days for phone agents.
+ *
+ * @param int $expiration Default expiration in seconds.
+ * @param int $user_id User ID.
+ * @param bool $remember Whether "Remember Me" was checked.
+ * @return int
+ */
+ public function extend_agent_cookie($expiration, $user_id, $remember) {
+ $user = get_userdata($user_id);
+ if ($user && $user->has_cap('twp_access_browser_phone')) {
+ return 7 * DAY_IN_SECONDS;
+ }
+ return $expiration;
+ }
+
+ // ------------------------------------------------------------------
+ // Rendering
+ // ------------------------------------------------------------------
+
+ /**
+ * Output the complete standalone HTML page.
+ */
+ private function render_page() {
+ // Gather data needed by the template (same as display_browser_phone_page).
+ $current_user_id = get_current_user_id();
+
+ global $wpdb;
+ $extensions_table = $wpdb->prefix . 'twp_user_extensions';
+ $extension_data = $wpdb->get_row($wpdb->prepare(
+ "SELECT extension FROM $extensions_table WHERE user_id = %d",
+ $current_user_id
+ ));
+
+ if (!$extension_data) {
+ TWP_User_Queue_Manager::create_user_queues($current_user_id);
+ $extension_data = $wpdb->get_row($wpdb->prepare(
+ "SELECT extension FROM $extensions_table WHERE user_id = %d",
+ $current_user_id
+ ));
+ }
+
+ $agent_status = TWP_Agent_Manager::get_agent_status($current_user_id);
+ $agent_stats = TWP_Agent_Manager::get_agent_stats($current_user_id);
+ $is_logged_in = TWP_Agent_Manager::is_agent_logged_in($current_user_id);
+
+ $current_mode = get_user_meta($current_user_id, 'twp_call_mode', true);
+ if (empty($current_mode)) {
+ $current_mode = 'cell';
+ }
+
+ $user_phone = get_user_meta($current_user_id, 'twp_phone_number', true);
+
+ // Smart routing check (for admin-only setup notice).
+ $smart_routing_configured = false;
+ try {
+ $twilio = new TWP_Twilio_API();
+ $phone_numbers = $twilio->get_phone_numbers();
+ if ($phone_numbers['success']) {
+ $smart_routing_url = home_url('/wp-json/twilio-webhook/v1/smart-routing');
+ foreach ($phone_numbers['data']['incoming_phone_numbers'] as $number) {
+ if (isset($number['voice_url']) && strpos($number['voice_url'], 'smart-routing') !== false) {
+ $smart_routing_configured = true;
+ break;
+ }
+ }
+ }
+ } catch (Exception $e) {
+ // Silently continue.
+ }
+
+ // Nonce for AJAX.
+ $nonce = wp_create_nonce('twp_ajax_nonce');
+
+ // URLs.
+ $ajax_url = admin_url('admin-ajax.php');
+ $ringtone_url = plugins_url('assets/sounds/ringtone.mp3', dirname(__FILE__));
+ $phone_icon_url = plugins_url('assets/images/phone-icon.png', dirname(__FILE__));
+ $sw_url = plugins_url('assets/js/twp-service-worker.js', dirname(__FILE__));
+ $twilio_edge = esc_js(get_option('twp_twilio_edge', 'roaming'));
+ $smart_routing_webhook = home_url('/wp-json/twilio-webhook/v1/smart-routing');
+
+ // Begin output.
+ ?>
+
+
+
+
+
+
+
+Phone -
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ extension) : '—'; ?>
+
+
+
+
+
+
+ Today:
+ Total:
+ Avg: s
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Ready
+
Loading...
+
+
00:00
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Loading your queues...
+
+
+
+
+
+
+
+
+
+
+
+
+
Outbound Caller ID
+
+
+
+
+
+
+
+
+
+
+
Call Reception Mode
+
+
+
+
+
+
+ Current:
+
+
+
+
+
+
+
Keep this page open to receive calls.
+
+
+
Calls forwarded to: Not configured'; ?>
+
+
+
+
+
+
+
Setup Required
+
Update your phone number webhook to:
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
+
-
+
-
-
@@ -23,7 +21,7 @@
-
-
-
-
-
-
{
- final _apiClient = ApiClient();
+ static const _storage = FlutterSecureStorage();
+ String? _serverUrl;
+ bool _loading = true;
+
+ @override
+ void initState() {
+ super.initState();
+ _checkSavedSession();
+ }
+
+ Future _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(
- 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),
);
}
}
diff --git a/mobile/lib/config/app_config.dart b/mobile/lib/config/app_config.dart
deleted file mode 100644
index 41dc521..0000000
--- a/mobile/lib/config/app_config.dart
+++ /dev/null
@@ -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';
-}
diff --git a/mobile/lib/models/agent_status.dart b/mobile/lib/models/agent_status.dart
deleted file mode 100644
index 0f58aee..0000000
--- a/mobile/lib/models/agent_status.dart
+++ /dev/null
@@ -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 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;
- }
- }
-}
diff --git a/mobile/lib/models/call_info.dart b/mobile/lib/models/call_info.dart
deleted file mode 100644
index 72b68db..0000000
--- a/mobile/lib/models/call_info.dart
+++ /dev/null
@@ -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;
-}
diff --git a/mobile/lib/models/queue_state.dart b/mobile/lib/models/queue_state.dart
deleted file mode 100644
index 748674e..0000000
--- a/mobile/lib/models/queue_state.dart
+++ /dev/null
@@ -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 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 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;
- }
-}
diff --git a/mobile/lib/models/user.dart b/mobile/lib/models/user.dart
deleted file mode 100644
index d535ca3..0000000
--- a/mobile/lib/models/user.dart
+++ /dev/null
@@ -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 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;
- }
-}
diff --git a/mobile/lib/providers/agent_provider.dart b/mobile/lib/providers/agent_provider.dart
deleted file mode 100644
index 0586dd1..0000000
--- a/mobile/lib/providers/agent_provider.dart
+++ /dev/null
@@ -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 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 _queues = [];
- bool _sseConnected = false;
- List _phoneNumbers = [];
- StreamSubscription? _sseSub;
- StreamSubscription? _connSub;
- Timer? _refreshTimer;
-
- AgentStatus? get status => _status;
- List get queues => _queues;
- bool get sseConnected => _sseConnected;
- List 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 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 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 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))
- .toList();
- notifyListeners();
- } catch (e) {
- debugPrint('AgentProvider.fetchQueues error: $e');
- if (e is DioException) debugPrint(' response: ${e.response?.data}');
- }
- }
-
- Future 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))
- .toList();
- notifyListeners();
- } catch (e) {
- debugPrint('AgentProvider.fetchPhoneNumbers error: $e');
- }
- }
-
- Future 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();
- }
-}
diff --git a/mobile/lib/providers/auth_provider.dart b/mobile/lib/providers/auth_provider.dart
deleted file mode 100644
index d53442c..0000000
--- a/mobile/lib/providers/auth_provider.dart
+++ /dev/null
@@ -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 tryRestoreSession() async {
- final user = await _authService.tryRestoreSession();
- if (user != null) {
- _user = user;
- _state = AuthState.authenticated;
- await _initializeServices();
- notifyListeners();
- }
- }
-
- Future 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 _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 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();
- }
-}
diff --git a/mobile/lib/providers/call_provider.dart b/mobile/lib/providers/call_provider.dart
deleted file mode 100644
index 8093242..0000000
--- a/mobile/lib/providers/call_provider.dart
+++ /dev/null
@@ -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 answer() => _voiceService.answer();
- Future reject() => _voiceService.reject();
- Future 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 toggleMute() async {
- final newMuted = !_callInfo.isMuted;
- await _voiceService.toggleMute(newMuted);
- _callInfo = _callInfo.copyWith(isMuted: newMuted);
- notifyListeners();
- }
-
- Future toggleSpeaker() async {
- final newSpeaker = !_callInfo.isSpeakerOn;
- await _voiceService.toggleSpeaker(newSpeaker);
- _callInfo = _callInfo.copyWith(isSpeakerOn: newSpeaker);
- notifyListeners();
- }
-
- Future sendDigits(String digits) => _voiceService.sendDigits(digits);
-
- Future 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 holdCall() async {
- final sid = _callInfo.callSid;
- if (sid == null) return;
- await _voiceService.holdCall(sid);
- _callInfo = _callInfo.copyWith(isOnHold: true);
- notifyListeners();
- }
-
- Future unholdCall() async {
- final sid = _callInfo.callSid;
- if (sid == null) return;
- await _voiceService.unholdCall(sid);
- _callInfo = _callInfo.copyWith(isOnHold: false);
- notifyListeners();
- }
-
- Future transferCall(String target) async {
- final sid = _callInfo.callSid;
- if (sid == null) return;
- await _voiceService.transferCall(sid, target);
- }
-
- Future 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();
- }
-}
diff --git a/mobile/lib/screens/active_call_screen.dart b/mobile/lib/screens/active_call_screen.dart
deleted file mode 100644
index e8eb129..0000000
--- a/mobile/lib/screens/active_call_screen.dart
+++ /dev/null
@@ -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 createState() => _ActiveCallScreenState();
-}
-
-class _ActiveCallScreenState extends State {
- bool _showDialpad = false;
-
- @override
- Widget build(BuildContext context) {
- final call = context.watch();
- 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'),
- ),
- ],
- ),
- );
- }
-}
diff --git a/mobile/lib/screens/dashboard_screen.dart b/mobile/lib/screens/dashboard_screen.dart
deleted file mode 100644
index 1fd740e..0000000
--- a/mobile/lib/screens/dashboard_screen.dart
+++ /dev/null
@@ -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 createState() => _DashboardScreenState();
-}
-
-class _DashboardScreenState extends State {
- bool _phoneAccountEnabled = true; // assume true until checked
-
- @override
- void initState() {
- super.initState();
- WidgetsBinding.instance.addPostFrameCallback((_) {
- context.read().refresh();
- _checkPhoneAccount();
- });
- }
-
- Future _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().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(
- initialValue: selectedCallerId,
- decoration: const InputDecoration(
- labelText: 'Caller ID',
- isDense: true,
- contentPadding: EdgeInsets.symmetric(horizontal: 12, vertical: 8),
- ),
- items: phoneNumbers.map((p) => DropdownMenuItem(
- 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().makeCall(number, callerId: selectedCallerId);
- Navigator.pop(ctx);
- },
- ),
- const SizedBox(height: 16),
- ],
- ),
- );
- },
- );
- },
- );
- }
-
- void _showQueueCalls(BuildContext context, QueueInfo queue) {
- final voiceService = context.read().voiceService;
- final callProvider = context.read();
-
- showModalBottomSheet(
- context: context,
- shape: const RoundedRectangleBorder(
- borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
- ),
- builder: (ctx) {
- return FutureBuilder>>(
- 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();
-
- // 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,
- ),
- )),
- ],
- ),
- ),
- );
- }
-}
diff --git a/mobile/lib/screens/login_screen.dart b/mobile/lib/screens/login_screen.dart
index 13f4c27..a29338e 100644
--- a/mobile/lib/screens/login_screen.dart
+++ b/mobile/lib/screens/login_screen.dart
@@ -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 createState() => _LoginScreenState();
}
class _LoginScreenState extends State {
+ static const _storage = FlutterSecureStorage();
+
final _formKey = GlobalKey();
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 {
}
Future _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().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 _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();
+ 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 {
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 {
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 {
@override
void dispose() {
_serverController.dispose();
- _usernameController.dispose();
- _passwordController.dispose();
super.dispose();
}
}
diff --git a/mobile/lib/screens/phone_screen.dart b/mobile/lib/screens/phone_screen.dart
new file mode 100644
index 0000000..fb1afa1
--- /dev/null
+++ b/mobile/lib/screens/phone_screen.dart
@@ -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 createState() => _PhoneScreenState();
+}
+
+class _PhoneScreenState extends State 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 _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 _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 _retry() async {
+ setState(() {
+ _hasError = false;
+ _loading = true;
+ });
+ final phoneUrl = '${widget.serverUrl}/twp-phone/';
+ await _controller.loadRequest(Uri.parse(phoneUrl));
+ }
+
+ Future _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(
+ 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(
+ 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'),
+ ),
+ ],
+ ),
+ ),
+ );
+ }
+}
diff --git a/mobile/lib/screens/settings_screen.dart b/mobile/lib/screens/settings_screen.dart
deleted file mode 100644
index 9c0e950..0000000
--- a/mobile/lib/screens/settings_screen.dart
+++ /dev/null
@@ -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 createState() => _SettingsScreenState();
-}
-
-class _SettingsScreenState extends State {
- String? _serverUrl;
-
- @override
- void initState() {
- super.initState();
- _loadServerUrl();
- }
-
- Future _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();
-
- 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);
- }
- },
- ),
- ],
- ),
- );
- }
-}
diff --git a/mobile/lib/services/api_client.dart b/mobile/lib/services/api_client.dart
deleted file mode 100644
index 59d519f..0000000
--- a/mobile/lib/services/api_client.dart
+++ /dev/null
@@ -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 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 restoreBaseUrl() async {
- final url = await _storage.read(key: 'server_url');
- if (url != null) {
- dio.options.baseUrl = '$url/wp-json/twilio-mobile/v1';
- }
- }
-
- Future _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();
diff --git a/mobile/lib/services/auth_service.dart b/mobile/lib/services/auth_service.dart
deleted file mode 100644
index ee08fc9..0000000
--- a/mobile/lib/services/auth_service.dart
+++ /dev/null
@@ -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 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 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);
- }
- return null;
- } catch (_) {
- return null;
- }
- }
-
- Future 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 logout() async {
- _refreshTimer?.cancel();
- try {
- await _api.dio.post('/auth/logout');
- } catch (_) {}
- await _storage.deleteAll();
- }
-
- void dispose() {
- _refreshTimer?.cancel();
- }
-}
diff --git a/mobile/lib/services/push_notification_service.dart b/mobile/lib/services/push_notification_service.dart
index d5a468a..2da6ad6 100644
--- a/mobile/lib/services/push_notification_service.dart
+++ b/mobile/lib/services/push_notification_service.dart
@@ -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 _firebaseMessagingBackgroundHandler(RemoteMessage message) async {
await Firebase.initializeApp();
@@ -21,7 +20,6 @@ Future _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 _showQueueAlertNotification(Map 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 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 _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);
}
diff --git a/mobile/lib/services/sse_service.dart b/mobile/lib/services/sse_service.dart
deleted file mode 100644
index 5fa9ddf..0000000
--- a/mobile/lib/services/sse_service.dart
+++ /dev/null
@@ -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 data;
-
- SseEvent({required this.event, required this.data});
-}
-
-class SseService {
- final ApiClient _api;
- final FlutterSecureStorage _storage = const FlutterSecureStorage();
- final StreamController _eventController =
- StreamController.broadcast();
- final StreamController _connectionController =
- StreamController.broadcast();
-
- CancelToken? _cancelToken;
- Timer? _reconnectTimer;
- int _reconnectAttempt = 0;
- bool _shouldReconnect = true;
- int _sseFailures = 0;
- Timer? _pollTimer;
- Map? _previousPollState;
-
- Stream get events => _eventController.stream;
- Stream get connectionState => _connectionController.stream;
-
- SseService(this._api);
-
- Future connect() async {
- _shouldReconnect = true;
- _reconnectAttempt = 0;
- _sseFailures = 0;
- await _doConnect();
- }
-
- Future _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>;
- 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;
- _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 _poll() async {
- if (!_shouldReconnect) return;
- try {
- final response = await _api.dio.get('/stream/poll');
- final data = Map.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 prev, Map 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?) ?? {},
- ));
- }
-
- final prevQueues = prev['queues'] as Map? ?? {};
- final currQueues = curr['queues'] as Map? ?? {};
- for (final entry in currQueues.entries) {
- final currQueue = Map.from(entry.value);
- final prevQueue = prevQueues[entry.key] as Map?;
- 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,
- ));
- } else if (curr['current_call'] == null && prev['current_call'] != null) {
- _eventController.add(SseEvent(
- event: 'call_ended',
- data: prev['current_call'] as Map,
- ));
- }
- }
- }
-
- void disconnect() {
- _shouldReconnect = false;
- _reconnectTimer?.cancel();
- _pollTimer?.cancel();
- _pollTimer = null;
- _cancelToken?.cancel();
- _connectionController.add(false);
- }
-
- void dispose() {
- disconnect();
- _eventController.close();
- _connectionController.close();
- }
-}
diff --git a/mobile/lib/services/voice_service.dart b/mobile/lib/services/voice_service.dart
deleted file mode 100644
index 53e0155..0000000
--- a/mobile/lib/services/voice_service.dart
+++ /dev/null
@@ -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 _callEventController =
- StreamController.broadcast();
- Stream get callEvents => _callEventController.stream;
-
- VoiceService(this._api);
-
- Future 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 _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 answer() async {
- await TwilioVoice.instance.call.answer();
- }
-
- Future reject() async {
- await TwilioVoice.instance.call.hangUp();
- }
-
- Future hangUp() async {
- await TwilioVoice.instance.call.hangUp();
- }
-
- Future toggleMute(bool mute) async {
- await TwilioVoice.instance.call.toggleMute(mute);
- }
-
- Future toggleSpeaker(bool speaker) async {
- await TwilioVoice.instance.call.toggleSpeaker(speaker);
- }
-
- Future makeCall(String to, {String? callerId}) async {
- try {
- final extraOptions = {};
- 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 sendDigits(String digits) async {
- await TwilioVoice.instance.call.sendDigits(digits);
- }
-
- Future>> getQueueCalls(int queueId) async {
- final response = await _api.dio.get('/queues/$queueId/calls');
- return List