From 621b0890a92b771b9b779da98c46d8b735b2681d Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 10 Mar 2026 09:11:25 -0700 Subject: [PATCH] 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 --- includes/class-twp-core.php | 6 +- includes/class-twp-mobile-phone-page.php | 1996 +++++++++++++++++ mobile/android/app/proguard-rules.pro | 5 + .../android/app/src/main/AndroidManifest.xml | 16 +- .../plugins/GeneratedPluginRegistrant.java | 4 +- .../src/main/res/mipmap-hdpi/ic_launcher.png | Bin 0 -> 415 bytes .../src/main/res/mipmap-mdpi/ic_launcher.png | Bin 0 -> 341 bytes .../src/main/res/mipmap-xhdpi/ic_launcher.png | Bin 0 -> 494 bytes .../main/res/mipmap-xxhdpi/ic_launcher.png | Bin 0 -> 619 bytes .../main/res/mipmap-xxxhdpi/ic_launcher.png | Bin 0 -> 811 bytes mobile/lib/app.dart | 116 +- mobile/lib/config/app_config.dart | 8 - mobile/lib/models/agent_status.dart | 38 - mobile/lib/models/call_info.dart | 46 - mobile/lib/models/queue_state.dart | 66 - mobile/lib/models/user.dart | 28 - mobile/lib/providers/agent_provider.dart | 132 -- mobile/lib/providers/auth_provider.dart | 122 - mobile/lib/providers/call_provider.dart | 180 -- mobile/lib/screens/active_call_screen.dart | 137 -- mobile/lib/screens/dashboard_screen.dart | 374 --- mobile/lib/screens/login_screen.dart | 164 +- mobile/lib/screens/phone_screen.dart | 314 +++ mobile/lib/screens/settings_screen.dart | 68 - mobile/lib/services/api_client.dart | 85 - mobile/lib/services/auth_service.dart | 108 - .../services/push_notification_service.dart | 42 +- mobile/lib/services/sse_service.dart | 238 -- mobile/lib/services/voice_service.dart | 146 -- mobile/lib/widgets/agent_status_toggle.dart | 51 - mobile/lib/widgets/call_controls.dart | 118 - mobile/lib/widgets/dialpad.dart | 55 - mobile/lib/widgets/queue_card.dart | 39 - mobile/pubspec.lock | 540 +---- mobile/pubspec.yaml | 12 +- mobile/test/webview_app_test.dart | 40 + test-deploy.sh | 113 + 37 files changed, 2744 insertions(+), 2663 deletions(-) create mode 100644 includes/class-twp-mobile-phone-page.php create mode 100644 mobile/android/app/src/main/res/mipmap-hdpi/ic_launcher.png create mode 100644 mobile/android/app/src/main/res/mipmap-mdpi/ic_launcher.png create mode 100644 mobile/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png create mode 100644 mobile/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png create mode 100644 mobile/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png delete mode 100644 mobile/lib/config/app_config.dart delete mode 100644 mobile/lib/models/agent_status.dart delete mode 100644 mobile/lib/models/call_info.dart delete mode 100644 mobile/lib/models/queue_state.dart delete mode 100644 mobile/lib/models/user.dart delete mode 100644 mobile/lib/providers/agent_provider.dart delete mode 100644 mobile/lib/providers/auth_provider.dart delete mode 100644 mobile/lib/providers/call_provider.dart delete mode 100644 mobile/lib/screens/active_call_screen.dart delete mode 100644 mobile/lib/screens/dashboard_screen.dart create mode 100644 mobile/lib/screens/phone_screen.dart delete mode 100644 mobile/lib/screens/settings_screen.dart delete mode 100644 mobile/lib/services/api_client.dart delete mode 100644 mobile/lib/services/auth_service.dart delete mode 100644 mobile/lib/services/sse_service.dart delete mode 100644 mobile/lib/services/voice_service.dart delete mode 100644 mobile/lib/widgets/agent_status_toggle.dart delete mode 100644 mobile/lib/widgets/call_controls.dart delete mode 100644 mobile/lib/widgets/dialpad.dart delete mode 100644 mobile/lib/widgets/queue_card.dart create mode 100644 mobile/test/webview_app_test.dart create mode 100755 test-deploy.sh 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 - <?php echo esc_html(get_bloginfo('name')); ?> + + + + + + + + + + + + + +
+ + +
+
+ extension) : '—'; ?> + + + + +
+
+ Today: + Total: + Avg: s +
+
+ + +
+ + + +
+ + +
+ + + + + +
+ + +
+
+
+
Ready
+
Loading...
+
+ +
+ + + +
+ + + + + + + + + + + + +
+ +
+ + + +
+ + +
+
+ + +
+
+
+

Your Queues

+ +
Ext: extension); ?>
+ +
+
+
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 @@ - - - - - - ({ISiK&3;>c9{y>lvu*ke_YZ7>%#1+TxWfMB!8Zbz z!h&|sE4{oy?y3Csu-{5P)0Q9mdENcz{l>29&y2S+Yxkr_wtjvqYRm9Zn3;`7mdKI;Vst0KaR8 ABLDyZ literal 0 HcmV?d00001 diff --git a/mobile/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/mobile/android/app/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000000000000000000000000000000000000..174e5b2490e1750c84b608e4938fe1d98c70fd57 GIT binary patch literal 341 zcmeAS@N?(olHy`uVBq!ia0vp^1|ZDA1|-9oezpTCg=CK)Uj~LMH3o);76yi2K%s^g z3=E|}g|8AA7_4S6Fo+k-*%fF5lweEpc6VWrWGG{}v_|OVG@!_7PZ!6KjC*fyT5}z8 z5OI4ryWL4D(E1?j4i@pGw?-?x0Mn%eEntIrFjx#mCEugws~u!bRmaRXBV>jAC?Q3hfKZp@1L^Clx-=3U-~um0!c zkG(v)cn|AbhP5!WUPNEGTVt3v?_QmWb?n@pxo2N)H-0Za|M9oVkB`5`$K8MWwBPX9 z44u>VNxP$8KQ7w#|LvK-HHLhB>%WH8FLf6n*HxElq<@8%GLh^Zv=Dg1Jt0ylzOU+T j888OeOI#yLauSnLa~MKCGQ=$d3Nd)P`njxgN@xNAWJ{>D literal 0 HcmV?d00001 diff --git a/mobile/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/mobile/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000000000000000000000000000000000000..339a120ce45aad3166e919e18831daf47a873cf1 GIT binary patch literal 619 zcmeAS@N?(olHy`uVBq!ia0vp^6F``Q4M;wBd$a>cDI|LY`7$t6sWC7#v@kII0tz*} zU|=XUU|@Kaz`$TNgMmT3V9u^U8=wSRlDE4HgCs*4!=*JsFQ+juFh2BjaSW-r_4cl! zmrEc++eLFL$HNW2Y&~U#vS!WR8zOVp9eE|hV*Y?@3s=*@H44)hOL@m8nRS^m$% z$M?$a=T!dvVtsH{nHzt?X3h>52NfnIL4nQ&4;B|C1tG?X9Ml&2e37~9@X?y6H*fpq zZQS<$>iIAKHcHtl-+a-KcW&C{pDz!-kj$>G`8i83FW&vUM@%9%_iWDn|MvUIi~qh9 zl>T}DUhn{Ady|s20J){iJ+jh?JCZ`_g7$^s|?^ dMwH|vCZ*;ugnVR(TLu(j@O1TaS?83{1OVP~zM%jB literal 0 HcmV?d00001 diff --git a/mobile/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/mobile/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000000000000000000000000000000000000..0453bd2aefa392172f50765b6641a2dac544a26f GIT binary patch literal 811 zcmeAS@N?(olHy`uVBq!ia0vp^2SAvE4M+yv$zcal3dtTpz6=aiY77hwEes65fIQm1sya5K{`cWePj9b(DY^I(S<{kfK;bWCd6Y)AbI(M6O zy^C#gdH%K2-_Ng~UvsX1KL6?C>&-7;w~X312tD3+_3iupr>}nfJNf+1-=8gNr!L+G zimaD{21kWE22&(qO(Opp`wJDMJSH@r1twqi64!{5oW!Km9EOmO3~|eVLJXd+elF{r G5}E+%5&HrF literal 0 HcmV?d00001 diff --git a/mobile/lib/app.dart b/mobile/lib/app.dart index 16b2a3d..c02476b 100644 --- a/mobile/lib/app.dart +++ b/mobile/lib/app.dart @@ -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 { - 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>.from(response.data['calls'] ?? []); - } - - Future acceptQueueCall(String callSid) async { - await _api.dio.post('/calls/$callSid/accept', data: { - 'client_identity': _identity, - }); - } - - Future holdCall(String callSid) async { - await _api.dio.post('/calls/$callSid/hold'); - } - - Future unholdCall(String callSid) async { - await _api.dio.post('/calls/$callSid/unhold'); - } - - Future 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(); - } -} diff --git a/mobile/lib/widgets/agent_status_toggle.dart b/mobile/lib/widgets/agent_status_toggle.dart deleted file mode 100644 index 77278e7..0000000 --- a/mobile/lib/widgets/agent_status_toggle.dart +++ /dev/null @@ -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(); - 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( - 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); - }, - ), - ], - ), - ), - ); - } -} diff --git a/mobile/lib/widgets/call_controls.dart b/mobile/lib/widgets/call_controls.dart deleted file mode 100644 index 81b2d5c..0000000 --- a/mobile/lib/widgets/call_controls.dart +++ /dev/null @@ -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), - ], - ); - } -} diff --git a/mobile/lib/widgets/dialpad.dart b/mobile/lib/widgets/dialpad.dart deleted file mode 100644 index 7b62286..0000000 --- a/mobile/lib/widgets/dialpad.dart +++ /dev/null @@ -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'), - ), - ], - ), - ); - } -} diff --git a/mobile/lib/widgets/queue_card.dart b/mobile/lib/widgets/queue_card.dart deleted file mode 100644 index ad352a8..0000000 --- a/mobile/lib/widgets/queue_card.dart +++ /dev/null @@ -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, - ), - ); - } -} diff --git a/mobile/pubspec.lock b/mobile/pubspec.lock index a2083ae..ec370c1 100644 --- a/mobile/pubspec.lock +++ b/mobile/pubspec.lock @@ -1,14 +1,6 @@ # Generated by pub # See https://dart.dev/tools/pub/glossary#lockfile packages: - _fe_analyzer_shared: - dependency: transitive - description: - name: _fe_analyzer_shared - sha256: "8d7ff3948166b8ec5da0fbb5962000926b8e02f2ed9b3e51d1738905fbd4c98d" - url: "https://pub.dev" - source: hosted - version: "93.0.0" _flutterfire_internals: dependency: transitive description: @@ -17,14 +9,6 @@ packages: url: "https://pub.dev" source: hosted version: "1.3.59" - analyzer: - dependency: transitive - description: - name: analyzer - sha256: de7148ed2fcec579b19f122c1800933dfa028f6d9fd38a152b04b1516cec120b - url: "https://pub.dev" - source: hosted - version: "10.0.1" args: dependency: transitive description: @@ -37,138 +21,42 @@ packages: dependency: transitive description: name: async - sha256: "758e6d74e971c3e5aceb4110bfd6698efc7f501675bcfe0c775459a8140750eb" + sha256: "947bfcf187f74dbc5e146c9eb9c0f10c9f8b30743e341481c1e2ed3ecc18c20c" url: "https://pub.dev" source: hosted - version: "2.13.0" + version: "2.11.0" boolean_selector: dependency: transitive description: name: boolean_selector - sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea" + sha256: "6cfb5af12253eaf2b368f07bacc5a80d1301a071c73360d746b7f2e32d762c66" url: "https://pub.dev" source: hosted - version: "2.1.2" - build: - dependency: transitive - description: - name: build - sha256: "275bf6bb2a00a9852c28d4e0b410da1d833a734d57d39d44f94bfc895a484ec3" - url: "https://pub.dev" - source: hosted - version: "4.0.4" - build_config: - dependency: transitive - description: - name: build_config - sha256: "4070d2a59f8eec34c97c86ceb44403834899075f66e8a9d59706f8e7834f6f71" - url: "https://pub.dev" - source: hosted - version: "1.3.0" - build_daemon: - dependency: transitive - description: - name: build_daemon - sha256: bf05f6e12cfea92d3c09308d7bcdab1906cd8a179b023269eed00c071004b957 - url: "https://pub.dev" - source: hosted - version: "4.1.1" - build_runner: - dependency: "direct dev" - description: - name: build_runner - sha256: "7981eb922842c77033026eb4341d5af651562008cdb116bdfa31fc46516b6462" - url: "https://pub.dev" - source: hosted - version: "2.12.2" - built_collection: - dependency: transitive - description: - name: built_collection - sha256: "376e3dd27b51ea877c28d525560790aee2e6fbb5f20e2f85d5081027d94e2100" - url: "https://pub.dev" - source: hosted - version: "5.1.1" - built_value: - dependency: transitive - description: - name: built_value - sha256: "6ae8a6435a8c6520c7077b107e77f1fb4ba7009633259a4d49a8afd8e7efc5e9" - url: "https://pub.dev" - source: hosted - version: "8.12.4" + version: "2.1.1" characters: dependency: transitive description: name: characters - sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b + sha256: "04a925763edad70e8443c99234dc3328f442e811f1d8fd1a72f1c8ad0f69a605" url: "https://pub.dev" source: hosted - version: "1.4.1" - checked_yaml: - dependency: transitive - description: - name: checked_yaml - sha256: "959525d3162f249993882720d52b7e0c833978df229be20702b33d48d91de70f" - url: "https://pub.dev" - source: hosted - version: "2.0.4" + version: "1.3.0" clock: dependency: transitive description: name: clock - sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b + sha256: cb6d7f03e1de671e34607e909a7213e31d7752be4fb66a86d29fe1eb14bfb5cf url: "https://pub.dev" source: hosted - version: "1.1.2" - code_assets: - dependency: transitive - description: - name: code_assets - sha256: "83ccdaa064c980b5596c35dd64a8d3ecc68620174ab9b90b6343b753aa721687" - url: "https://pub.dev" - source: hosted - version: "1.0.0" - code_builder: - dependency: transitive - description: - name: code_builder - sha256: "6a6cab2ba4680d6423f34a9b972a4c9a94ebe1b62ecec4e1a1f2cba91fd1319d" - url: "https://pub.dev" - source: hosted - version: "4.11.1" + version: "1.1.1" collection: dependency: transitive description: name: collection - sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76" + sha256: a1ace0a119f20aabc852d165077c036cd864315bd99b7eaa10a60100341941bf url: "https://pub.dev" source: hosted - version: "1.19.1" - convert: - dependency: transitive - description: - name: convert - sha256: b30acd5944035672bc15c6b7a8b47d773e41e2f17de064350988c5d02adb1c68 - url: "https://pub.dev" - source: hosted - version: "3.1.2" - crypto: - dependency: transitive - description: - name: crypto - sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf - url: "https://pub.dev" - source: hosted - version: "3.0.7" - dart_style: - dependency: transitive - description: - name: dart_style - sha256: "6f6b30cba0301e7b38f32bdc9a6bdae6f5921a55f0a1eb9450e1e6515645dbb2" - url: "https://pub.dev" - source: hosted - version: "3.1.6" + version: "1.19.0" dbus: dependency: transitive description: @@ -177,46 +65,22 @@ packages: url: "https://pub.dev" source: hosted version: "0.7.12" - dio: - dependency: "direct main" - description: - name: dio - sha256: aff32c08f92787a557dd5c0145ac91536481831a01b4648136373cddb0e64f8c - url: "https://pub.dev" - source: hosted - version: "5.9.2" - dio_web_adapter: - dependency: transitive - description: - name: dio_web_adapter - sha256: "2f9e64323a7c3c7ef69567d5c800424a11f8337b8b228bad02524c9fb3c1f340" - url: "https://pub.dev" - source: hosted - version: "2.1.2" fake_async: dependency: transitive description: name: fake_async - sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44" + sha256: "511392330127add0b769b75a987850d136345d9227c6b94c96a04cf4a391bf78" url: "https://pub.dev" source: hosted - version: "1.3.3" + version: "1.3.1" ffi: dependency: transitive description: name: ffi - sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45" + sha256: "16ed7b077ef01ad6170a3d0c57caa4a112a38d7a2ed5602e0aca9ca6f3d98da6" url: "https://pub.dev" source: hosted - version: "2.2.0" - file: - dependency: transitive - description: - name: file - sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4 - url: "https://pub.dev" - source: hosted - version: "7.0.1" + version: "2.1.3" firebase_core: dependency: "direct main" description: @@ -265,14 +129,6 @@ packages: url: "https://pub.dev" source: hosted version: "3.10.10" - fixnum: - dependency: transitive - description: - name: fixnum - sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be - url: "https://pub.dev" - source: hosted - version: "1.1.1" flutter: dependency: "direct main" description: flutter @@ -368,110 +224,30 @@ packages: description: flutter source: sdk version: "0.0.0" - glob: - dependency: transitive - description: - name: glob - sha256: c3f1ee72c96f8f78935e18aa8cecced9ab132419e8625dc187e1c2408efc20de - url: "https://pub.dev" - source: hosted - version: "2.1.3" - graphs: - dependency: transitive - description: - name: graphs - sha256: "741bbf84165310a68ff28fe9e727332eef1407342fca52759cb21ad8177bb8d0" - url: "https://pub.dev" - source: hosted - version: "2.3.2" - hooks: - dependency: transitive - description: - name: hooks - sha256: e79ed1e8e1929bc6ecb6ec85f0cb519c887aa5b423705ded0d0f2d9226def388 - url: "https://pub.dev" - source: hosted - version: "1.0.2" - http_multi_server: - dependency: transitive - description: - name: http_multi_server - sha256: aa6199f908078bb1c5efb8d8638d4ae191aac11b311132c3ef48ce352fb52ef8 - url: "https://pub.dev" - source: hosted - version: "3.2.2" - http_parser: - dependency: transitive - description: - name: http_parser - sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571" - url: "https://pub.dev" - source: hosted - version: "4.1.2" - io: - dependency: transitive - description: - name: io - sha256: dfd5a80599cf0165756e3181807ed3e77daf6dd4137caaad72d0b7931597650b - url: "https://pub.dev" - source: hosted - version: "1.0.5" - js: - dependency: transitive - description: - name: js - sha256: "53385261521cc4a0c4658fd0ad07a7d14591cf8fc33abbceae306ddb974888dc" - url: "https://pub.dev" - source: hosted - version: "0.7.2" - js_notifications: - dependency: transitive - description: - name: js_notifications - sha256: "980280649b29d618669866bdbf99e4a813009033101a434652d231eaf976c975" - url: "https://pub.dev" - source: hosted - version: "0.0.5" - json_annotation: - dependency: "direct main" - description: - name: json_annotation - sha256: cb09e7dac6210041fad964ed7fbee004f14258b4eca4040f72d1234062ace4c8 - url: "https://pub.dev" - source: hosted - version: "4.11.0" - json_serializable: - dependency: "direct dev" - description: - name: json_serializable - sha256: "44729f5c45748e6748f6b9a57ab8f7e4336edc8ae41fc295070e3814e616a6c0" - url: "https://pub.dev" - source: hosted - version: "6.13.0" leak_tracker: dependency: transitive description: name: leak_tracker - sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de" + sha256: "7bb2830ebd849694d1ec25bf1f44582d6ac531a57a365a803a6034ff751d2d06" url: "https://pub.dev" source: hosted - version: "11.0.2" + version: "10.0.7" leak_tracker_flutter_testing: dependency: transitive description: name: leak_tracker_flutter_testing - sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1" + sha256: "9491a714cca3667b60b5c420da8217e6de0d1ba7a5ec322fab01758f6998f379" url: "https://pub.dev" source: hosted - version: "3.0.10" + version: "3.0.8" leak_tracker_testing: dependency: transitive description: name: leak_tracker_testing - sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1" + sha256: "6ba465d5d76e67ddf503e1161d1f4a6bc42306f9d66ca1e8f079a47290fb06d3" url: "https://pub.dev" source: hosted - version: "3.0.2" + version: "3.0.1" lints: dependency: transitive description: @@ -480,86 +256,38 @@ packages: url: "https://pub.dev" source: hosted version: "4.0.0" - logging: - dependency: transitive - description: - name: logging - sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61 - url: "https://pub.dev" - source: hosted - version: "1.3.0" matcher: dependency: transitive description: name: matcher - sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 + sha256: d2323aa2060500f906aa31a895b4030b6da3ebdcc5619d14ce1aada65cd161cb url: "https://pub.dev" source: hosted - version: "0.12.19" + version: "0.12.16+1" material_color_utilities: dependency: transitive description: name: material_color_utilities - sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" + sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec url: "https://pub.dev" source: hosted - version: "0.13.0" + version: "0.11.1" meta: dependency: transitive description: name: meta - sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" + sha256: bdb68674043280c3428e9ec998512fb681678676b3c54e773629ffe74419f8c7 url: "https://pub.dev" source: hosted - version: "1.17.0" - mime: - dependency: transitive - description: - name: mime - sha256: "41a20518f0cb1256669420fdba0cd90d21561e560ac240f26ef8322e45bb7ed6" - url: "https://pub.dev" - source: hosted - version: "2.0.0" - native_toolchain_c: - dependency: transitive - description: - name: native_toolchain_c - sha256: "92b2ca62c8bd2b8d2f267cdfccf9bfbdb7322f778f8f91b3ce5b5cda23a3899f" - url: "https://pub.dev" - source: hosted - version: "0.17.5" - nested: - dependency: transitive - description: - name: nested - sha256: "03bac4c528c64c95c722ec99280375a6f2fc708eec17c7b3f07253b626cd2a20" - url: "https://pub.dev" - source: hosted - version: "1.0.0" - objective_c: - dependency: transitive - description: - name: objective_c - sha256: "100a1c87616ab6ed41ec263b083c0ef3261ee6cd1dc3b0f35f8ddfa4f996fe52" - url: "https://pub.dev" - source: hosted - version: "9.3.0" - package_config: - dependency: transitive - description: - name: package_config - sha256: f096c55ebb7deb7e384101542bfba8c52696c1b56fca2eb62827989ef2353bbc - url: "https://pub.dev" - source: hosted - version: "2.2.0" + version: "1.15.0" path: dependency: transitive description: name: path - sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" + sha256: "087ce49c3f0dc39180befefc60fdb4acd8f8620e5682fe2476afd0b3688bb4af" url: "https://pub.dev" source: hosted - version: "1.9.1" + version: "1.9.0" path_provider: dependency: transitive description: @@ -572,18 +300,18 @@ packages: dependency: transitive description: name: path_provider_android - sha256: f2c65e21139ce2c3dad46922be8272bb5963516045659e71bb16e151c93b580e + sha256: d0d310befe2c8ab9e7f393288ccbb11b60c019c6b5afc21973eeee4dda2b35e9 url: "https://pub.dev" source: hosted - version: "2.2.22" + version: "2.2.17" path_provider_foundation: dependency: transitive description: name: path_provider_foundation - sha256: "2a376b7d6392d80cd3705782d2caa734ca4727776db0b6ec36ef3f1855197699" + sha256: "4843174df4d288f5e29185bd6e72a6fbdf5a4a4602717eed565497429f179942" url: "https://pub.dev" source: hosted - version: "2.6.0" + version: "2.4.1" path_provider_linux: dependency: transitive description: @@ -612,10 +340,10 @@ packages: dependency: transitive description: name: petitparser - sha256: "91bd59303e9f769f108f8df05e371341b15d59e995e6806aefab827b58336675" + sha256: c15605cd28af66339f8eb6fbe0e541bfe2d1b72d5825efc6598f3e0a31b9ad27 url: "https://pub.dev" source: hosted - version: "7.0.2" + version: "6.0.2" platform: dependency: transitive description: @@ -632,139 +360,59 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.8" - pool: - dependency: transitive - description: - name: pool - sha256: "978783255c543aa3586a1b3c21f6e9d720eb315376a915872c61ef8b5c20177d" - url: "https://pub.dev" - source: hosted - version: "1.5.2" - provider: - dependency: "direct main" - description: - name: provider - sha256: "4e82183fa20e5ca25703ead7e05de9e4cceed1fbd1eadc1ac3cb6f565a09f272" - url: "https://pub.dev" - source: hosted - version: "6.1.5+1" - pub_semver: - dependency: transitive - description: - name: pub_semver - sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585" - url: "https://pub.dev" - source: hosted - version: "2.2.0" - pubspec_parse: - dependency: transitive - description: - name: pubspec_parse - sha256: "0560ba233314abbed0a48a2956f7f022cce7c3e1e73df540277da7544cad4082" - url: "https://pub.dev" - source: hosted - version: "1.5.0" - shelf: - dependency: transitive - description: - name: shelf - sha256: e7dd780a7ffb623c57850b33f43309312fc863fb6aa3d276a754bb299839ef12 - url: "https://pub.dev" - source: hosted - version: "1.4.2" - shelf_web_socket: - dependency: transitive - description: - name: shelf_web_socket - sha256: "3632775c8e90d6c9712f883e633716432a27758216dfb61bd86a8321c0580925" - url: "https://pub.dev" - source: hosted - version: "3.0.0" - simple_print: - dependency: transitive - description: - name: simple_print - sha256: "49b6796fb93b557bbba4eca687b8521d3d20ffee47d74d8a0857f6ee0727042b" - url: "https://pub.dev" - source: hosted - version: "0.0.1+2" sky_engine: dependency: transitive description: flutter source: sdk version: "0.0.0" - source_gen: - dependency: transitive - description: - name: source_gen - sha256: "1d562a3c1f713904ebbed50d2760217fd8a51ca170ac4b05b0db490699dbac17" - url: "https://pub.dev" - source: hosted - version: "4.2.0" - source_helper: - dependency: transitive - description: - name: source_helper - sha256: "4a85e90b50694e652075cbe4575665539d253e6ec10e46e76b45368ab5e3caae" - url: "https://pub.dev" - source: hosted - version: "1.3.10" source_span: dependency: transitive description: name: source_span - sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab" + sha256: "53e943d4206a5e30df338fd4c6e7a077e02254531b138a15aec3bd143c1a8b3c" url: "https://pub.dev" source: hosted - version: "1.10.2" + version: "1.10.0" stack_trace: dependency: transitive description: name: stack_trace - sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1" + sha256: "9f47fd3630d76be3ab26f0ee06d213679aa425996925ff3feffdec504931c377" url: "https://pub.dev" source: hosted - version: "1.12.1" + version: "1.12.0" stream_channel: dependency: transitive description: name: stream_channel - sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d" + sha256: ba2aa5d8cc609d96bbb2899c28934f9e1af5cddbd60a827822ea467161eb54e7 url: "https://pub.dev" source: hosted - version: "2.1.4" - stream_transform: - dependency: transitive - description: - name: stream_transform - sha256: ad47125e588cfd37a9a7f86c7d6356dde8dfe89d071d293f80ca9e9273a33871 - url: "https://pub.dev" - source: hosted - version: "2.1.1" + version: "2.1.2" string_scanner: dependency: transitive description: name: string_scanner - sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43" + sha256: "688af5ed3402a4bde5b3a6c15fd768dbf2621a614950b17f04626c431ab3c4c3" url: "https://pub.dev" source: hosted - version: "1.4.1" + version: "1.3.0" term_glyph: dependency: transitive description: name: term_glyph - sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e" + sha256: a29248a84fbb7c79282b40b8c72a1209db169a2e0542bce341da992fe1bc7e84 url: "https://pub.dev" source: hosted - version: "1.2.2" + version: "1.2.1" test_api: dependency: transitive description: name: test_api - sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a" + sha256: "664d3a9a64782fcdeb83ce9c6b39e78fd2971d4e37827b9b06c3aa1edc5e760c" url: "https://pub.dev" source: hosted - version: "0.7.10" + version: "0.7.3" timezone: dependency: transitive description: @@ -773,54 +421,22 @@ packages: url: "https://pub.dev" source: hosted version: "0.9.4" - twilio_voice: - dependency: "direct main" - description: - name: twilio_voice - sha256: "010ac416dc8bcc842486407aec2e6f97fd5bb34b521c04fd4a4a5710f9ec045b" - url: "https://pub.dev" - source: hosted - version: "0.3.2+2" - typed_data: - dependency: transitive - description: - name: typed_data - sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006 - url: "https://pub.dev" - source: hosted - version: "1.4.0" - uuid: - dependency: transitive - description: - name: uuid - sha256: "1fef9e8e11e2991bb773070d4656b7bd5d850967a2456cfc83cf47925ba79489" - url: "https://pub.dev" - source: hosted - version: "4.5.3" vector_math: dependency: transitive description: name: vector_math - sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b + sha256: "80b3257d1492ce4d091729e3a67a60407d227c27241d6927be0130c98e741803" url: "https://pub.dev" source: hosted - version: "2.2.0" + version: "2.1.4" vm_service: dependency: transitive description: name: vm_service - sha256: "45caa6c5917fa127b5dbcfbd1fa60b14e583afdc08bfc96dda38886ca252eb60" + sha256: f6be3ed8bd01289b34d679c2b62226f63c0e69f9fd2e50a6b3c1c729a961041b url: "https://pub.dev" source: hosted - version: "15.0.2" - watcher: - dependency: transitive - description: - name: watcher - sha256: "1398c9f081a753f9226febe8900fce8f7d0a67163334e1c94a2438339d79d635" - url: "https://pub.dev" - source: hosted - version: "1.2.1" + version: "14.3.0" web: dependency: transitive description: @@ -829,38 +445,46 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.1" - web_callkit: - dependency: transitive + webview_flutter: + dependency: "direct main" description: - name: web_callkit - sha256: ca05b0fd79366ea072c1ea4982c8a7880ad219e4d1cc74a3a541b010533febee + name: webview_flutter + sha256: c3e4fe614b1c814950ad07186007eff2f2e5dd2935eba7b9a9a1af8e5885f1ba url: "https://pub.dev" source: hosted - version: "0.0.4+1" - web_socket: - dependency: transitive + version: "4.13.0" + webview_flutter_android: + dependency: "direct main" description: - name: web_socket - sha256: "34d64019aa8e36bf9842ac014bb5d2f5586ca73df5e4d9bf5c936975cae6982c" + name: webview_flutter_android + sha256: "0a42444056b24ed832bdf3442d65c5194f6416f7e782152384944053c2ecc9a3" url: "https://pub.dev" source: hosted - version: "1.0.1" - web_socket_channel: + version: "4.10.0" + webview_flutter_platform_interface: dependency: transitive description: - name: web_socket_channel - sha256: d645757fb0f4773d602444000a8131ff5d48c9e47adfe9772652dd1a4f2d45c8 + name: webview_flutter_platform_interface + sha256: "63d26ee3aca7256a83ccb576a50272edd7cfc80573a4305caa98985feb493ee0" url: "https://pub.dev" source: hosted - version: "3.0.3" + version: "2.14.0" + webview_flutter_wkwebview: + dependency: transitive + description: + name: webview_flutter_wkwebview + sha256: fb46db8216131a3e55bcf44040ca808423539bc6732e7ed34fb6d8044e3d512f + url: "https://pub.dev" + source: hosted + version: "3.23.0" win32: dependency: transitive description: name: win32 - sha256: d7cb55e04cd34096cd3a79b3330245f54cb96a370a1c27adb3c84b917de8b08e + sha256: daf97c9d80197ed7b619040e86c8ab9a9dad285e7671ee7390f9180cc828a51e url: "https://pub.dev" source: hosted - version: "5.15.0" + version: "5.10.1" xdg_directories: dependency: transitive description: @@ -873,18 +497,10 @@ packages: dependency: transitive description: name: xml - sha256: "971043b3a0d3da28727e40ed3e0b5d18b742fa5a68665cca88e74b7876d5e025" + sha256: b015a8ad1c488f66851d762d3090a21c600e479dc75e68328c52774040cf9226 url: "https://pub.dev" source: hosted - version: "6.6.1" - yaml: - dependency: transitive - description: - name: yaml - sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce - url: "https://pub.dev" - source: hosted - version: "3.1.3" + version: "6.5.0" sdks: - dart: ">=3.10.3 <4.0.0" - flutter: ">=3.38.4" + dart: ">=3.6.0 <4.0.0" + flutter: ">=3.27.0" diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml index ff6c1d6..76a8471 100644 --- a/mobile/pubspec.yaml +++ b/mobile/pubspec.yaml @@ -1,7 +1,7 @@ name: twp_softphone -description: TWP Softphone - VoIP client for Twilio WordPress Plugin +description: TWP Softphone - WebView client for Twilio WordPress Plugin publish_to: 'none' -version: 1.0.0+1 +version: 2.0.0+6 environment: sdk: ^3.5.0 @@ -9,21 +9,17 @@ environment: dependencies: flutter: sdk: flutter - twilio_voice: ^0.3.0 firebase_core: ^3.0.0 firebase_messaging: ^15.0.0 - dio: ^5.4.0 flutter_secure_storage: ^10.0.0 - provider: ^6.1.0 flutter_local_notifications: ^17.0.0 - json_annotation: ^4.8.0 + webview_flutter: ^4.10.0 + webview_flutter_android: ^4.3.0 dev_dependencies: flutter_test: sdk: flutter flutter_lints: ^4.0.0 - build_runner: ^2.4.0 - json_serializable: ^6.7.0 flutter: uses-material-design: true diff --git a/mobile/test/webview_app_test.dart b/mobile/test/webview_app_test.dart new file mode 100644 index 0000000..abfc6e4 --- /dev/null +++ b/mobile/test/webview_app_test.dart @@ -0,0 +1,40 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:twp_softphone/app.dart'; +import 'package:twp_softphone/screens/login_screen.dart'; + +void main() { + group('TwpSoftphoneApp', () { + testWidgets('shows loading indicator on startup', (tester) async { + await tester.pumpWidget(const TwpSoftphoneApp()); + expect(find.byType(TwpSoftphoneApp), findsOneWidget); + expect(find.bySubtype(), findsOneWidget); + }); + }); + + group('LoginScreen', () { + testWidgets('renders server URL field and connect button', (tester) async { + await tester.pumpWidget( + MaterialApp( + home: LoginScreen(onLoginSuccess: (_) {}), + ), + ); + await tester.pump(const Duration(milliseconds: 100)); + expect(find.text('Server URL'), findsOneWidget); + expect(find.text('Connect'), findsOneWidget); + expect(find.text('TWP Softphone'), findsOneWidget); + }); + + testWidgets('validates empty server URL on submit', (tester) async { + await tester.pumpWidget( + MaterialApp( + home: LoginScreen(onLoginSuccess: (_) {}), + ), + ); + await tester.pump(const Duration(milliseconds: 100)); + await tester.tap(find.text('Connect')); + await tester.pump(); + expect(find.text('Required'), findsOneWidget); + }); + }); +} diff --git a/test-deploy.sh b/test-deploy.sh new file mode 100755 index 0000000..a4e9479 --- /dev/null +++ b/test-deploy.sh @@ -0,0 +1,113 @@ +#!/bin/bash +# Test harness for TWP WebView Softphone deployment +# Run after deploying PHP files and flushing rewrite rules + +SERVER="https://phone.cloud-hosting.io" +PASS=0 +FAIL=0 + +check() { + local desc="$1" + local result="$2" + if [ "$result" = "0" ]; then + echo " PASS: $desc" + PASS=$((PASS + 1)) + else + echo " FAIL: $desc" + FAIL=$((FAIL + 1)) + fi +} + +echo "=== TWP WebView Softphone - Deployment Test Harness ===" +echo "" + +# 1. Test standalone phone page exists (should redirect to login for unauthenticated) +echo "[1] Standalone Phone Page (/twp-phone/)" +RESPONSE=$(curl -s -o /dev/null -w "%{http_code}:%{redirect_url}" -L --max-redirs 0 "$SERVER/twp-phone/" 2>/dev/null) +HTTP_CODE=$(echo "$RESPONSE" | cut -d: -f1) +REDIRECT=$(echo "$RESPONSE" | cut -d: -f2-) +# Should redirect (302) to wp-login.php for unauthenticated users +if [ "$HTTP_CODE" = "302" ] && echo "$REDIRECT" | grep -q "wp-login"; then + check "Unauthenticated redirect to wp-login.php" 0 +else + check "Unauthenticated redirect to wp-login.php (got $HTTP_CODE, redirect: $REDIRECT)" 1 +fi + +# 2. Test that wp-login.php page loads +echo "" +echo "[2] WordPress Login Page" +LOGIN_RESPONSE=$(curl -s -o /dev/null -w "%{http_code}" "$SERVER/wp-login.php" 2>/dev/null) +check "wp-login.php returns 200" "$([ "$LOGIN_RESPONSE" = "200" ] && echo 0 || echo 1)" + +# 3. Test authenticated access (login and get cookies, then access /twp-phone/) +echo "" +echo "[3] Authenticated Access" +# Try to log in and get session cookies +COOKIE_JAR="/tmp/twp-test-cookies.txt" +rm -f "$COOKIE_JAR" + +# Login - use the test credentials if available +LOGIN_RESULT=$(curl -s -o /dev/null -w "%{http_code}" \ + -c "$COOKIE_JAR" \ + -d "log=admin&pwd=admin&rememberme=forever&redirect_to=$SERVER/twp-phone/&wp-submit=Log+In" \ + "$SERVER/wp-login.php" 2>/dev/null) + +if [ "$LOGIN_RESULT" = "302" ]; then + # Follow redirect to /twp-phone/ + PAGE_RESULT=$(curl -s -b "$COOKIE_JAR" -w "%{http_code}" -o /tmp/twp-phone-page.html "$SERVER/twp-phone/" 2>/dev/null) + check "Authenticated /twp-phone/ returns 200" "$([ "$PAGE_RESULT" = "200" ] && echo 0 || echo 1)" + + if [ "$PAGE_RESULT" = "200" ]; then + # Check page content + check "Page contains Twilio SDK" "$(grep -q 'twilio.min.js' /tmp/twp-phone-page.html && echo 0 || echo 1)" + check "Page contains dialpad" "$(grep -q 'dialpad' /tmp/twp-phone-page.html && echo 0 || echo 1)" + check "Page contains ajaxurl" "$(grep -q 'ajaxurl' /tmp/twp-phone-page.html && echo 0 || echo 1)" + check "Page contains TwpMobile bridge" "$(grep -q 'TwpMobile' /tmp/twp-phone-page.html && echo 0 || echo 1)" + check "Page contains twpNonce" "$(grep -q 'twpNonce' /tmp/twp-phone-page.html && echo 0 || echo 1)" + check "Page has mobile viewport" "$(grep -q 'viewport-fit=cover' /tmp/twp-phone-page.html && echo 0 || echo 1)" + check "Page has dark mode CSS" "$(grep -q 'prefers-color-scheme' /tmp/twp-phone-page.html && echo 0 || echo 1)" + check "No WP admin bar" "$(grep -q 'wp-admin-bar' /tmp/twp-phone-page.html && echo 1 || echo 0)" + check "Page contains phone-number-input" "$(grep -q 'phone-number-input' /tmp/twp-phone-page.html && echo 0 || echo 1)" + check "Page contains caller-id-select" "$(grep -q 'caller-id-select' /tmp/twp-phone-page.html && echo 0 || echo 1)" + check "Page contains hold/transfer buttons" "$(grep -q 'hold-btn' /tmp/twp-phone-page.html && echo 0 || echo 1)" + check "Page contains queue tab" "$(grep -q 'queue' /tmp/twp-phone-page.html && echo 0 || echo 1)" + fi +else + echo " SKIP: Could not log in (HTTP $LOGIN_RESULT) - manual auth testing required" +fi + +# 4. Test AJAX endpoint availability +echo "" +echo "[4] AJAX Endpoints" +if [ -f "$COOKIE_JAR" ] && [ "$LOGIN_RESULT" = "302" ]; then + # Test that admin-ajax.php is accessible with cookies + AJAX_RESULT=$(curl -s -b "$COOKIE_JAR" -o /dev/null -w "%{http_code}" \ + -d "action=twp_generate_capability_token&nonce=test" \ + "$SERVER/wp-admin/admin-ajax.php" 2>/dev/null) + # Should return 200 (even if nonce fails, it means AJAX is working) + check "admin-ajax.php accessible" "$([ "$AJAX_RESULT" = "200" ] || [ "$AJAX_RESULT" = "400" ] || [ "$AJAX_RESULT" = "403" ] && echo 0 || echo 1)" +fi + +# 5. Test 7-day cookie expiration +echo "" +echo "[5] Session Cookie" +if [ -f "$COOKIE_JAR" ]; then + # Check if cookies have extended expiry + COOKIE_EXISTS=$(grep -c "wordpress_logged_in" "$COOKIE_JAR" 2>/dev/null) + check "Login cookies set" "$([ "$COOKIE_EXISTS" -gt 0 ] && echo 0 || echo 1)" +fi + +# Cleanup +rm -f "$COOKIE_JAR" /tmp/twp-phone-page.html + +echo "" +echo "=== Results: $PASS passed, $FAIL failed ===" +echo "" + +if [ "$FAIL" -gt 0 ]; then + echo "Some tests failed. Review output above." + exit 1 +else + echo "All tests passed!" + exit 0 +fi