EnakPoint & EnakCoin wallet, PIN, outlet, order, community, reward
- Wallet EnakPoint & EnakCoin: beranda, riwayat, saldo kedaluwarsa, kode bayar, tukar EnakCoin, transfer - PIN (buat, ganti, lupa), outlet, riwayat order, game spin - Halaman community, webview, coming soon, dan ikon menu beranda baru - Redesign Reward Saya: kartu tiket, filter chip, detail kode + QR - Hapus customer point loader, menu, dan checkout lama - Base URL ke enaklo-pos-api.altru.id Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
5954510b93
commit
f5e3075205
@@ -0,0 +1,283 @@
|
||||
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<WebViewPage> createState() => _WebViewPageState();
|
||||
}
|
||||
|
||||
class _WebViewPageState extends State<WebViewPage> {
|
||||
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<int> _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<void> _enableOffscreenPreRaster(
|
||||
AndroidWebViewController platform,
|
||||
) async {
|
||||
try {
|
||||
await _nativeChannel.invokeMethod('enableOffscreenPreRaster', {
|
||||
'webViewId': platform.webViewIdentifier,
|
||||
});
|
||||
} on PlatformException catch (e, s) {
|
||||
log('enableOffscreenPreRaster', error: e, stackTrace: s);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _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<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;
|
||||
}
|
||||
|
||||
Future<void> _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<SystemUiOverlayStyle>(
|
||||
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<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),
|
||||
);
|
||||
}
|
||||
|
||||
/// 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(),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user