import 'dart:developer'; import 'package:auto_route/auto_route.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:url_launcher/url_launcher.dart'; import 'package:webview_flutter/webview_flutter.dart'; import 'package:webview_flutter_android/webview_flutter_android.dart'; import 'package:webview_flutter_wkwebview/webview_flutter_wkwebview.dart'; import '../../../common/theme/theme.dart'; import '../../components/button/button.dart'; /// Membuka halaman web di dalam aplikasi, mis. EnakGame. @RoutePage() class WebViewPage extends StatefulWidget { const WebViewPage({ super.key, required this.title, required this.url, this.fullscreen = false, this.backgroundColor, }); final String title; final String url; /// Tanpa AppBar: web digambar sampai ke belakang status bar. Untuk web yang /// punya header sendiri dan memakai `viewport-fit=cover`. Kalau WebView /// diletakkan di bawah AppBar, Android WebView tetap melaporkan /// `env(safe-area-inset-top)` setinggi status bar, sehingga header web /// turun dan menyisakan celah kosong. final bool fullscreen; /// Samakan dengan warna latar web, supaya tidak berkedip saat dimuat dan /// warna ikon status bar pas. Bawaan putih. final Color? backgroundColor; @override State createState() => _WebViewPageState(); } class _WebViewPageState extends State { static const _nativeChannel = MethodChannel('com.appskel.enaklo/webview'); late final WebViewController _controller; /// Lewat notifier, bukan setState, supaya WebView tidak ikut di-rebuild /// setiap progres berubah. final ValueNotifier _progress = ValueNotifier(0); bool _hasError = false; /// Selama web masih punya riwayat, back (tombol Android / swipe iOS) /// mundur di web. Di halaman pertama, back langsung menutup halaman ini. bool _canGoBack = false; Color get _backgroundColor => widget.backgroundColor ?? AppColor.white; @override void initState() { super.initState(); _controller = WebViewController() ..setJavaScriptMode(JavaScriptMode.unrestricted) ..setBackgroundColor(_backgroundColor) ..setNavigationDelegate( NavigationDelegate( onProgress: (progress) => _progress.value = progress, onPageStarted: (_) { if (mounted && _hasError) setState(() => _hasError = false); }, onPageFinished: (_) => _syncCanGoBack(), onUrlChange: (_) => _syncCanGoBack(), onWebResourceError: (error) { // Error sub-resource (gambar, script pihak ketiga) diabaikan. if (error.isForMainFrame == false) return; if (mounted) setState(() => _hasError = true); }, onNavigationRequest: _onNavigationRequest, ), ) ..loadRequest(Uri.parse(widget.url)); final platform = _controller.platform; if (platform is AndroidWebViewController) { _enableOffscreenPreRaster(platform); } // Swipe dari tepi kiri di iOS untuk mundur di riwayat web. if (platform is WebKitWebViewController) { platform.setAllowsBackForwardNavigationGestures(true); } } /// Bawaannya Android WebView hanya menyimpan tile yang sedang terlihat: /// bagian yang di-scroll keluar layar dibuang, lalu digambar ulang saat /// di-scroll kembali. Pre-raster membuat tile di sekitar layar ikut /// disimpan (memakai memori lebih besar). Lihat `MainActivity.kt`. Future _enableOffscreenPreRaster( AndroidWebViewController platform, ) async { try { await _nativeChannel.invokeMethod('enableOffscreenPreRaster', { 'webViewId': platform.webViewIdentifier, }); } on PlatformException catch (e, s) { log('enableOffscreenPreRaster', error: e, stackTrace: s); } } Future _syncCanGoBack() async { final canGoBack = await _controller.canGoBack(); if (mounted && canGoBack != _canGoBack) { setState(() => _canGoBack = canGoBack); } } /// Skema selain http/https (tel:, mailto:, whatsapp:, intent:) diserahkan /// ke aplikasi luar karena WebView tidak bisa membukanya. Future _onNavigationRequest( NavigationRequest request, ) async { final uri = Uri.tryParse(request.url); if (uri == null || uri.scheme == 'http' || uri.scheme == 'https') { return NavigationDecision.navigate; } await launchUrl(uri, mode: LaunchMode.externalApplication); return NavigationDecision.prevent; } Future _onPopInvoked(bool didPop, Object? result) async { if (didPop) return; if (await _controller.canGoBack()) { await _controller.goBack(); } else if (mounted) { context.router.pop(); } } void _reload() { setState(() => _hasError = false); _controller.reload(); } @override void dispose() { _progress.dispose(); super.dispose(); } @override Widget build(BuildContext context) { final surface = _hasError ? AppColor.white : _backgroundColor; final overlayStyle = ThemeData.estimateBrightnessForColor(surface) == Brightness.dark ? SystemUiOverlayStyle.light : SystemUiOverlayStyle.dark; return AnnotatedRegion( value: overlayStyle.copyWith(statusBarColor: Colors.transparent), child: PopScope( canPop: !_canGoBack, onPopInvokedWithResult: _onPopInvoked, child: Scaffold( backgroundColor: _backgroundColor, appBar: widget.fullscreen ? null : _buildAppBar(), // WebView tetap terpasang saat error, supaya "Coba lagi" tidak // membuat ulang native view-nya. body: Stack( children: [ _buildWebView(), if (widget.fullscreen) Positioned( top: MediaQuery.paddingOf(context).top, left: 0, right: 0, child: _buildProgress(), ), if (_hasError) Positioned.fill(child: _buildError()), ], ), ), ), ); } PreferredSizeWidget _buildAppBar() { return AppBar( title: Text(widget.title), actions: [ IconButton( icon: const Icon(Icons.refresh), tooltip: 'Muat ulang', onPressed: _reload, ), ], // Tinggi tetap: kalau bar ini muncul-hilang, WebView ikut di-resize // dan halamannya di-layout ulang. bottom: PreferredSize( preferredSize: const Size.fromHeight(2), child: _buildProgress(), ), ); } Widget _buildProgress() { return ValueListenableBuilder( valueListenable: _progress, builder: (context, progress, _) => progress < 100 && !_hasError ? LinearProgressIndicator( value: progress / 100, minHeight: 2, color: AppColor.primary, backgroundColor: AppColor.primaryWithOpacity(0.1), ) : const SizedBox(height: 2), ); } /// Android memakai Hybrid Composition: WebView digambar langsung oleh /// Android, bukan disalin ke texture Flutter setiap frame (mode default). /// Jauh lebih lancar untuk halaman yang terus beranimasi seperti EnakGame. Widget _buildWebView() { final platform = _controller.platform; return WebViewWidget.fromPlatformCreationParams( params: platform is AndroidWebViewController ? AndroidWebViewWidgetCreationParams( controller: platform, displayWithHybridComposition: true, ) : PlatformWebViewWidgetCreationParams(controller: platform), ); } Widget _buildError() { return ColoredBox( color: AppColor.white, child: SafeArea( child: Stack( children: [ Center( child: Padding( padding: const EdgeInsets.all(24), child: Column( mainAxisSize: MainAxisSize.min, children: [ const Icon( Icons.wifi_off, size: 56, color: AppColor.textSecondary, ), const SizedBox(height: 16), Text( 'Halaman tidak bisa dimuat. Periksa koneksi internet kamu.', style: AppStyle.md, textAlign: TextAlign.center, ), const SizedBox(height: 16), AppOutlineButton( title: 'Coba lagi', width: 160, onPressed: _reload, ), ], ), ), ), // Tanpa AppBar, ini satu-satunya tombol keluar yang terlihat. if (widget.fullscreen) Positioned( top: 4, left: 4, child: IconButton( icon: const Icon(Icons.close), tooltip: 'Tutup', onPressed: () => context.router.pop(), ), ), ], ), ), ); } }