284 lines
9.1 KiB
Dart
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.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;
|
|
|
|
/// 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(),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|