Files
enaklo-flutter/lib/presentation/pages/enakgame/enakgame_page.dart
T
efrilm 38850b281f
Build & Deploy iOS to TestFlight / build-and-deploy (push) Canceled after 0s
feat: integrate enakgame and voucher
2026-10-10 02:33:16 +07:00

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