2079 lines
62 KiB
Dart
2079 lines
62 KiB
Dart
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;
|
||
}
|
||
|
||
Future<void> main() async {
|
||
WidgetsFlutterBinding.ensureInitialized();
|
||
await SystemChrome.setPreferredOrientations(const [
|
||
DeviceOrientation.portraitUp,
|
||
DeviceOrientation.portraitDown,
|
||
]);
|
||
await _enterImmersiveMode();
|
||
runApp(const WebShellApp());
|
||
}
|
||
|
||
Future<void> _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<Object?, Object?> 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<String> parts = <String>[
|
||
'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<WebShellPage> createState() => _WebShellPageState();
|
||
}
|
||
|
||
class _WebShellPageState extends State<WebShellPage>
|
||
with WidgetsBindingObserver {
|
||
final ImagePicker _imagePicker = ImagePicker();
|
||
final WebViewCookieManager _cookieManager = WebViewCookieManager();
|
||
|
||
late WebViewController _controller;
|
||
late WebViewWidget _webViewWidget;
|
||
late Future<void> _controllerSetupFuture;
|
||
late final Future<void> _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 初始化,初始地址=$_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<void> _prepareAndroidCompatibility() async {
|
||
if (defaultTargetPlatform != TargetPlatform.android) {
|
||
return;
|
||
}
|
||
|
||
try {
|
||
final Map<Object?, Object?>? rawInfo = await _appShellDeviceChannel
|
||
.invokeMapMethod<Object?, Object?>('getAndroidWebViewInfo');
|
||
if (rawInfo != null) {
|
||
_androidWebViewInfo = _AndroidWebViewInfo.fromMap(rawInfo);
|
||
}
|
||
} catch (error, stackTrace) {
|
||
debugPrint('读取 Android WebView 信息失败:$error\n$stackTrace');
|
||
}
|
||
|
||
_androidCompatibilityPlan = _AndroidCompatibilityPlan.fromInfo(
|
||
_androidWebViewInfo,
|
||
);
|
||
_renderModeIndex = 0;
|
||
debugPrint(
|
||
'WebShell Android WebView 信息:'
|
||
'${_androidWebViewInfo?.summary ?? '不可用'}',
|
||
);
|
||
debugPrint(
|
||
'WebShell 兼容策略:${_androidCompatibilityPlan.describe()}',
|
||
);
|
||
}
|
||
|
||
Future<void> _ensureCompatibilityPlanApplied() async {
|
||
await _androidCompatibilityFuture;
|
||
if (!mounted ||
|
||
_hasAppliedCompatibilityPlan ||
|
||
defaultTargetPlatform != TargetPlatform.android) {
|
||
return;
|
||
}
|
||
|
||
_hasAppliedCompatibilityPlan = true;
|
||
if (_configuredRenderMode == _activeRenderMode) {
|
||
debugPrint(
|
||
'WebShell 兼容策略保持当前 WebView '
|
||
'(${_activeRenderMode.logName})',
|
||
);
|
||
return;
|
||
}
|
||
_recreateWebView();
|
||
}
|
||
|
||
bool _switchToNextRenderMode() {
|
||
final int nextIndex = _safeRenderModeIndex + 1;
|
||
if (nextIndex >= _androidCompatibilityPlan.renderModes.length) {
|
||
return false;
|
||
}
|
||
|
||
_renderModeIndex = nextIndex;
|
||
debugPrint('WebShell 已切换渲染模式为 ${_activeRenderMode.logName}');
|
||
return true;
|
||
}
|
||
|
||
String _buildCompatibilityGuidance() {
|
||
if (defaultTargetPlatform != TargetPlatform.android) {
|
||
return '';
|
||
}
|
||
|
||
final List<String> lines = <String>[
|
||
'当前渲染策略:${_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 正在以 ${renderMode.logName} 重建 WebView #$generation',
|
||
);
|
||
PlatformWebViewWidgetCreationParams widgetParams =
|
||
PlatformWebViewWidgetCreationParams(
|
||
key: ValueKey<String>('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<void> _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 控制台 [${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 忽略非网络页面开始事件:$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 忽略非网络页面完成事件:$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(
|
||
'忽略子资源 HTTP 错误:'
|
||
'${statusCode ?? '未知'} ${requestUri ?? '未知'}',
|
||
);
|
||
return;
|
||
}
|
||
_recordWebViewEvent(
|
||
'HTTP 错误:${statusCode ?? '未知'} ${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<void> _loadInitialPage() async {
|
||
if (!mounted) {
|
||
return;
|
||
}
|
||
await _ensureCompatibilityPlanApplied();
|
||
if (!mounted) {
|
||
return;
|
||
}
|
||
debugPrint('WebShell 首帧已就绪,开始初始加载');
|
||
await _startLoadSequence(rebuildWebView: false, resetRetryCount: true);
|
||
}
|
||
|
||
Future<void> _handleBackPressed() async {
|
||
if (await _controller.canGoBack()) {
|
||
await _controller.goBack();
|
||
return;
|
||
}
|
||
await SystemNavigator.pop();
|
||
}
|
||
|
||
Future<NavigationDecision> _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 <String>{
|
||
'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<void> _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: <String>[
|
||
'${_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<void> _recoverFromBrokenStartupState({bool deepReset = false}) async {
|
||
final Future<void> controllerSetupFuture = _controllerSetupFuture;
|
||
try {
|
||
await controllerSetupFuture;
|
||
} catch (error, stackTrace) {
|
||
debugPrint('等待 WebView 控制器初始化失败:$error\n$stackTrace');
|
||
}
|
||
|
||
try {
|
||
await _controller.clearCache();
|
||
} catch (error, stackTrace) {
|
||
debugPrint('清理 WebView 缓存失败:$error\n$stackTrace');
|
||
}
|
||
|
||
try {
|
||
await _controller.clearLocalStorage();
|
||
} catch (error, stackTrace) {
|
||
debugPrint('清理 WebView 本地存储失败:$error\n$stackTrace');
|
||
}
|
||
|
||
try {
|
||
await _cookieManager.clearCookies();
|
||
} catch (error, stackTrace) {
|
||
debugPrint('清理 WebView Cookie 失败:$error\n$stackTrace');
|
||
}
|
||
|
||
if (!deepReset || defaultTargetPlatform != TargetPlatform.android) {
|
||
return;
|
||
}
|
||
|
||
try {
|
||
await _appShellDeviceChannel.invokeMethod<void>(
|
||
'resetAndroidWebViewState',
|
||
);
|
||
} catch (error, stackTrace) {
|
||
debugPrint('重置 Android WebView 状态失败:$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<void> _injectAppShellBridge(String url) async {
|
||
try {
|
||
await _controller.runJavaScript(_buildAppShellBridgeScript());
|
||
debugPrint('已为 $url 注入 AppShell 桥接');
|
||
} catch (error, stackTrace) {
|
||
debugPrint('注入 AppShell 桥接失败:$error\n$stackTrace');
|
||
}
|
||
}
|
||
|
||
Future<void> _handleBridgeMessage(String rawMessage) async {
|
||
String requestId = '';
|
||
|
||
try {
|
||
final dynamic decoded = jsonDecode(rawMessage);
|
||
if (decoded is! Map) {
|
||
return;
|
||
}
|
||
|
||
final Map<String, dynamic> message = Map<String, dynamic>.from(decoded);
|
||
requestId = (message['requestId'] ?? '').toString();
|
||
final String action = (message['action'] ?? '').toString();
|
||
final Map<String, dynamic> payload = message['payload'] is Map
|
||
? Map<String, dynamic>.from(message['payload'] as Map)
|
||
: <String, dynamic>{};
|
||
|
||
if (requestId.isEmpty || action.isEmpty) {
|
||
return;
|
||
}
|
||
|
||
debugPrint('AppShell 桥接请求: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('处理 AppShell 桥接请求失败:$error\n$stackTrace');
|
||
if (requestId.isNotEmpty) {
|
||
await _sendBridgeResponse(
|
||
requestId: requestId,
|
||
success: false,
|
||
error: error.toString(),
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
Future<Object?> _pickImagesFromBridge({
|
||
required ImageSource source,
|
||
required Map<String, dynamic> payload,
|
||
}) async {
|
||
final bool multiple =
|
||
source == ImageSource.gallery && _boolValue(payload['multiple']);
|
||
final String responseType = (payload['responseType'] ?? 'dataUrl')
|
||
.toString();
|
||
|
||
List<XFile> files = <XFile>[];
|
||
if (source == ImageSource.camera) {
|
||
final XFile? file = await _pickCameraImage(showPermissionAlert: true);
|
||
if (file != null) {
|
||
files = <XFile>[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 = <XFile>[file];
|
||
}
|
||
}
|
||
|
||
final List<Map<String, dynamic>> serialized = await _serializeXFiles(
|
||
files,
|
||
responseType: responseType,
|
||
);
|
||
return multiple ? serialized : serialized.firstOrNull;
|
||
}
|
||
|
||
Future<Object?> _pickFilesFromBridge(Map<String, dynamic> 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']) ? <Map<String, dynamic>>[] : null;
|
||
}
|
||
|
||
final List<Map<String, dynamic>> serialized = await _serializePlatformFiles(
|
||
result.files,
|
||
responseType: responseType,
|
||
);
|
||
return _boolValue(payload['multiple'])
|
||
? serialized
|
||
: serialized.firstOrNull;
|
||
}
|
||
|
||
Future<bool> _openExternalFromBridge(Map<String, dynamic> 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<Map<String, String>> _requestPermissionsFromBridge(
|
||
Map<String, dynamic> payload,
|
||
) async {
|
||
final List<String> types =
|
||
(payload['types'] as List<dynamic>? ?? const <dynamic>[])
|
||
.map((type) => type.toString())
|
||
.toList();
|
||
|
||
final Map<String, Permission> permissions = <String, Permission>{
|
||
for (final String type in types)
|
||
if (_permissionForType(type) case final Permission permission)
|
||
type: permission,
|
||
};
|
||
|
||
if (permissions.isEmpty) {
|
||
return <String, String>{};
|
||
}
|
||
|
||
final Map<Permission, PermissionStatus> statuses = await permissions.values
|
||
.toSet()
|
||
.toList()
|
||
.request();
|
||
|
||
return <String, String>{
|
||
for (final MapEntry<String, Permission> entry in permissions.entries)
|
||
entry.key: (statuses[entry.value] ?? PermissionStatus.denied).name,
|
||
};
|
||
}
|
||
|
||
Future<bool> _goBackFromBridge() async {
|
||
if (await _controller.canGoBack()) {
|
||
await _controller.goBack();
|
||
return true;
|
||
}
|
||
return false;
|
||
}
|
||
|
||
Future<void> _sendBridgeResponse({
|
||
required String requestId,
|
||
required bool success,
|
||
Object? data,
|
||
String? error,
|
||
}) async {
|
||
final Map<String, dynamic> response = <String, dynamic>{
|
||
'requestId': requestId,
|
||
'success': success,
|
||
'data': data,
|
||
'error': error,
|
||
};
|
||
|
||
try {
|
||
await _controller.runJavaScript(
|
||
'window.__appShellReceiveResponse(${jsonEncode(response)});',
|
||
);
|
||
} catch (bridgeError, stackTrace) {
|
||
debugPrint(
|
||
'发送 AppShell 桥接响应失败:$bridgeError\n$stackTrace',
|
||
);
|
||
}
|
||
}
|
||
|
||
Future<List<String>> _handleFileSelector(FileSelectorParams params) async {
|
||
debugPrint(
|
||
'WebView 文件选择: '
|
||
'accept=${params.acceptTypes}, '
|
||
'capture=${params.isCaptureEnabled}, '
|
||
'mode=${params.mode.name}',
|
||
);
|
||
|
||
if (params.mode == FileSelectorMode.save) {
|
||
return <String>[];
|
||
}
|
||
|
||
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 <XFile>[] : <XFile>[capturedImage],
|
||
);
|
||
}
|
||
|
||
if (imagesOnly) {
|
||
if (params.mode == FileSelectorMode.openMultiple) {
|
||
final List<XFile> 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 <XFile>[] : <XFile>[image],
|
||
);
|
||
}
|
||
|
||
final FilePickerResult? result = await FilePicker.platform.pickFiles(
|
||
allowMultiple: params.mode == FileSelectorMode.openMultiple,
|
||
type: FileType.any,
|
||
);
|
||
|
||
if (result == null) {
|
||
return <String>[];
|
||
}
|
||
|
||
return result.files
|
||
.map((file) => file.path)
|
||
.whereType<String>()
|
||
.map((path) => Uri.file(path).toString())
|
||
.toList();
|
||
} catch (error, stackTrace) {
|
||
debugPrint('处理文件选择失败:$error\n$stackTrace');
|
||
return <String>[];
|
||
}
|
||
}
|
||
|
||
Future<void> _handlePlatformPermissionRequest(
|
||
PlatformWebViewPermissionRequest request,
|
||
) async {
|
||
debugPrint(
|
||
'WebView 权限请求:'
|
||
'${request.types.map((type) => type.name).join(', ')}',
|
||
);
|
||
|
||
final List<Permission> permissions = <Permission>[
|
||
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<Permission, PermissionStatus> statuses = await permissions
|
||
.request();
|
||
final bool allGranted = statuses.values.every((status) => status.isGranted);
|
||
|
||
if (allGranted) {
|
||
await request.grant();
|
||
return;
|
||
}
|
||
|
||
await request.deny();
|
||
}
|
||
|
||
Future<GeolocationPermissionsResponse> _handleGeolocationPermissionRequest(
|
||
GeolocationPermissionsRequestParams request,
|
||
) async {
|
||
debugPrint('WebView 地理位置权限请求:${request.origin}');
|
||
final PermissionStatus status = await Permission.location.request();
|
||
return GeolocationPermissionsResponse(
|
||
allow: status.isGranted,
|
||
retain: status.isGranted,
|
||
);
|
||
}
|
||
|
||
Future<XFile?> _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('调用相机拍照失败:$error\n$stackTrace');
|
||
if (showPermissionAlert) {
|
||
await _showWebAlert('无法打开系统相机,请稍后重试');
|
||
}
|
||
return null;
|
||
}
|
||
}
|
||
|
||
Future<List<Map<String, dynamic>>> _serializeXFiles(
|
||
List<XFile> files, {
|
||
required String responseType,
|
||
}) async {
|
||
final bool includeBase64 =
|
||
responseType == 'base64' || responseType == 'dataUrl';
|
||
final bool includeDataUrl = responseType == 'dataUrl';
|
||
|
||
final List<Map<String, dynamic>> serialized = <Map<String, dynamic>>[];
|
||
for (final XFile file in files) {
|
||
String? base64Value;
|
||
final String mimeType = _guessMimeType(file.name);
|
||
|
||
if (includeBase64 || includeDataUrl) {
|
||
base64Value = base64Encode(await file.readAsBytes());
|
||
}
|
||
|
||
serialized.add(<String, dynamic>{
|
||
'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<List<Map<String, dynamic>>> _serializePlatformFiles(
|
||
List<PlatformFile> files, {
|
||
required String responseType,
|
||
}) async {
|
||
final bool includeBase64 =
|
||
responseType == 'base64' || responseType == 'dataUrl';
|
||
final bool includeDataUrl = responseType == 'dataUrl';
|
||
|
||
final List<Map<String, dynamic>> serialized = <Map<String, dynamic>>[];
|
||
for (final PlatformFile file in files) {
|
||
final String mimeType = _guessMimeType(file.name);
|
||
String? base64Value;
|
||
|
||
if (includeBase64 || includeDataUrl) {
|
||
final List<int>? bytes =
|
||
file.bytes ??
|
||
(file.path == null ? null : await XFile(file.path!).readAsBytes());
|
||
if (bytes != null) {
|
||
base64Value = base64Encode(bytes);
|
||
}
|
||
}
|
||
|
||
serialized.add(<String, dynamic>{
|
||
'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<bool> _openExternalUri(Uri uri) async {
|
||
try {
|
||
return await launchUrl(uri, mode: LaunchMode.externalApplication);
|
||
} catch (error, stackTrace) {
|
||
debugPrint('外部打开 URI 失败:$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<void> _showWebAlert(String message) async {
|
||
try {
|
||
await _controller.runJavaScript('window.alert(${jsonEncode(message)});');
|
||
} catch (error, stackTrace) {
|
||
debugPrint('展示网页弹窗失败:$error\n$stackTrace');
|
||
}
|
||
}
|
||
|
||
List<String> _xFilesToUriStrings(List<XFile> files) {
|
||
return files.map((file) => Uri.file(file.path).toString()).toList();
|
||
}
|
||
|
||
bool _acceptsImages(List<String> acceptTypes) {
|
||
return acceptTypes
|
||
.map((type) => type.trim())
|
||
.where((type) => type.isNotEmpty)
|
||
.any(_isImageAcceptType);
|
||
}
|
||
|
||
bool _acceptsOnlyImages(List<String> acceptTypes) {
|
||
final List<String> 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 <String>{
|
||
'.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<void> _startLoadSequence({
|
||
required bool rebuildWebView,
|
||
required bool resetRetryCount,
|
||
}) async {
|
||
_cancelStartupWatchdog();
|
||
if (!mounted) {
|
||
return;
|
||
}
|
||
if (rebuildWebView) {
|
||
_recreateWebView();
|
||
}
|
||
|
||
final int generation = _webViewGeneration;
|
||
final Future<void> 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 开始在 WebView #$generation 加载真实地址 '
|
||
'(${_activeRenderMode.logName}): $_initialUrl',
|
||
);
|
||
try {
|
||
_armStartupWatchdog();
|
||
await _controller.loadRequest(_initialUri);
|
||
} catch (error, stackTrace) {
|
||
debugPrint('初始地址加载失败:$error\n$stackTrace');
|
||
_setMainFrameError(
|
||
title: '地址无法加载',
|
||
message: '无法打开 $_initialUrl,请检查地址格式或网络后重试。',
|
||
);
|
||
}
|
||
}
|
||
|
||
Future<void> _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<void>.delayed(settleDelay);
|
||
if (!mounted || !_isActiveWebViewGeneration(generation)) {
|
||
return;
|
||
}
|
||
}
|
||
}
|
||
|
||
Future<void> _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('重新加载当前页面失败:$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<void>(
|
||
canPop: false,
|
||
onPopInvokedWithResult: (didPop, result) {
|
||
if (!didPop) {
|
||
unawaited(_handleBackPressed());
|
||
}
|
||
},
|
||
child: Scaffold(
|
||
backgroundColor: _shellBackgroundColor,
|
||
body: DecoratedBox(
|
||
decoration: const BoxDecoration(
|
||
gradient: LinearGradient(
|
||
colors: <Color>[_shellBackgroundColor, Color(0xFFF4FBF7)],
|
||
begin: Alignment.topCenter,
|
||
end: Alignment.bottomCenter,
|
||
),
|
||
),
|
||
child: SafeArea(
|
||
top: false,
|
||
bottom: false,
|
||
child: Stack(
|
||
children: <Widget>[
|
||
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: <Widget>[
|
||
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 平板运行。\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<Color>(_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: <Widget>[
|
||
Container(
|
||
width: 88,
|
||
height: 88,
|
||
decoration: BoxDecoration(
|
||
gradient: const LinearGradient(
|
||
colors: <Color>[Color(0xFF66E59A), _shellAccentColor],
|
||
begin: Alignment.topLeft,
|
||
end: Alignment.bottomRight,
|
||
),
|
||
borderRadius: BorderRadius.circular(28),
|
||
boxShadow: const <BoxShadow>[
|
||
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<Color>(
|
||
_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<void> 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: <Widget>[
|
||
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('重新加载'),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|