124 lines
3.7 KiB
Dart
124 lines
3.7 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:go_router/go_router.dart';
|
|
import 'package:url_launcher/url_launcher.dart';
|
|
import 'package:webview_flutter/webview_flutter.dart';
|
|
|
|
/// Shared HTTPS WebView host. Mall-specific residency and attribution are
|
|
/// layered above this page so ordinary H5 links never receive mall state.
|
|
class AppWebPage extends StatefulWidget {
|
|
const AppWebPage({
|
|
super.key,
|
|
required this.initialUri,
|
|
this.hideTitle = false,
|
|
this.allowedHosts = const <String>{},
|
|
});
|
|
|
|
final Uri initialUri;
|
|
final bool hideTitle;
|
|
final Set<String> allowedHosts;
|
|
|
|
@override
|
|
State<AppWebPage> createState() => _AppWebPageState();
|
|
}
|
|
|
|
class _AppWebPageState extends State<AppWebPage> {
|
|
late final WebViewController _controller;
|
|
var _progress = 0;
|
|
String? _error;
|
|
String _title = '';
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_controller = WebViewController()
|
|
..setJavaScriptMode(JavaScriptMode.unrestricted)
|
|
..addJavaScriptChannel(
|
|
'JinzhiBridge',
|
|
onMessageReceived: (message) {
|
|
if (message.message == 'close' && mounted) context.pop();
|
|
},
|
|
)
|
|
..setNavigationDelegate(
|
|
NavigationDelegate(
|
|
onProgress: (progress) => setState(() => _progress = progress),
|
|
onPageFinished: (_) async {
|
|
final title = await _controller.getTitle();
|
|
if (mounted && title != null && title.trim().isNotEmpty) {
|
|
setState(() => _title = title.trim());
|
|
}
|
|
},
|
|
onWebResourceError: (error) {
|
|
if (error.isForMainFrame == true && mounted) {
|
|
setState(() => _error = error.description);
|
|
}
|
|
},
|
|
onNavigationRequest: _onNavigationRequest,
|
|
),
|
|
)
|
|
..loadRequest(widget.initialUri);
|
|
}
|
|
|
|
NavigationDecision _onNavigationRequest(NavigationRequest request) {
|
|
final uri = Uri.tryParse(request.url);
|
|
if (uri == null || !_isHttp(uri)) return NavigationDecision.prevent;
|
|
if (_allowsInApp(uri)) return NavigationDecision.navigate;
|
|
launchUrl(uri, mode: LaunchMode.externalApplication);
|
|
return NavigationDecision.prevent;
|
|
}
|
|
|
|
bool _allowsInApp(Uri uri) {
|
|
final host = uri.host.toLowerCase();
|
|
if (host == widget.initialUri.host.toLowerCase()) return true;
|
|
return widget.allowedHosts.any(
|
|
(allowed) => host == allowed || host.endsWith('.$allowed'),
|
|
);
|
|
}
|
|
|
|
bool _isHttp(Uri uri) =>
|
|
uri.hasAuthority && (uri.scheme == 'https' || uri.scheme == 'http');
|
|
|
|
Future<bool> _onPop() async {
|
|
if (await _controller.canGoBack()) {
|
|
await _controller.goBack();
|
|
return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final body = _error == null
|
|
? Stack(
|
|
children: [
|
|
WebViewWidget(controller: _controller),
|
|
if (_progress < 100)
|
|
LinearProgressIndicator(value: _progress / 100),
|
|
],
|
|
)
|
|
: Center(
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(24),
|
|
child: Text('页面加载失败\n$_error', textAlign: TextAlign.center),
|
|
),
|
|
);
|
|
return PopScope(
|
|
canPop: false,
|
|
onPopInvokedWithResult: (didPop, _) async {
|
|
if (!didPop && await _onPop() && context.mounted) context.pop();
|
|
},
|
|
child: Scaffold(
|
|
appBar: widget.hideTitle
|
|
? null
|
|
: AppBar(title: Text(_title.isEmpty ? '加载中' : _title)),
|
|
body: body,
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
Uri? parseSafeWebUri(String? raw) {
|
|
final uri = Uri.tryParse(raw?.trim() ?? '');
|
|
if (uri == null || !uri.hasAuthority) return null;
|
|
return uri.scheme == 'https' || uri.scheme == 'http' ? uri : null;
|
|
}
|