import 'dart:async'; import 'dart:convert'; import 'package:file_picker/file_picker.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/scheduler.dart'; import 'package:flutter/services.dart'; import 'package:image_picker/image_picker.dart'; import 'package:permission_handler/permission_handler.dart'; import 'package:url_launcher/url_launcher.dart'; import 'package:webview_flutter/webview_flutter.dart'; import 'package:webview_flutter_android/webview_flutter_android.dart'; const String _defaultInitialUrl = 'http://xszy.lzzneng.com/login.html'; const String _configuredInitialUrl = String.fromEnvironment( 'APP_INITIAL_URL', defaultValue: _defaultInitialUrl, ); const String _appShellBridgeChannel = 'AppShellBridge'; const String _appShellBridgeVersion = '1.0.0'; const Duration _releaseStartupWatchdogDuration = Duration(seconds: 15); const Duration _debugStartupWatchdogDuration = Duration(seconds: 15); const double _pickedImageMaxWidth = 1600; const double _pickedImageMaxHeight = 1600; const int _pickedImageQuality = 85; const MethodChannel _appShellDeviceChannel = MethodChannel('app_shell/device'); const int _legacyWebViewMajorVersionThreshold = 110; const Color _shellAccentColor = Color(0xFF3ED37B); const Color _shellBackgroundColor = Color(0xFFFFFFFF); const Color _shellTextColor = Color(0xFF1F2937); const Color _shellMutedTextColor = Color(0xFF6B7280); final Uri _initialUri = _resolveInitialUri(); final String _initialUrl = _initialUri.toString(); Uri _resolveInitialUri() { final String candidate = _configuredInitialUrl.trim(); if (candidate.isEmpty) { return Uri.parse(_defaultInitialUrl); } final Uri? directUri = Uri.tryParse(candidate); if (directUri != null && directUri.hasScheme) { return directUri; } if (candidate.startsWith('//')) { final Uri? protocolRelativeUri = Uri.tryParse('https:$candidate'); if (protocolRelativeUri != null && protocolRelativeUri.hasScheme) { return protocolRelativeUri; } } final Uri? httpsUri = Uri.tryParse('https://$candidate'); if (httpsUri != null && httpsUri.hasScheme && httpsUri.host.isNotEmpty) { return httpsUri; } return Uri.parse(_defaultInitialUrl); } bool _supportsEmbeddedWebView({bool isWeb = kIsWeb, TargetPlatform? platform}) { if (isWeb) { return false; } final TargetPlatform target = platform ?? defaultTargetPlatform; return target == TargetPlatform.android || target == TargetPlatform.iOS || target == TargetPlatform.macOS; } Future main() async { WidgetsFlutterBinding.ensureInitialized(); await SystemChrome.setPreferredOrientations(const [ DeviceOrientation.portraitUp, DeviceOrientation.portraitDown, ]); await _enterImmersiveMode(); runApp(const WebShellApp()); } Future _enterImmersiveMode() async { await SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky); SystemChrome.setSystemUIOverlayStyle( const SystemUiOverlayStyle( statusBarColor: Colors.transparent, statusBarIconBrightness: Brightness.light, statusBarBrightness: Brightness.dark, systemNavigationBarColor: Colors.black, systemNavigationBarDividerColor: Colors.black, systemNavigationBarIconBrightness: Brightness.light, ), ); } enum _AndroidRenderMode { texture, hybrid; bool get usesHybridComposition => this == _AndroidRenderMode.hybrid; String get logName => switch (this) { _AndroidRenderMode.texture => 'texture-layer', _AndroidRenderMode.hybrid => 'hybrid-composition', }; String get displayName => switch (this) { _AndroidRenderMode.texture => '标准模式', _AndroidRenderMode.hybrid => '兼容模式', }; } class _AndroidWebViewInfo { _AndroidWebViewInfo({ required this.sdkInt, required this.manufacturer, required this.brand, required this.model, this.webViewDataDirectorySuffix, this.webViewPackageName, this.webViewVersionName, this.webViewLongVersionCode, }) : webViewMajorVersion = _parseWebViewMajorVersion(webViewVersionName); factory _AndroidWebViewInfo.fromMap(Map raw) { return _AndroidWebViewInfo( sdkInt: _readInt(raw['sdkInt']), manufacturer: _readString(raw['manufacturer']), brand: _readString(raw['brand']), model: _readString(raw['model']), webViewDataDirectorySuffix: _readNullableString( raw['webViewDataDirectorySuffix'], ), webViewPackageName: _readNullableString(raw['webViewPackageName']), webViewVersionName: _readNullableString(raw['webViewVersionName']), webViewLongVersionCode: _readNullableInt(raw['webViewLongVersionCode']), ); } final int sdkInt; final String manufacturer; final String brand; final String model; final String? webViewDataDirectorySuffix; final String? webViewPackageName; final String? webViewVersionName; final int? webViewLongVersionCode; final int? webViewMajorVersion; bool get isLegacyWebView => webViewMajorVersion != null && webViewMajorVersion! <= _legacyWebViewMajorVersionThreshold; bool get isF136A => model.toUpperCase() == 'F136A'; String get summary { final List parts = [ 'sdk=$sdkInt', if (manufacturer.isNotEmpty || model.isNotEmpty) 'device=${[manufacturer, model].where((part) => part.isNotEmpty).join(' ')}', if (webViewPackageName case final String packageName when packageName.isNotEmpty) 'webViewPackage=$packageName', if (webViewVersionName case final String versionName when versionName.isNotEmpty) 'webViewVersion=$versionName', if (webViewDataDirectorySuffix case final String suffix when suffix.isNotEmpty) 'webViewSuffix=$suffix', ]; return parts.join(', '); } static int _readInt(Object? value, {int fallback = 0}) { if (value is int) { return value; } return int.tryParse((value ?? '').toString()) ?? fallback; } static int? _readNullableInt(Object? value) { if (value == null) { return null; } if (value is int) { return value; } return int.tryParse(value.toString()); } static String _readString(Object? value) { return (value ?? '').toString().trim(); } static String? _readNullableString(Object? value) { final String normalized = _readString(value); return normalized.isEmpty ? null : normalized; } } class _AndroidCompatibilityPlan { const _AndroidCompatibilityPlan({ required this.renderModes, required this.useWideViewPort, required this.suggestWebViewUpdate, required this.prefersAggressiveRecovery, }); factory _AndroidCompatibilityPlan.fallback() { return _AndroidCompatibilityPlan( renderModes: kDebugMode ? const <_AndroidRenderMode>[ _AndroidRenderMode.texture, _AndroidRenderMode.hybrid, ] : const <_AndroidRenderMode>[ _AndroidRenderMode.hybrid, _AndroidRenderMode.texture, ], useWideViewPort: true, suggestWebViewUpdate: false, prefersAggressiveRecovery: true, ); } factory _AndroidCompatibilityPlan.fromInfo(_AndroidWebViewInfo? info) { if (info == null) { return _AndroidCompatibilityPlan.fallback(); } final bool legacyAndroid = info.sdkInt <= 28; final bool legacyWebView = info.isLegacyWebView; final bool preferHybridFirst = info.isF136A || legacyAndroid || legacyWebView; return _AndroidCompatibilityPlan( renderModes: preferHybridFirst ? const <_AndroidRenderMode>[ _AndroidRenderMode.hybrid, _AndroidRenderMode.texture, ] : const <_AndroidRenderMode>[ _AndroidRenderMode.texture, _AndroidRenderMode.hybrid, ], useWideViewPort: true, suggestWebViewUpdate: info.isF136A || legacyWebView || info.sdkInt <= 26, prefersAggressiveRecovery: info.isF136A || legacyAndroid || legacyWebView, ); } final List<_AndroidRenderMode> renderModes; final bool useWideViewPort; final bool suggestWebViewUpdate; final bool prefersAggressiveRecovery; String describe() { return [ 'modes=${renderModes.map((mode) => mode.logName).join(' -> ')}', 'wideViewport=$useWideViewPort', 'aggressiveRecovery=$prefersAggressiveRecovery', ].join(', '); } } int? _parseWebViewMajorVersion(String? versionName) { if (versionName == null || versionName.isEmpty) { return null; } final String candidate = versionName.split('.').first.trim(); return int.tryParse(candidate); } class WebShellApp extends StatelessWidget { const WebShellApp({super.key}); @override Widget build(BuildContext context) { return MaterialApp( debugShowCheckedModeBanner: false, home: _supportsEmbeddedWebView() ? const WebShellPage() : const UnsupportedPlatformPage(), ); } } class WebShellPage extends StatefulWidget { const WebShellPage({super.key}); @override State createState() => _WebShellPageState(); } class _WebShellPageState extends State with WidgetsBindingObserver { final ImagePicker _imagePicker = ImagePicker(); final WebViewCookieManager _cookieManager = WebViewCookieManager(); late WebViewController _controller; late WebViewWidget _webViewWidget; late Future _controllerSetupFuture; late final Future _androidCompatibilityFuture; _AndroidWebViewInfo? _androidWebViewInfo; _AndroidCompatibilityPlan _androidCompatibilityPlan = _AndroidCompatibilityPlan.fallback(); _AndroidRenderMode? _configuredRenderMode; int _webViewGeneration = 0; int _renderModeIndex = 0; bool _hasTriggeredInitialLoad = false; bool _hasAppliedCompatibilityPlan = false; bool _isLoadingPage = false; bool _hasBootstrapped = false; bool _hasStartedRemoteMainFrame = false; bool _hasMainFrameError = false; bool _hasMeasuredProgress = false; Timer? _startupWatchdogTimer; int _progress = 0; int _startupRetryCount = 0; String _currentUrl = _initialUrl; String _errorTitle = '页面加载失败'; String _errorMessage = '请检查网络后重试。'; String _lastWebViewEvent = '应用启动'; @override void initState() { super.initState(); debugPrint('WebShell initState, initialUrl=$_initialUrl'); WidgetsBinding.instance.addObserver(this); _androidCompatibilityFuture = _prepareAndroidCompatibility(); _recreateWebView(); WidgetsBinding.instance.addPostFrameCallback((_) { if (!mounted || _hasTriggeredInitialLoad) { return; } _hasTriggeredInitialLoad = true; unawaited(_loadInitialPage()); }); } int get _safeRenderModeIndex { if (_renderModeIndex < 0) { return 0; } final int maxIndex = _androidCompatibilityPlan.renderModes.length - 1; if (_renderModeIndex > maxIndex) { return maxIndex; } return _renderModeIndex; } _AndroidRenderMode get _activeRenderMode => _androidCompatibilityPlan.renderModes[_safeRenderModeIndex]; Duration get _effectiveStartupWatchdogDuration { if (defaultTargetPlatform != TargetPlatform.android) { return _releaseStartupWatchdogDuration; } if (kDebugMode) { return _debugStartupWatchdogDuration; } return _releaseStartupWatchdogDuration; } Future _prepareAndroidCompatibility() async { if (defaultTargetPlatform != TargetPlatform.android) { return; } try { final Map? rawInfo = await _appShellDeviceChannel .invokeMapMethod('getAndroidWebViewInfo'); if (rawInfo != null) { _androidWebViewInfo = _AndroidWebViewInfo.fromMap(rawInfo); } } catch (error, stackTrace) { debugPrint('Load Android WebView info failed: $error\n$stackTrace'); } _androidCompatibilityPlan = _AndroidCompatibilityPlan.fromInfo( _androidWebViewInfo, ); _renderModeIndex = 0; debugPrint( 'WebShell Android WebView info: ' '${_androidWebViewInfo?.summary ?? 'unavailable'}', ); debugPrint( 'WebShell compatibility plan: ${_androidCompatibilityPlan.describe()}', ); } Future _ensureCompatibilityPlanApplied() async { await _androidCompatibilityFuture; if (!mounted || _hasAppliedCompatibilityPlan || defaultTargetPlatform != TargetPlatform.android) { return; } _hasAppliedCompatibilityPlan = true; if (_configuredRenderMode == _activeRenderMode) { debugPrint( 'WebShell compatibility plan keeps current WebView ' '(${_activeRenderMode.logName})', ); return; } _recreateWebView(); } bool _switchToNextRenderMode() { final int nextIndex = _safeRenderModeIndex + 1; if (nextIndex >= _androidCompatibilityPlan.renderModes.length) { return false; } _renderModeIndex = nextIndex; debugPrint('WebShell switched render mode to ${_activeRenderMode.logName}'); return true; } String _buildCompatibilityGuidance() { if (defaultTargetPlatform != TargetPlatform.android) { return ''; } final List lines = [ '当前渲染策略:${_activeRenderMode.displayName}', if (_androidWebViewInfo?.model case final String model when model.isNotEmpty) '当前设备:$model', if (_androidWebViewInfo?.webViewVersionName case final String version when version.isNotEmpty) '系统 WebView:$version', if (_androidCompatibilityPlan.suggestWebViewUpdate) '如果内嵌页面仍异常,建议更新 Android System WebView 或 Chrome。', ]; return lines.join('\n'); } void _recreateWebView() { final PlatformWebViewControllerCreationParams controllerParams = const PlatformWebViewControllerCreationParams(); final WebViewController controller = WebViewController.fromPlatformCreationParams(controllerParams); final int generation = ++_webViewGeneration; final _AndroidRenderMode renderMode = _activeRenderMode; _controller = controller; _controllerSetupFuture = _configureController(controller, generation); _configuredRenderMode = renderMode; debugPrint( 'WebShell recreate WebView #$generation with ${renderMode.logName}', ); PlatformWebViewWidgetCreationParams widgetParams = PlatformWebViewWidgetCreationParams( key: ValueKey('webview-$generation-${renderMode.logName}'), controller: controller.platform, layoutDirection: TextDirection.ltr, ); if (defaultTargetPlatform == TargetPlatform.android) { widgetParams = AndroidWebViewWidgetCreationParams.fromPlatformWebViewWidgetCreationParams( widgetParams, displayWithHybridComposition: renderMode.usesHybridComposition, ); } _webViewWidget = WebViewWidget.fromPlatformCreationParams( params: widgetParams, ); } bool _isActiveWebViewGeneration(int generation) { return generation == _webViewGeneration; } Future _configureController( WebViewController controller, int generation, ) async { await controller.setJavaScriptMode(JavaScriptMode.unrestricted); await controller.enableZoom(false); await controller.setBackgroundColor(Colors.white); await controller.addJavaScriptChannel( _appShellBridgeChannel, onMessageReceived: (JavaScriptMessage message) { if (!_isActiveWebViewGeneration(generation)) { return; } unawaited(_handleBridgeMessage(message.message)); }, ); await controller.setNavigationDelegate( _buildNavigationDelegate(generation), ); if (controller.platform is AndroidWebViewController) { final AndroidWebViewController androidController = controller.platform as AndroidWebViewController; await AndroidWebViewController.enableDebugging( kDebugMode && !_androidCompatibilityPlan.prefersAggressiveRecovery, ); await androidController.setMediaPlaybackRequiresUserGesture(false); await androidController.setMixedContentMode(MixedContentMode.alwaysAllow); await androidController.setOverScrollMode(WebViewOverScrollMode.never); await androidController.setUseWideViewPort( _androidCompatibilityPlan.useWideViewPort, ); await androidController.setTextZoom(100); await androidController.setVerticalScrollBarEnabled(false); await androidController.setHorizontalScrollBarEnabled(false); await androidController.setOnShowFileSelector(_handleFileSelector); await androidController.setOnPlatformPermissionRequest( _handlePlatformPermissionRequest, ); await androidController.setGeolocationPermissionsPromptCallbacks( onShowPrompt: _handleGeolocationPermissionRequest, ); await androidController.setOnConsoleMessage((message) { debugPrint( 'WebView console [${message.level.name}] ${message.message}', ); }); } } NavigationDelegate _buildNavigationDelegate(int generation) { return NavigationDelegate( onProgress: (int progress) { if (!_isActiveWebViewGeneration(generation)) { return; } if (progress == 10 || progress == 30 || progress == 60 || progress == 90) { _recordWebViewEvent('加载进度 $progress%'); } if (!mounted) { return; } setState(() { _progress = progress; if (_hasStartedRemoteMainFrame || progress >= 100) { _hasMeasuredProgress = true; } }); }, onNavigationRequest: (NavigationRequest request) async { if (!_isActiveWebViewGeneration(generation)) { return NavigationDecision.navigate; } return _handleNavigationRequest(request); }, onUrlChange: (UrlChange change) { if (!_isActiveWebViewGeneration(generation)) { return; } final String? url = change.url; if (url == null || url.isEmpty) { return; } _recordWebViewEvent('地址变化:$url'); if (!mounted) { return; } setState(() { _currentUrl = url; }); }, onPageStarted: (url) { if (!_isActiveWebViewGeneration(generation)) { return; } if (!_isNetworkUrl(url)) { debugPrint('WebView ignore non-network page start: $url'); return; } _hasStartedRemoteMainFrame = true; _cancelStartupWatchdog(); _recordWebViewEvent('页面开始加载:$url'); if (!mounted) { return; } setState(() { _currentUrl = url; if (_hasMeasuredProgress) { _progress = _progress < 30 ? 30 : _progress; } _isLoadingPage = true; _hasBootstrapped = true; _hasMainFrameError = false; _errorTitle = '页面加载失败'; _errorMessage = '请检查网络后重试。'; }); }, onPageFinished: (url) { if (!_isActiveWebViewGeneration(generation)) { return; } if (!_isNetworkUrl(url)) { debugPrint('WebView ignore non-network page finish: $url'); return; } _recordWebViewEvent('页面加载完成:$url'); _cancelStartupWatchdog(); unawaited(_injectAppShellBridge(url)); if (!mounted) { return; } setState(() { _currentUrl = url; _hasMeasuredProgress = true; _progress = 100; _isLoadingPage = false; _hasBootstrapped = true; _hasMainFrameError = false; _startupRetryCount = 0; }); }, onHttpError: (HttpResponseError error) { if (!_isActiveWebViewGeneration(generation)) { return; } final int? statusCode = error.response?.statusCode; final Uri? requestUri = error.request?.uri; if (!_shouldTreatHttpErrorAsMainFrame(error)) { debugPrint( 'Ignore subresource HTTP error: ' '${statusCode ?? 'unknown'} ${requestUri ?? 'unknown'}', ); return; } _recordWebViewEvent( 'HTTP 错误:${statusCode ?? 'unknown'} ${requestUri ?? _currentUrl}', ); _setMainFrameError( title: statusCode == null ? '服务器响应异常' : '服务器异常 $statusCode', message: statusCode == null ? '页面返回异常,请稍后重试。' : '页面返回了 $statusCode 状态码,请稍后重试。', ); }, onWebResourceError: (error) { if (!_isActiveWebViewGeneration(generation)) { return; } _recordWebViewEvent( '资源错误:code=${error.errorCode}, ' 'type=${error.errorType}, ' 'mainFrame=${error.isForMainFrame}, ' 'url=${error.url}, ' 'description=${error.description}', ); if (!mounted || error.isForMainFrame == false) { return; } _setMainFrameError( title: _friendlyErrorTitle(error), message: _friendlyErrorMessage(error), ); }, ); } @override void didChangeAppLifecycleState(AppLifecycleState state) { if (state == AppLifecycleState.resumed) { _enterImmersiveMode(); } } @override void dispose() { _cancelStartupWatchdog(); WidgetsBinding.instance.removeObserver(this); super.dispose(); } Future _loadInitialPage() async { if (!mounted) { return; } await _ensureCompatibilityPlanApplied(); if (!mounted) { return; } debugPrint('WebShell first frame ready, start initial load'); await _startLoadSequence(rebuildWebView: false, resetRetryCount: true); } Future _handleBackPressed() async { if (await _controller.canGoBack()) { await _controller.goBack(); return; } await SystemNavigator.pop(); } Future _handleNavigationRequest( NavigationRequest request, ) async { final Uri? uri = Uri.tryParse(request.url); if (uri == null || _shouldStayInWebView(uri)) { return NavigationDecision.navigate; } final bool opened = await _openExternalUri(uri); if (!opened) { await _showWebAlert('无法打开外部应用:${uri.scheme}'); } return NavigationDecision.prevent; } bool _isNetworkUrl(String? url) { final Uri? uri = Uri.tryParse(url ?? ''); if (uri == null) { return false; } return uri.scheme == 'http' || uri.scheme == 'https'; } bool _shouldStayInWebView(Uri uri) { return { 'http', 'https', 'about', 'data', 'javascript', 'file', 'blob', }.contains(uri.scheme.toLowerCase()); } void _recordWebViewEvent(String event) { _lastWebViewEvent = event; debugPrint('WebView $event'); } void _armStartupWatchdog() { _cancelStartupWatchdog(); _startupWatchdogTimer = Timer(_effectiveStartupWatchdogDuration, () { unawaited(_handleStartupTimeout()); }); } void _cancelStartupWatchdog() { _startupWatchdogTimer?.cancel(); _startupWatchdogTimer = null; } Future _handleStartupTimeout() async { if (!mounted || !_isLoadingPage || _hasMainFrameError || _hasStartedRemoteMainFrame) { return; } final bool switchedRenderMode = _switchToNextRenderMode(); final int maxRetryCount = _androidCompatibilityPlan.prefersAggressiveRecovery ? 2 : 1; if (switchedRenderMode || _startupRetryCount < maxRetryCount) { final int nextRetryCount = _startupRetryCount + 1; final String recoveryAction = switchedRenderMode ? '切换到${_activeRenderMode.displayName}' : '深度清理 WebView 状态'; _recordWebViewEvent('启动超时,$recoveryAction 并自动重试第 $nextRetryCount 次'); setState(() { _startupRetryCount = nextRetryCount; _hasMeasuredProgress = false; _progress = 0; _isLoadingPage = true; }); await _recoverFromBrokenStartupState(deepReset: true); await _startLoadSequence(rebuildWebView: true, resetRetryCount: false); return; } _setMainFrameError( title: '页面启动超时', message: [ '${_effectiveStartupWatchdogDuration.inSeconds} 秒内没有完成页面加载。', '最近事件:$_lastWebViewEvent', _buildCompatibilityGuidance(), ].where((line) => line.isNotEmpty).join('\n'), ); } void _setMainFrameError({required String title, required String message}) { _cancelStartupWatchdog(); if (!mounted) { return; } setState(() { _isLoadingPage = false; _hasMainFrameError = true; _hasMeasuredProgress = false; _errorTitle = title; _errorMessage = message; _progress = 0; }); } Future _recoverFromBrokenStartupState({bool deepReset = false}) async { final Future controllerSetupFuture = _controllerSetupFuture; try { await controllerSetupFuture; } catch (error, stackTrace) { debugPrint('Await WebView controller setup failed: $error\n$stackTrace'); } try { await _controller.clearCache(); } catch (error, stackTrace) { debugPrint('Clear WebView cache failed: $error\n$stackTrace'); } try { await _controller.clearLocalStorage(); } catch (error, stackTrace) { debugPrint('Clear WebView local storage failed: $error\n$stackTrace'); } try { await _cookieManager.clearCookies(); } catch (error, stackTrace) { debugPrint('Clear WebView cookies failed: $error\n$stackTrace'); } if (!deepReset || defaultTargetPlatform != TargetPlatform.android) { return; } try { await _appShellDeviceChannel.invokeMethod( 'resetAndroidWebViewState', ); } catch (error, stackTrace) { debugPrint('Reset Android WebView state failed: $error\n$stackTrace'); } } String _friendlyErrorTitle(WebResourceError error) { switch (error.errorType) { case WebResourceErrorType.timeout: return '请求超时'; case WebResourceErrorType.hostLookup: case WebResourceErrorType.connect: case WebResourceErrorType.io: return '网络连接失败'; case WebResourceErrorType.failedSslHandshake: return '安全连接失败'; default: return '页面加载失败'; } } String _friendlyErrorMessage(WebResourceError error) { switch (error.errorType) { case WebResourceErrorType.timeout: return '当前网络较慢,请稍后重新加载。'; case WebResourceErrorType.hostLookup: case WebResourceErrorType.connect: case WebResourceErrorType.io: return '没有成功连接到服务器,请检查网络后重试。'; case WebResourceErrorType.failedSslHandshake: return '当前站点证书校验失败,请稍后再试。'; default: final String description = error.description.trim(); return description.isEmpty ? '请稍后重新加载页面。' : description; } } bool _shouldTreatHttpErrorAsMainFrame(HttpResponseError error) { final Uri? requestUri = error.request?.uri; if (requestUri == null) { return true; } if (!_isNetworkUrl(requestUri.toString())) { return false; } final Uri? currentUri = Uri.tryParse(_currentUrl); if (_isSameDocumentRequest(requestUri, currentUri)) { return true; } if (!_hasBootstrapped && _isSameDocumentRequest(requestUri, _initialUri)) { return true; } return false; } bool _isSameDocumentRequest(Uri left, Uri? right) { if (right == null) { return false; } return _normalizeComparableUri(left) == _normalizeComparableUri(right); } String _normalizeComparableUri(Uri uri) { final String scheme = uri.scheme.toLowerCase(); final String host = uri.host.toLowerCase(); final int port = uri.hasPort ? uri.port : _defaultPortForScheme(scheme); final String path = _normalizeComparablePath(uri.path); final String query = uri.query; return '$scheme://$host:$port$path?$query'; } int _defaultPortForScheme(String scheme) { return switch (scheme) { 'https' => 443, 'http' => 80, _ => -1, }; } String _normalizeComparablePath(String path) { if (path.isEmpty) { return '/'; } if (path.length > 1 && path.endsWith('/')) { return path.substring(0, path.length - 1); } return path; } Future _injectAppShellBridge(String url) async { try { await _controller.runJavaScript(_buildAppShellBridgeScript()); debugPrint('Injected AppShell bridge for $url'); } catch (error, stackTrace) { debugPrint('Inject AppShell bridge failed: $error\n$stackTrace'); } } Future _handleBridgeMessage(String rawMessage) async { String requestId = ''; try { final dynamic decoded = jsonDecode(rawMessage); if (decoded is! Map) { return; } final Map message = Map.from(decoded); requestId = (message['requestId'] ?? '').toString(); final String action = (message['action'] ?? '').toString(); final Map payload = message['payload'] is Map ? Map.from(message['payload'] as Map) : {}; if (requestId.isEmpty || action.isEmpty) { return; } debugPrint('AppShell bridge request: action=$action payload=$payload'); late final Object? data; switch (action) { case 'pickImage': data = await _pickImagesFromBridge( source: ImageSource.gallery, payload: payload, ); case 'captureImage': data = await _pickImagesFromBridge( source: ImageSource.camera, payload: payload, ); case 'pickFile': data = await _pickFilesFromBridge(payload); case 'openExternal': data = await _openExternalFromBridge(payload); case 'requestPermissions': data = await _requestPermissionsFromBridge(payload); case 'reloadPage': await _reloadPage(); data = true; case 'goBack': data = await _goBackFromBridge(); case 'closeApp': await _sendBridgeResponse(requestId: requestId, success: true); await SystemNavigator.pop(); return; default: throw UnsupportedError('Unsupported AppShell action: $action'); } await _sendBridgeResponse( requestId: requestId, success: true, data: data, ); } catch (error, stackTrace) { debugPrint('Handle AppShell bridge failed: $error\n$stackTrace'); if (requestId.isNotEmpty) { await _sendBridgeResponse( requestId: requestId, success: false, error: error.toString(), ); } } } Future _pickImagesFromBridge({ required ImageSource source, required Map payload, }) async { final bool multiple = source == ImageSource.gallery && _boolValue(payload['multiple']); final String responseType = (payload['responseType'] ?? 'dataUrl') .toString(); List files = []; if (source == ImageSource.camera) { final XFile? file = await _pickCameraImage(showPermissionAlert: true); if (file != null) { files = [file]; } } else if (multiple) { files = await _imagePicker.pickMultiImage( imageQuality: _pickedImageQuality, maxWidth: _pickedImageMaxWidth, maxHeight: _pickedImageMaxHeight, ); } else { final XFile? file = await _imagePicker.pickImage( source: ImageSource.gallery, imageQuality: _pickedImageQuality, maxWidth: _pickedImageMaxWidth, maxHeight: _pickedImageMaxHeight, ); if (file != null) { files = [file]; } } final List> serialized = await _serializeXFiles( files, responseType: responseType, ); return multiple ? serialized : serialized.firstOrNull; } Future _pickFilesFromBridge(Map payload) async { final String responseType = (payload['responseType'] ?? 'uri').toString(); final bool includeBinary = responseType == 'dataUrl' || responseType == 'base64'; final FilePickerResult? result = await FilePicker.platform.pickFiles( allowMultiple: _boolValue(payload['multiple']), withData: includeBinary, type: FileType.any, ); if (result == null) { return _boolValue(payload['multiple']) ? >[] : null; } final List> serialized = await _serializePlatformFiles( result.files, responseType: responseType, ); return _boolValue(payload['multiple']) ? serialized : serialized.firstOrNull; } Future _openExternalFromBridge(Map payload) async { final String url = (payload['url'] ?? '').toString(); final Uri? uri = Uri.tryParse(url); if (uri == null) { return false; } if (_isNetworkUrl(uri.toString())) { return false; } return _openExternalUri(uri); } Future> _requestPermissionsFromBridge( Map payload, ) async { final List types = (payload['types'] as List? ?? const []) .map((type) => type.toString()) .toList(); final Map permissions = { for (final String type in types) if (_permissionForType(type) case final Permission permission) type: permission, }; if (permissions.isEmpty) { return {}; } final Map statuses = await permissions.values .toSet() .toList() .request(); return { for (final MapEntry entry in permissions.entries) entry.key: (statuses[entry.value] ?? PermissionStatus.denied).name, }; } Future _goBackFromBridge() async { if (await _controller.canGoBack()) { await _controller.goBack(); return true; } return false; } Future _sendBridgeResponse({ required String requestId, required bool success, Object? data, String? error, }) async { final Map response = { 'requestId': requestId, 'success': success, 'data': data, 'error': error, }; try { await _controller.runJavaScript( 'window.__appShellReceiveResponse(${jsonEncode(response)});', ); } catch (bridgeError, stackTrace) { debugPrint( 'Send AppShell bridge response failed: $bridgeError\n$stackTrace', ); } } Future> _handleFileSelector(FileSelectorParams params) async { debugPrint( 'WebView file selector: ' 'accept=${params.acceptTypes}, ' 'capture=${params.isCaptureEnabled}, ' 'mode=${params.mode.name}', ); if (params.mode == FileSelectorMode.save) { return []; } try { final bool acceptsImages = _acceptsImages(params.acceptTypes); final bool imagesOnly = _acceptsOnlyImages(params.acceptTypes); if (params.isCaptureEnabled && acceptsImages) { final XFile? capturedImage = await _pickCameraImage(); return _xFilesToUriStrings( capturedImage == null ? const [] : [capturedImage], ); } if (imagesOnly) { if (params.mode == FileSelectorMode.openMultiple) { final List images = await _imagePicker.pickMultiImage( imageQuality: _pickedImageQuality, maxWidth: _pickedImageMaxWidth, maxHeight: _pickedImageMaxHeight, ); return _xFilesToUriStrings(images); } final XFile? image = await _imagePicker.pickImage( source: ImageSource.gallery, imageQuality: _pickedImageQuality, maxWidth: _pickedImageMaxWidth, maxHeight: _pickedImageMaxHeight, ); return _xFilesToUriStrings( image == null ? const [] : [image], ); } final FilePickerResult? result = await FilePicker.platform.pickFiles( allowMultiple: params.mode == FileSelectorMode.openMultiple, type: FileType.any, ); if (result == null) { return []; } return result.files .map((file) => file.path) .whereType() .map((path) => Uri.file(path).toString()) .toList(); } catch (error, stackTrace) { debugPrint('Handle file selector failed: $error\n$stackTrace'); return []; } } Future _handlePlatformPermissionRequest( PlatformWebViewPermissionRequest request, ) async { debugPrint( 'WebView permission request: ' '${request.types.map((type) => type.name).join(', ')}', ); final List permissions = [ if (request.types.contains(WebViewPermissionResourceType.camera)) Permission.camera, if (request.types.contains(WebViewPermissionResourceType.microphone)) Permission.microphone, ]; if (permissions.isEmpty) { await request.deny(); return; } final Map statuses = await permissions .request(); final bool allGranted = statuses.values.every((status) => status.isGranted); if (allGranted) { await request.grant(); return; } await request.deny(); } Future _handleGeolocationPermissionRequest( GeolocationPermissionsRequestParams request, ) async { debugPrint('WebView geolocation permission request: ${request.origin}'); final PermissionStatus status = await Permission.location.request(); return GeolocationPermissionsResponse( allow: status.isGranted, retain: status.isGranted, ); } Future _pickCameraImage({bool showPermissionAlert = false}) async { final PermissionStatus cameraStatus = await Permission.camera.request(); if (!cameraStatus.isGranted) { if (showPermissionAlert) { await _showWebAlert('请先在系统设置中允许相机权限'); } return null; } try { return await _imagePicker.pickImage( source: ImageSource.camera, preferredCameraDevice: CameraDevice.rear, imageQuality: _pickedImageQuality, maxWidth: _pickedImageMaxWidth, maxHeight: _pickedImageMaxHeight, ); } catch (error, stackTrace) { debugPrint('Pick camera image failed: $error\n$stackTrace'); if (showPermissionAlert) { await _showWebAlert('无法打开系统相机,请稍后重试'); } return null; } } Future>> _serializeXFiles( List files, { required String responseType, }) async { final bool includeBase64 = responseType == 'base64' || responseType == 'dataUrl'; final bool includeDataUrl = responseType == 'dataUrl'; final List> serialized = >[]; for (final XFile file in files) { String? base64Value; final String mimeType = _guessMimeType(file.name); if (includeBase64 || includeDataUrl) { base64Value = base64Encode(await file.readAsBytes()); } serialized.add({ 'name': file.name, 'uri': Uri.file(file.path).toString(), 'mimeType': mimeType, 'size': await file.length(), if (responseType == 'base64') 'base64': base64Value, if (includeDataUrl && base64Value != null) 'dataUrl': 'data:$mimeType;base64,$base64Value', }); } return serialized; } Future>> _serializePlatformFiles( List files, { required String responseType, }) async { final bool includeBase64 = responseType == 'base64' || responseType == 'dataUrl'; final bool includeDataUrl = responseType == 'dataUrl'; final List> serialized = >[]; for (final PlatformFile file in files) { final String mimeType = _guessMimeType(file.name); String? base64Value; if (includeBase64 || includeDataUrl) { final List? bytes = file.bytes ?? (file.path == null ? null : await XFile(file.path!).readAsBytes()); if (bytes != null) { base64Value = base64Encode(bytes); } } serialized.add({ 'name': file.name, 'uri': file.path == null ? null : Uri.file(file.path!).toString(), 'mimeType': mimeType, 'size': file.size, if (responseType == 'base64') 'base64': base64Value, if (includeDataUrl && base64Value != null) 'dataUrl': 'data:$mimeType;base64,$base64Value', }); } return serialized; } Future _openExternalUri(Uri uri) async { try { return await launchUrl(uri, mode: LaunchMode.externalApplication); } catch (error, stackTrace) { debugPrint('Open external uri failed: $error\n$stackTrace'); return false; } } Permission? _permissionForType(String type) { return switch (type.toLowerCase()) { 'camera' => Permission.camera, 'microphone' || 'audio' => Permission.microphone, 'location' => Permission.location, 'photos' || 'images' => Permission.photos, 'videos' => Permission.videos, 'storage' => Permission.storage, _ => null, }; } bool _boolValue(Object? value, {bool defaultValue = false}) { return switch (value) { bool boolValue => boolValue, String stringValue => stringValue.toLowerCase() == 'true', int intValue => intValue != 0, _ => defaultValue, }; } Future _showWebAlert(String message) async { try { await _controller.runJavaScript('window.alert(${jsonEncode(message)});'); } catch (error, stackTrace) { debugPrint('Show web alert failed: $error\n$stackTrace'); } } List _xFilesToUriStrings(List files) { return files.map((file) => Uri.file(file.path).toString()).toList(); } bool _acceptsImages(List acceptTypes) { return acceptTypes .map((type) => type.trim()) .where((type) => type.isNotEmpty) .any(_isImageAcceptType); } bool _acceptsOnlyImages(List acceptTypes) { final List normalizedTypes = acceptTypes .map((type) => type.trim()) .where((type) => type.isNotEmpty) .toList(); if (normalizedTypes.isEmpty) { return false; } return normalizedTypes.every(_isImageAcceptType); } bool _isImageAcceptType(String acceptType) { final String value = acceptType.toLowerCase(); return value.startsWith('image/') || const { '.png', '.jpg', '.jpeg', '.webp', '.gif', '.bmp', '.heic', '.heif', }.contains(value); } String _guessMimeType(String fileName) { final String lower = fileName.toLowerCase(); if (lower.endsWith('.png')) { return 'image/png'; } if (lower.endsWith('.jpg') || lower.endsWith('.jpeg')) { return 'image/jpeg'; } if (lower.endsWith('.webp')) { return 'image/webp'; } if (lower.endsWith('.gif')) { return 'image/gif'; } if (lower.endsWith('.bmp')) { return 'image/bmp'; } if (lower.endsWith('.heic')) { return 'image/heic'; } if (lower.endsWith('.heif')) { return 'image/heif'; } if (lower.endsWith('.pdf')) { return 'application/pdf'; } if (lower.endsWith('.txt')) { return 'text/plain'; } if (lower.endsWith('.apk')) { return 'application/vnd.android.package-archive'; } return 'application/octet-stream'; } String _buildAppShellBridgeScript() { return ''' (() => { const channel = window.$_appShellBridgeChannel; if (!channel || typeof channel.postMessage !== 'function') { return; } if (window.AppShell && window.AppShell.__nativeShellVersion === '$_appShellBridgeVersion') { return; } const pending = new Map(); window.__appShellReceiveResponse = function(response) { if (!response || !response.requestId) { return; } const task = pending.get(response.requestId); if (!task) { return; } pending.delete(response.requestId); if (response.success) { task.resolve(response.data ?? null); return; } task.reject(new Error(response.error || 'Native request failed')); }; const send = (action, payload = {}) => new Promise((resolve, reject) => { const requestId = Date.now().toString(36) + Math.random().toString(36).slice(2); pending.set(requestId, { resolve, reject }); channel.postMessage(JSON.stringify({ requestId, action, payload })); }); window.AppShell = { __nativeShellVersion: '$_appShellBridgeVersion', isNativeShell: true, version: '$_appShellBridgeVersion', pickImage: (options = {}) => send('pickImage', options), captureImage: (options = {}) => send('captureImage', options), pickFile: (options = {}) => send('pickFile', options), openExternal: (url) => send('openExternal', { url }), requestPermissions: (types = []) => send('requestPermissions', { types }), reloadPage: () => send('reloadPage'), goBack: () => send('goBack'), closeApp: () => send('closeApp'), }; window.dispatchEvent(new CustomEvent('app-shell-ready', { detail: { version: '$_appShellBridgeVersion', isNativeShell: true, }, })); if (!window.__appShellLegacyCameraCompatInstalled) { window.__appShellLegacyCameraCompatInstalled = true; const installLegacyCameraCompat = () => { if (typeof window.openCamera !== 'function') { return false; } if (window.openCamera.__appShellCompatWrapped) { return true; } const originalOpenCamera = window.openCamera.bind(window); const originalCapturePhoto = typeof window.capturePhoto === 'function' ? window.capturePhoto.bind(window) : null; const deliverCaptureResult = (args, result) => { const detail = { args: Array.from(args), result, }; if (typeof window.handleNativeCapture === 'function') { window.handleNativeCapture(detail); return true; } const targetId = detail.args[0] ?? window.currentUploadId ?? null; if ( targetId != null && result && result.dataUrl && typeof window.addPreview === 'function' ) { window.currentUploadId = targetId; window.addPreview(targetId, result.dataUrl); return true; } window.dispatchEvent( new CustomEvent('app-shell-image-captured', { detail, }), ); return false; }; const closeLegacyCameraModal = () => { if (typeof window.closeCamera === 'function') { try { window.closeCamera(); } catch (_) {} } }; const wrappedOpenCamera = async function(...args) { try { const result = await window.AppShell.captureImage({ responseType: 'dataUrl', }); const delivered = deliverCaptureResult(args, result); if (!delivered) { return result; } closeLegacyCameraModal(); return result; } catch (error) { console.warn( '[AppShell] legacy openCamera fallback to page implementation', error, ); return originalOpenCamera(...args); } }; wrappedOpenCamera.__appShellCompatWrapped = true; window.openCamera = wrappedOpenCamera; if (originalCapturePhoto) { const wrappedCapturePhoto = function(...args) { if ( window.currentUploadId != null && window.__lastAppShellCaptureResult && window.__lastAppShellCaptureResult.dataUrl && typeof window.addPreview === 'function' ) { window.addPreview( window.currentUploadId, window.__lastAppShellCaptureResult.dataUrl, ); closeLegacyCameraModal(); return; } return originalCapturePhoto(...args); }; wrappedCapturePhoto.__appShellCompatWrapped = true; window.capturePhoto = wrappedCapturePhoto; } return true; }; const originalCaptureImage = window.AppShell.captureImage; window.AppShell.captureImage = async function(options = {}) { const result = await originalCaptureImage(options); window.__lastAppShellCaptureResult = result; return result; }; installLegacyCameraCompat(); let attempts = 0; const compatTimer = setInterval(() => { attempts += 1; const installed = installLegacyCameraCompat(); if (installed || attempts >= 20) { clearInterval(compatTimer); } }, 500); } })(); '''; } Future _startLoadSequence({ required bool rebuildWebView, required bool resetRetryCount, }) async { _cancelStartupWatchdog(); if (!mounted) { return; } if (rebuildWebView) { _recreateWebView(); } final int generation = _webViewGeneration; final Future controllerSetupFuture = _controllerSetupFuture; _hasStartedRemoteMainFrame = false; setState(() { _isLoadingPage = true; _hasMainFrameError = false; _hasMeasuredProgress = false; _progress = 0; if (resetRetryCount) { _startupRetryCount = 0; } _errorTitle = '页面加载失败'; _errorMessage = '请检查网络后重试。'; _currentUrl = _initialUrl; }); await controllerSetupFuture; if (!mounted || !_isActiveWebViewGeneration(generation)) { return; } await _waitForWebViewMount(generation); debugPrint( 'WebShell start real URL load on WebView #$generation ' '(${_activeRenderMode.logName}): $_initialUrl', ); try { _armStartupWatchdog(); await _controller.loadRequest(_initialUri); } catch (error, stackTrace) { debugPrint('Start initial URL load failed: $error\n$stackTrace'); _setMainFrameError( title: '地址无法加载', message: '无法打开 $_initialUrl,请检查地址格式或网络后重试。', ); } } Future _waitForWebViewMount(int generation) async { final bool isAndroidDebug = kDebugMode && defaultTargetPlatform == TargetPlatform.android; final bool usesHybridComposition = defaultTargetPlatform == TargetPlatform.android && _activeRenderMode.usesHybridComposition; final int framesToWait = isAndroidDebug ? (usesHybridComposition ? 6 : 4) : (usesHybridComposition ? 2 : 1); final Duration settleDelay = isAndroidDebug ? (usesHybridComposition ? const Duration(milliseconds: 250) : const Duration(milliseconds: 140)) : (usesHybridComposition ? const Duration(milliseconds: 80) : const Duration(milliseconds: 40)); for (int index = 0; index < framesToWait; index += 1) { await SchedulerBinding.instance.endOfFrame; if (!mounted || !_isActiveWebViewGeneration(generation)) { return; } await Future.delayed(settleDelay); if (!mounted || !_isActiveWebViewGeneration(generation)) { return; } } } Future _reloadPage() async { if (!mounted) { return; } await _ensureCompatibilityPlanApplied(); if (!mounted) { return; } if (!_hasBootstrapped) { await _recoverFromBrokenStartupState(deepReset: true); await _startLoadSequence(rebuildWebView: true, resetRetryCount: true); return; } setState(() { _hasStartedRemoteMainFrame = false; _isLoadingPage = true; _hasMainFrameError = false; _hasMeasuredProgress = false; _progress = 0; _startupRetryCount = 0; _errorTitle = '页面加载失败'; _errorMessage = '请检查网络后重试。'; }); try { _armStartupWatchdog(); await _controller.reload(); } catch (error, stackTrace) { debugPrint('Reload current page failed: $error\n$stackTrace'); await _startLoadSequence(rebuildWebView: true, resetRetryCount: true); } } @override Widget build(BuildContext context) { final bool showProgressBar = _isLoadingPage && (!_hasMeasuredProgress || _progress < 100); final bool showLaunchOverlay = !_hasBootstrapped && !_hasMainFrameError; return PopScope( canPop: false, onPopInvokedWithResult: (didPop, result) { if (!didPop) { unawaited(_handleBackPressed()); } }, child: Scaffold( backgroundColor: _shellBackgroundColor, body: DecoratedBox( decoration: const BoxDecoration( gradient: LinearGradient( colors: [_shellBackgroundColor, Color(0xFFF4FBF7)], begin: Alignment.topCenter, end: Alignment.bottomCenter, ), ), child: SafeArea( top: false, bottom: false, child: Stack( children: [ Positioned.fill( child: ColoredBox( color: _shellBackgroundColor, child: _webViewWidget, ), ), if (showProgressBar) Positioned( top: 0, left: 0, right: 0, child: _TopProgressBar( progress: _progress, hasMeasuredProgress: _hasMeasuredProgress, ), ), if (showLaunchOverlay) Positioned.fill( child: _LaunchOverlay( progress: _progress, hasMeasuredProgress: _hasMeasuredProgress, ), ), if (_hasMainFrameError) Positioned.fill( child: _ErrorOverlay( title: _errorTitle, message: _errorMessage, currentUrl: _currentUrl, onRetry: _reloadPage, ), ), ], ), ), ), ), ); } } class UnsupportedPlatformPage extends StatelessWidget { const UnsupportedPlatformPage({super.key}); @override Widget build(BuildContext context) { return Scaffold( backgroundColor: _shellBackgroundColor, body: Center( child: Padding( padding: const EdgeInsets.symmetric(horizontal: 28), child: Column( mainAxisSize: MainAxisSize.min, children: [ Container( width: 84, height: 84, decoration: BoxDecoration( color: _shellAccentColor.withValues(alpha: 0.12), borderRadius: BorderRadius.circular(24), ), alignment: Alignment.center, child: const Icon( Icons.language_rounded, size: 42, color: _shellAccentColor, ), ), const SizedBox(height: 20), const Text( '当前平台不支持内嵌 WebView', style: TextStyle( fontSize: 22, fontWeight: FontWeight.w700, color: _shellTextColor, ), textAlign: TextAlign.center, ), const SizedBox(height: 12), Text( '请在 Android、iOS 或 macOS 上运行当前项目。\n$_initialUrl', textAlign: TextAlign.center, style: const TextStyle( height: 1.6, color: _shellMutedTextColor, ), ), ], ), ), ), ); } } class _TopProgressBar extends StatelessWidget { const _TopProgressBar({ required this.progress, required this.hasMeasuredProgress, }); final int progress; final bool hasMeasuredProgress; @override Widget build(BuildContext context) { return LinearProgressIndicator( minHeight: 3, value: hasMeasuredProgress ? progress / 100 : null, backgroundColor: Colors.white.withValues(alpha: 0.8), valueColor: const AlwaysStoppedAnimation(_shellAccentColor), ); } } class _LaunchOverlay extends StatelessWidget { const _LaunchOverlay({ required this.progress, required this.hasMeasuredProgress, }); final int progress; final bool hasMeasuredProgress; @override Widget build(BuildContext context) { return ColoredBox( color: _shellBackgroundColor, child: Center( child: Padding( padding: const EdgeInsets.symmetric(horizontal: 32), child: Column( mainAxisSize: MainAxisSize.min, children: [ Container( width: 88, height: 88, decoration: BoxDecoration( gradient: const LinearGradient( colors: [Color(0xFF66E59A), _shellAccentColor], begin: Alignment.topLeft, end: Alignment.bottomRight, ), borderRadius: BorderRadius.circular(28), boxShadow: const [ BoxShadow( color: Color(0x263ED37B), blurRadius: 24, offset: Offset(0, 14), ), ], ), alignment: Alignment.center, child: const Icon( Icons.public_rounded, size: 40, color: Colors.white, ), ), const SizedBox(height: 24), const Text( '页面加载中', style: TextStyle( fontSize: 24, fontWeight: FontWeight.w800, color: _shellTextColor, ), ), const SizedBox(height: 10), const Text( '正在为你启动 H5 页面', style: TextStyle(color: _shellMutedTextColor, fontSize: 14), ), const SizedBox(height: 24), ClipRRect( borderRadius: BorderRadius.circular(999), child: SizedBox( width: 220, child: LinearProgressIndicator( minHeight: 6, value: hasMeasuredProgress ? progress / 100 : null, backgroundColor: const Color(0xFFE7F3EB), valueColor: const AlwaysStoppedAnimation( _shellAccentColor, ), ), ), ), const SizedBox(height: 10), Text( hasMeasuredProgress ? '$progress%' : '连接中...', style: const TextStyle( color: Color(0xFF94A3B8), fontSize: 12, fontWeight: FontWeight.w600, ), ), ], ), ), ), ); } } class _ErrorOverlay extends StatelessWidget { const _ErrorOverlay({ required this.title, required this.message, required this.currentUrl, required this.onRetry, }); final String title; final String message; final String currentUrl; final Future Function() onRetry; @override Widget build(BuildContext context) { return ColoredBox( color: _shellBackgroundColor, child: Center( child: Padding( padding: const EdgeInsets.symmetric(horizontal: 28), child: Column( mainAxisSize: MainAxisSize.min, children: [ Container( width: 92, height: 92, decoration: BoxDecoration( color: const Color(0xFFFEF2F2), borderRadius: BorderRadius.circular(28), ), alignment: Alignment.center, child: const Icon( Icons.wifi_off_rounded, size: 44, color: Color(0xFFEF4444), ), ), const SizedBox(height: 22), Text( title, style: const TextStyle( fontSize: 22, fontWeight: FontWeight.w800, color: _shellTextColor, ), textAlign: TextAlign.center, ), const SizedBox(height: 10), Text( message, style: const TextStyle( fontSize: 14, height: 1.6, color: _shellMutedTextColor, ), textAlign: TextAlign.center, ), const SizedBox(height: 12), Text( currentUrl, style: const TextStyle(fontSize: 12, color: Color(0xFF94A3B8)), textAlign: TextAlign.center, ), const SizedBox(height: 24), FilledButton.icon( onPressed: onRetry, style: FilledButton.styleFrom( backgroundColor: _shellAccentColor, foregroundColor: Colors.black, ), icon: const Icon(Icons.refresh_rounded), label: const Text('重新加载'), ), ], ), ), ), ); } }