369 lines
11 KiB
Dart
369 lines
11 KiB
Dart
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<EnakGamePage> createState() => _EnakGamePageState();
|
|
}
|
|
|
|
class _EnakGamePageState extends State<EnakGamePage> {
|
|
/// 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<int> _progress = ValueNotifier(0);
|
|
bool _hasError = false;
|
|
bool _isRelogging = false;
|
|
bool _isExiting = false;
|
|
|
|
String? get _token =>
|
|
getIt<SharedPreferences>().getString(LocalStorageKey.token);
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_controller = WebViewController();
|
|
_load();
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_progress.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
Future<void> _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<void> _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<WalletLoaderBloc>().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<Env>().baseUrl}/api/v1',
|
|
'token': token,
|
|
});
|
|
}
|
|
|
|
/// Aplikasi → game: `window.enakGame.receive('<json>')`.
|
|
Future<void> _send(Map<String, Object?> 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<void> _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<bool>(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<WalletLoaderBloc>().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<void> _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<NavigationDecision> _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<SystemUiOverlayStyle>(
|
|
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<int>(
|
|
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,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|