feat: integrate enakgame and voucher
Build & Deploy iOS to TestFlight / build-and-deploy (push) Canceled after 0s
Build & Deploy iOS to TestFlight / build-and-deploy (push) Canceled after 0s
This commit is contained in:
@@ -0,0 +1,368 @@
|
||||
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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user