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'), ), ], ), ), ); } }