Files
enaklo-flutter/lib/presentation/pages/webview/webview_page.dart
T
efrilmandClaude Opus 5.5 f5e3075205 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>
2026-10-01 01:43:22 +07:00

284 lines
9.1 KiB
Dart

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