import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:webview_flutter/webview_flutter.dart'; import '../../../core/theme/halo_tokens.dart'; /// Embedded WebView host for the Xendit hosted checkout page. /// /// Why embedded (vs `LaunchMode.inAppBrowserView` / Custom Tab): /// 1. We get full control over `NavigationDelegate`, so we can short-circuit /// the `halobestie://` deeplink + the backend's `/payment/return/*` HTML /// pages without round-tripping through the system browser intent /// handler. /// 2. Pop result tells the parent (`WaitingPaymentScreen`) to trigger an /// immediate poll instead of waiting up to 3s for the next tick. /// /// Pop results: /// - `'success'` — URL matched `/payment/return/success` /// - `'failure'` — URL matched `/payment/return/failure` /// - `'deeplink'` — `halobestie://` scheme was followed (typically from the /// return page's button; the existing intent-filter in /// AndroidManifest would also handle it, but we intercept /// here so the WebView host pops cleanly) /// - `null` — user tapped the close (X) button manually /// /// iOS NOTE: when shipping iOS, the dev backend at `http://192.168.88.247:3000` /// will be blocked by ATS. Add an `NSAppTransportSecurity > NSAllowsArbitraryLoads` /// (or per-domain `NSExceptionDomains`) entry to `ios/Runner/Info.plist` before /// the iOS dev build will let the WebView load any /payment/return/* page from /// the dev backend. Production (`api.halobestie.com`) is HTTPS so no exception /// is needed there. Not touching Info.plist now — Android-only session. class XenditCheckoutScreen extends ConsumerStatefulWidget { final String invoiceUrl; final String paymentId; const XenditCheckoutScreen({ super.key, required this.invoiceUrl, required this.paymentId, }); @override ConsumerState createState() => _XenditCheckoutScreenState(); } class _XenditCheckoutScreenState extends ConsumerState { late final WebViewController _controller; int _progress = 0; // Pre-compiled regexes for the backend's return pages. The route is defined // in backend `routes/payment.return.js` as `GET /payment/return/:status` // where :status is 'success' or 'failure'. Match anywhere in the URL so we // don't have to care about query strings, host, or scheme. static final RegExp _successPattern = RegExp(r'/payment/return/success(\?|$|/)'); static final RegExp _failurePattern = RegExp(r'/payment/return/failure(\?|$|/)'); @override void initState() { super.initState(); _controller = WebViewController() ..setJavaScriptMode(JavaScriptMode.unrestricted) ..setBackgroundColor(HaloTokens.surface) ..setNavigationDelegate( NavigationDelegate( onProgress: (p) { if (!mounted) return; setState(() => _progress = p); }, onNavigationRequest: _handleNavigationRequest, onWebResourceError: (error) { // Log only — Xendit pages routinely emit non-fatal resource errors // (favicons, third-party trackers blocked by adblock-style rules, // etc). Surfacing those as snackbars would be noisy. if (kDebugMode) { debugPrint( '[XenditCheckoutScreen] WebResourceError ' 'code=${error.errorCode} type=${error.errorType} ' 'desc=${error.description} url=${error.url}', ); } }, ), ) ..loadRequest(Uri.parse(widget.invoiceUrl)); } NavigationDecision _handleNavigationRequest(NavigationRequest request) { final url = request.url; // 1. Deeplink scheme — pop with informational result. The Android intent // filter would also catch this from a system-browser context, but // inside a WebView the engine surfaces it here as a navigation, and // if we don't block it the WebView shows an `ERR_UNKNOWN_URL_SCHEME` // error page. if (url.startsWith('halobestie://')) { _popWith('deeplink'); return NavigationDecision.prevent; } // 2. Backend's success return page. if (_successPattern.hasMatch(url)) { _popWith('success'); return NavigationDecision.prevent; } // 3. Backend's failure return page. if (_failurePattern.hasMatch(url)) { _popWith('failure'); return NavigationDecision.prevent; } return NavigationDecision.navigate; } void _popWith(String result) { if (!mounted) return; Navigator.of(context).pop(result); } @override Widget build(BuildContext context) { return Scaffold( backgroundColor: HaloTokens.surface, appBar: AppBar( backgroundColor: HaloTokens.surface, elevation: 0, leading: IconButton( icon: const Icon(Icons.close, color: HaloTokens.brandDark), tooltip: 'Tutup', onPressed: () => Navigator.of(context).pop(), ), title: const Text( 'Pembayaran Xendit', style: TextStyle( fontFamily: HaloTokens.fontDisplay, fontSize: 16, fontWeight: FontWeight.w700, color: HaloTokens.brandDark, ), ), centerTitle: true, bottom: _progress < 100 ? PreferredSize( preferredSize: const Size.fromHeight(2), child: LinearProgressIndicator( value: _progress / 100.0, minHeight: 2, backgroundColor: HaloTokens.brandSofter, valueColor: const AlwaysStoppedAnimation( HaloTokens.brand, ), ), ) : null, ), body: WebViewWidget(controller: _controller), ); } }