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 createState() => _AppWebPageState(); } class _AppWebPageState extends State { 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 _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()); }