import 'dart:convert'; import 'dart:developer'; import 'package:auto_route/auto_route.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:shared_preferences/shared_preferences.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 '../../../application/wallet/wallet_loader/wallet_loader_bloc.dart'; import '../../../common/constant/app_constant.dart'; import '../../../common/constant/local_storage_key.dart'; import '../../../common/theme/theme.dart'; import '../../../env.dart'; import '../../../injection.dart'; import '../../components/button/button.dart'; import '../../router/app_router.gr.dart'; import '../wallet/widgets/wallet_confirm_dialog.dart'; /// EnakGame di webview layar penuh: web game.enaklo.com yang menampilkan /// daftar game, memulai session, memotong EnakCoin, dan menampilkan hadiah. /// Aplikasi hanya memberi token lewat bridge (§8.3) dan tidak pernah /// menaruhnya di URL. @RoutePage() class EnakGamePage extends StatefulWidget { const EnakGamePage({super.key}); @override State createState() => _EnakGamePageState(); } class _EnakGamePageState extends State { /// Game → aplikasi: JavaScript channel dengan pesan berupa JSON string. static const String _hostChannel = 'EnakGameHost'; static const _nativeChannel = MethodChannel( 'com.apskel.enaklocustomer/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; bool _isRelogging = false; bool _isExiting = false; String? get _token => getIt().getString(LocalStorageKey.token); @override void initState() { super.initState(); _controller = WebViewController(); _load(); } @override void dispose() { _progress.dispose(); super.dispose(); } Future _load() async { try { await _controller.setJavaScriptMode(JavaScriptMode.unrestricted); await _controller.setBackgroundColor(AppConstant.gameBackgroundColor); await _controller.setNavigationDelegate( NavigationDelegate( onProgress: (progress) => _progress.value = progress, onPageStarted: (_) { if (mounted && _hasError) setState(() => _hasError = false); }, onWebResourceError: (error) { // Error sub-resource (gambar, script pihak ketiga) diabaikan. if (error.isForMainFrame == false) return; if (mounted) setState(() => _hasError = true); }, onNavigationRequest: _onNavigationRequest, ), ); // Bridge dipasang sebelum halaman dimuat. await _controller.addJavaScriptChannel( _hostChannel, onMessageReceived: _onMessage, ); final platform = _controller.platform; if (platform is AndroidWebViewController) { await _enableOffscreenPreRaster(platform); } // Swipe dari tepi kiri di iOS untuk mundur di riwayat web. if (platform is WebKitWebViewController) { await platform.setAllowsBackForwardNavigationGestures(true); } await _controller.loadRequest(Uri.parse(AppConstant.gameUrl)); } catch (e, s) { log('load', name: 'EnakGamePage', error: e, stackTrace: s); if (mounted) setState(() => _hasError = true); } } /// Daftar game di web di-scroll; tanpa pre-raster, Android WebView /// menggambar ulang bagian yang kembali masuk layar. 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); } } void _onMessage(JavaScriptMessage message) { final Object? decoded; try { decoded = jsonDecode(message.message); } on FormatException { return; } if (decoded is! Map) return; switch (decoded['type']) { case 'ready': _sendInit(); case 'token_expired': _relogin(); case 'balance_changed': final coinBalance = decoded['coin_balance']; if (coinBalance is num) { context.read().add( WalletLoaderEvent.coinBalanceUpdated(coinBalance.toInt()), ); } case 'close': if (!_isRelogging) _exit(); } // Tipe lain diabaikan. } /// Tanpa `game_id`: game dipilih customer di web, bukan di aplikasi. void _sendInit() { final token = _token; if (token == null) { _relogin(); return; } _send({ 'type': 'init', 'api_base_url': '${getIt().baseUrl}/api/v1', 'token': token, }); } /// Aplikasi → game: `window.enakGame.receive('')`. Future _send(Map message) async { // Encode dua kali: JSON pesan dijadikan string literal JavaScript. final argument = jsonEncode(jsonEncode(message)); try { await _controller.runJavaScript( 'window.enakGame && window.enakGame.receive($argument);', ); } catch (e) { // Pesan berisi token: jangan ikut dicatat. log('send ${message['type']} failed', name: 'EnakGamePage'); } } /// Belum ada refresh token: customer login ulang, lalu token baru dikirim /// ke game. Bila batal, game ditutup. Future _relogin() async { if (_isRelogging || _isExiting) return; _isRelogging = true; final proceed = await showWalletConfirmDialog( context, title: 'Sesi berakhir', message: 'Masuk lagi untuk melanjutkan permainan.', confirmLabel: 'Masuk', ); final loggedIn = proceed && mounted && await context.router.push(LoginRoute(returnAfterLogin: true)) == true; _isRelogging = false; if (!mounted) return; final token = _token; if (loggedIn && token != null) { _send({'type': 'token', 'token': token}); } else { _exit(); } } /// Menutup game lalu memuat ulang saldo, karena EnakCoin bisa berubah /// selama main. void _exit() { if (_isExiting) return; _isExiting = true; context.read().add(const WalletLoaderEvent.fetched()); // Dialog yang masih terbuka (mis. konfirmasi keluar) ikut ditutup. final navigator = Navigator.of(context); final route = ModalRoute.of(context); navigator.popUntil((r) => r == route); navigator.pop(); } /// Tombol back Android mundur di riwayat web dulu. Di halaman pertama, /// game tidak langsung ditutup: minta konfirmasi. Future _onPopInvoked(bool didPop, Object? result) async { if (didPop || _isExiting) return; if (_hasError) { _exit(); return; } if (await _controller.canGoBack()) { await _controller.goBack(); return; } if (!mounted) return; final leave = await showWalletConfirmDialog( context, title: 'Keluar dari game?', message: 'EnakCoin yang sudah dipakai untuk main tidak kembali.', confirmLabel: 'Keluar', ); if (leave && mounted) _exit(); } /// 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; } void _reload() { setState(() => _hasError = false); _controller.reload(); } @override Widget build(BuildContext context) { return AnnotatedRegion( value: (_hasError ? SystemUiOverlayStyle.dark : SystemUiOverlayStyle.light) .copyWith(statusBarColor: Colors.transparent), child: PopScope( canPop: false, onPopInvokedWithResult: _onPopInvoked, child: Scaffold( backgroundColor: AppConstant.gameBackgroundColor, // WebView tetap terpasang saat error, supaya "Coba lagi" tidak // membuat ulang native view-nya. body: Stack( children: [ _buildWebView(), Positioned( top: MediaQuery.paddingOf(context).top, left: 0, right: 0, child: _buildProgress(), ), if (_hasError) Positioned.fill(child: _buildError()), ], ), ), ), ); } /// Android memakai Hybrid Composition: WebView digambar langsung oleh /// Android, jauh lebih lancar untuk game yang terus beranimasi. Widget _buildWebView() { final platform = _controller.platform; return WebViewWidget.fromPlatformCreationParams( params: platform is AndroidWebViewController ? AndroidWebViewWidgetCreationParams( controller: platform, displayWithHybridComposition: true, ) : PlatformWebViewWidgetCreationParams(controller: platform), ); } 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), ); } 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( 'Game 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, ), ], ), ), ), Positioned( top: 4, left: 4, child: IconButton( icon: const Icon(Icons.close), tooltip: 'Tutup', onPressed: _exit, ), ), ], ), ), ); } }