Files
jinzhi/lib/features/web/presentation/web_page.dart
T

131 lines
3.9 KiB
Dart

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