web_shell_flutter/packages/web_android_shell/lib/main.dart

2079 lines
62 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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('重新加载'),
),
],
),
),
),
);
}
}