138 lines
4.3 KiB
Dart
138 lines
4.3 KiB
Dart
import 'dart:async';
|
|
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter/foundation.dart';
|
|
import 'package:go_router/go_router.dart';
|
|
import 'package:url_launcher/url_launcher.dart';
|
|
import 'package:webview_flutter/webview_flutter.dart';
|
|
|
|
import '../application/web_navigation_policy.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,
|
|
});
|
|
|
|
final Uri initialUri;
|
|
final bool hideTitle;
|
|
|
|
@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(
|
|
// Ordinary pages never receive camera/microphone or other protected
|
|
// resources. MallWebHost owns its separate permission policy.
|
|
onPermissionRequest: (request) => unawaited(request.deny()),
|
|
)
|
|
..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 && shouldOpenWebUrlExternally(request.url)) {
|
|
launchUrl(uri, mode: LaunchMode.externalApplication);
|
|
return NavigationDecision.prevent;
|
|
}
|
|
if (uri == null || !_isAllowedWebUri(uri)) {
|
|
return NavigationDecision.prevent;
|
|
}
|
|
// Align with jinzhi-flutter-app: a normal HTTPS navigation stays in this
|
|
// WebView even when its host changes. H5 opts into an external handoff
|
|
// through `out`, which is handled above.
|
|
return NavigationDecision.navigate;
|
|
}
|
|
|
|
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() ?? '');
|
|
return uri != null && _isAllowedWebUri(uri) ? uri : null;
|
|
}
|
|
|
|
bool _isAllowedWebUri(Uri uri) {
|
|
if (!uri.hasAuthority || uri.host.isEmpty) return false;
|
|
if (uri.scheme.toLowerCase() == 'https') return true;
|
|
if (kReleaseMode || uri.scheme.toLowerCase() != 'http') return false;
|
|
return const {
|
|
'localhost',
|
|
'127.0.0.1',
|
|
'::1',
|
|
}.contains(uri.host.toLowerCase());
|
|
}
|