import 'dart:async'; import 'dart:convert'; import 'dart:io'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:permission_handler/permission_handler.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:url_launcher/url_launcher.dart'; import 'package:webview_flutter/webview_flutter.dart'; import 'package:webview_flutter_android/webview_flutter_android.dart'; import 'config/app_config.dart'; const _shellBackground = Color(0xFFF6FAFF); const _shellAccent = Color(0xFF168CFF); const _shellAccentDeep = Color(0xFF0066D9); const _shellInk = Color(0xFF17233D); const _shellSubText = Color(0xFF7C8AA3); const _shellBrandingChannel = MethodChannel('io.openim.flutter.im_webview_app/shell_branding'); const _androidFilePickerChannel = MethodChannel('io.openim.flutter.openim/file_picker'); const _h5CacheChannel = MethodChannel('io.openim.flutter.im_webview_app/h5_cache'); const _keyboardAnimationDuration = Duration(milliseconds: 250); const _initialH5CacheClearedKey = 'initial_h5_cache_cleared_v1'; const _h5DomainCacheDateKey = 'h5_random_domain_date_v1'; const _h5DomainCacheSessionKey = 'h5_random_domain_session_v1'; const _h5DomainCacheUrlKey = 'h5_random_domain_url_v1'; const _lineProbeTimeout = Duration(seconds: 5); const _maxGeneratedLineAttempts = 6; final String _h5DomainProcessSession = 'session-${DateTime.now().microsecondsSinceEpoch}'; String _domainDateKey(DateTime value) { final year = value.year.toString().padLeft(4, '0'); final month = value.month.toString().padLeft(2, '0'); final day = value.day.toString().padLeft(2, '0'); return '$year-$month-$day'; } Future main() async { WidgetsFlutterBinding.ensureInitialized(); SystemChrome.setSystemUIOverlayStyle( const SystemUiOverlayStyle( statusBarColor: Colors.white, statusBarIconBrightness: Brightness.dark, systemNavigationBarColor: _shellBackground, systemNavigationBarIconBrightness: Brightness.dark, ), ); runApp(const ImWebViewApp()); } class ShellBranding { const ShellBranding({ required this.appName, required this.appLogo, }); static const fallback = ShellBranding( appName: AppConfig.appName, appLogo: AppConfig.appLogo, ); final String appName; final String appLogo; static Future load() async { try { final data = await _shellBrandingChannel .invokeMapMethod('getShellBranding'); final appName = _trim(data?['appName']); final appLogo = _trim(data?['appLogo']); return ShellBranding( appName: appName.isNotEmpty ? appName : fallback.appName, appLogo: appLogo.isNotEmpty ? appLogo : fallback.appLogo, ); } catch (_) { return fallback; } } static String _trim(String? value) => value?.trim() ?? ''; } enum _LineAvailability { checking, available, unavailable, } class ImWebViewApp extends StatelessWidget { const ImWebViewApp({ super.key, this.shellBranding = ShellBranding.fallback, }); final ShellBranding shellBranding; @override Widget build(BuildContext context) { return MaterialApp( title: shellBranding.appName, debugShowCheckedModeBanner: false, theme: ThemeData( colorScheme: ColorScheme.fromSeed(seedColor: const Color(0xFF1F6FEB)), scaffoldBackgroundColor: Colors.white, useMaterial3: true, ), home: H5ShellPage(initialShellBranding: shellBranding), ); } } class H5ShellPage extends StatefulWidget { const H5ShellPage({super.key, required this.initialShellBranding}); final ShellBranding initialShellBranding; @override State createState() => _H5ShellPageState(); } class _H5ShellPageState extends State with WidgetsBindingObserver { late H5Line _h5Line; late _H5LineWebViewSlot _lineSlot; bool _shellBrandingLoaded = false; bool _isPreparingInitialLine = true; bool _isReplacingUnavailableLine = false; double _lastKeyboardInset = 0; bool _lastKeyboardVisible = false; int _keyboardSyncToken = 0; int _lineGeneration = 0; int _h5LineAttempt = 0; late ShellBranding _shellBranding; Future? _cacheClearTask; Timer? _domainExpiryTimer; String _activeDomainDateKey = ''; _H5LineWebViewSlot get _currentSlot => _lineSlot; @override void initState() { super.initState(); WidgetsBinding.instance.addObserver(this); _h5Line = AppConfig.h5Line; _shellBranding = widget.initialShellBranding; _lineSlot = _createLineSlot(_h5Line); _scheduleDomainExpiryTimer(); unawaited(_initializeLines()); } _H5LineWebViewSlot _createLineSlot(H5Line line) { final generation = _lineGeneration; return _H5LineWebViewSlot( line: line, controller: _buildController(generation), ); } WebViewController _buildController(int generation) { final controller = WebViewController( onPermissionRequest: (request) { unawaited(_handleWebViewPermissionRequest(request)); }, ) ..setJavaScriptMode(JavaScriptMode.unrestricted) ..addJavaScriptChannel( 'OpenIMShell', onMessageReceived: (message) => _handleShellMessage( generation, message, ), ) ..addJavaScriptChannel( 'OpenIMFlutterShell', onMessageReceived: (message) => _handleFlutterShellMessage( generation, message, ), ) ..setBackgroundColor(Colors.white) ..setNavigationDelegate( NavigationDelegate( onProgress: (progress) { if (!_isCurrentLineGeneration(generation)) { return; } final slot = _lineSlot; final nextProgress = progress.clamp(0, 100).toInt(); if (slot.progress == nextProgress) { return; } slot.progress = nextProgress; if (mounted && slot.isAwaitingFirstScreen) { setState(() {}); } }, onPageStarted: (url) { if (!_isCurrentLineGeneration(generation)) { return; } final slot = _lineSlot; final shouldShowCover = !slot.hasPresentedFirstScreen; slot.shellCoverFallbackTimer?.cancel(); slot.loadError = null; slot.progress = 0; slot.showShellCover = shouldShowCover; if (mounted) { setState(() {}); } }, onPageFinished: (_) { unawaited(_handlePageFinished(generation)); }, onWebResourceError: (error) { if (!_isCurrentLineGeneration(generation)) { return; } if (error.isForMainFrame ?? true) { _handleMainFrameLoadError(error.description); } }, onNavigationRequest: _handleNavigationRequest, ), ); _configurePlatformController(controller); return controller; } void _configurePlatformController(WebViewController controller) { final platformController = controller.platform; if (platformController is AndroidWebViewController) { unawaited( platformController.setOnShowFileSelector(_handleAndroidFileSelection), ); unawaited(platformController.setMediaPlaybackRequiresUserGesture(false)); } } Future> _handleAndroidFileSelection( FileSelectorParams params, ) async { if (params.mode == FileSelectorMode.save) { return []; } try { final result = await _androidFilePickerChannel.invokeListMethod( 'pickFiles', { 'acceptTypes': params.acceptTypes, 'allowMultiple': params.mode == FileSelectorMode.openMultiple, 'capture': params.isCaptureEnabled, }, ); return result ?? []; } catch (_) { return []; } } @override void dispose() { WidgetsBinding.instance.removeObserver(this); _domainExpiryTimer?.cancel(); _lineSlot.dispose(); super.dispose(); } @override void didChangeAppLifecycleState(AppLifecycleState state) { if (state == AppLifecycleState.resumed) { unawaited(_refreshExpiredH5LinesIfNeeded()); } } Future _initializeLines() async { await _clearInitialH5CachesIfNeeded(); await _prepareAndLoadH5Line(forceNew: false); } Future _prepareAndLoadH5Line({required bool forceNew}) async { var shouldCreateNewLine = forceNew; for (var attempt = 0; attempt < _maxGeneratedLineAttempts; attempt += 1) { final line = await _loadCachedOrCreateH5Line( forceNew: shouldCreateNewLine, ); if (!mounted) { return; } _replaceLineSlotIfNeeded(line); final available = await _probeCurrentLine(); if (!mounted) { return; } if (available) { setState(() { _isPreparingInitialLine = false; }); await _loadCurrentLine(forceReload: true); _scheduleDomainExpiryTimer(); return; } shouldCreateNewLine = true; } if (!mounted) { return; } setState(() { _isPreparingInitialLine = false; _currentSlot.loadError = '当前线路暂不可用,请稍后重试'; _currentSlot.showShellCover = false; }); _scheduleDomainExpiryTimer(); } Future _loadCachedOrCreateH5Line({ required bool forceNew, }) async { final today = _domainDateKey(DateTime.now()); _activeDomainDateKey = today; try { final preferences = await SharedPreferences.getInstance(); final cachedUrl = preferences.getString(_h5DomainCacheUrlKey); final cachedLine = cachedUrl == null ? null : AppConfig.h5LineFromUrl(cachedUrl); final canUseCachedLine = !forceNew && preferences.getString(_h5DomainCacheDateKey) == today && preferences.getString(_h5DomainCacheSessionKey) == _h5DomainProcessSession && cachedLine != null; if (canUseCachedLine) { _logH5Line('复用本进程当天随机线路', cachedLine); return cachedLine; } final line = AppConfig.createRandomH5Line(attempt: _h5LineAttempt); _h5LineAttempt += 1; await preferences.setString(_h5DomainCacheDateKey, today); await preferences.setString( _h5DomainCacheSessionKey, _h5DomainProcessSession, ); await preferences.setString(_h5DomainCacheUrlKey, line.url); _logH5Line('生成当天随机线路', line); return line; } catch (_) { final line = AppConfig.createRandomH5Line(attempt: _h5LineAttempt); _h5LineAttempt += 1; _logH5Line('本地缓存不可用,生成临时随机线路', line); return line; } } Future _refreshExpiredH5LinesIfNeeded() async { final today = _domainDateKey(DateTime.now()); if (_isPreparingInitialLine || _activeDomainDateKey == today) { _scheduleDomainExpiryTimer(); return; } if (mounted) { setState(() { _isPreparingInitialLine = true; }); } await _clearAllH5Caches(); await _prepareAndLoadH5Line(forceNew: true); } void _scheduleDomainExpiryTimer() { _domainExpiryTimer?.cancel(); final now = DateTime.now(); final nextMidnight = DateTime(now.year, now.month, now.day + 1); final delay = nextMidnight.difference(now) + const Duration(seconds: 1); _domainExpiryTimer = Timer( delay, () => unawaited(_refreshExpiredH5LinesIfNeeded()), ); } Future _clearInitialH5CachesIfNeeded() async { try { final preferences = await SharedPreferences.getInstance(); if (preferences.getBool(_initialH5CacheClearedKey) == true) { return; } await _clearAllH5Caches(); await preferences.setBool(_initialH5CacheClearedKey, true); } catch (_) { await _clearAllH5Caches(); } } Future _clearAllH5Caches() { final runningTask = _cacheClearTask; if (runningTask != null) { return runningTask; } final task = _clearAllH5CachesInternal(); _cacheClearTask = task; return task.whenComplete(() { if (identical(_cacheClearTask, task)) { _cacheClearTask = null; } }); } Future _clearAllH5CachesInternal() async { await _clearNativeH5WebsiteData(); await _clearCookiesSafely(); await _clearPageStorage(_lineSlot.controller); await _clearControllerStorage(_lineSlot.controller); } Future _clearNativeH5WebsiteData() async { try { await _h5CacheChannel.invokeMethod('clearAllWebsiteData'); } catch (_) { // Older native shells may not expose the cache channel yet. } } Future _clearCookiesSafely() async { try { await WebViewCookieManager().clearCookies(); } catch (_) { // Cookie clearing is best-effort across platform WebView implementations. } } Future _clearControllerStorage(WebViewController controller) async { try { await controller.clearCache(); } catch (_) {} try { await controller.clearLocalStorage(); } catch (_) {} } Future _clearPageStorage(WebViewController controller) async { const script = ''' (() => { try { if ('caches' in window) { caches.keys().then((keys) => Promise.all(keys.map((key) => caches.delete(key)))); } } catch (_) {} try { if ('serviceWorker' in navigator) { navigator.serviceWorker.getRegistrations() .then((items) => Promise.all(items.map((item) => item.unregister()))); } } catch (_) {} try { if (typeof indexedDB !== 'undefined' && indexedDB.databases) { indexedDB.databases().then((databases) => { databases.forEach((database) => { if (database && database.name) { indexedDB.deleteDatabase(database.name); } }); }); } } catch (_) {} try { window.localStorage.clear(); } catch (_) {} try { window.sessionStorage.clear(); } catch (_) {} })(); '''; try { await controller.runJavaScript(script); } catch (_) {} } Future _probeCurrentLine() async { final generation = _lineGeneration; final line = _lineSlot.line; _setLineAvailability( _LineAvailability.checking, generation: generation, line: line, ); final available = await _isLineReachable(line); if (!mounted || generation != _lineGeneration || _lineSlot.line.url != line.url) { return false; } _setLineAvailability( available ? _LineAvailability.available : _LineAvailability.unavailable, generation: generation, line: line, ); _logH5LineDebug( '线路探测: ${line.label} ${line.url} => ${available ? '可用' : '不可用'}', ); return available; } Future _isLineReachable(H5Line line) async { final client = HttpClient()..connectionTimeout = _lineProbeTimeout; try { final request = await client.getUrl(line.uri).timeout(_lineProbeTimeout); request.followRedirects = true; request.maxRedirects = 5; final response = await request.close().timeout(_lineProbeTimeout); final available = response.statusCode >= 200 && response.statusCode < 400; unawaited(response.drain().catchError((_) {})); return available; } catch (_) { return false; } finally { client.close(force: true); } } void _setLineAvailability( _LineAvailability availability, { required int generation, required H5Line line, }) { if (!mounted || generation != _lineGeneration || _lineSlot.line.url != line.url || _lineSlot.availability == availability) { return; } setState(() { _lineSlot.availability = availability; }); } void _replaceLineSlotIfNeeded(H5Line line) { if (_h5Line.url == line.url) { _logH5Line('WebView 使用的线路', _h5Line); return; } setState(() { _replaceLineSlot(line); }); } void _replaceLineSlot(H5Line line) { _lineSlot.dispose(); _lineGeneration += 1; _h5Line = line; _lineSlot = _createLineSlot(line); _logH5Line('WebView 使用的线路', _h5Line); } void _logH5Line(String title, H5Line line) { _logH5LineDebug('$title ${line.label} => ${line.url}'); } void _logH5LineDebug(String message) { debugPrint('[H5LineDebug] $message'); } bool _isCurrentLineGeneration(int generation) { return generation == _lineGeneration; } Future _runJavaScriptSafely(String source) async { try { await _lineSlot.controller.runJavaScript(source); } catch (_) { // WebView can reject JavaScript while a page is still navigating. } } Future _handlePageFinished(int generation) async { if (!_isCurrentLineGeneration(generation)) { return; } await _loadShellBrandingIfNeeded(); if (!_isCurrentLineGeneration(generation)) { return; } await _syncShellRequestUrl(); await _syncShellBranding(); await _syncKeyboardState(); final slot = _lineSlot; slot.availability = _LineAvailability.available; slot.progress = 100; if (mounted && slot.isAwaitingFirstScreen) { setState(() {}); } if (slot.showShellCover) { _scheduleShellCoverFallback(generation); } else { slot.shellCoverFallbackTimer?.cancel(); } } void _handleShellMessage(int generation, JavaScriptMessage message) { if (!_isCurrentLineGeneration(generation)) { return; } try { final decoded = jsonDecode(message.message); if (decoded is Map && decoded['type'] == 'first-screen-ready') { _hideShellCover(generation); } else if (decoded is Map && decoded['type'] == 'keyboard-bridge-ready') { unawaited(_syncKeyboardState()); } } catch (_) { if (message.message == 'first-screen-ready') { _hideShellCover(generation); } } } void _handleFlutterShellMessage(int generation, JavaScriptMessage message) { if (!_isCurrentLineGeneration(generation)) { return; } try { final decoded = jsonDecode(message.message); if (decoded is! Map) { return; } switch (decoded['type']) { case 'requestMediaPermissions': final requestId = decoded['requestId']?.toString(); if (requestId == null || requestId.isEmpty) { return; } unawaited( _handleShellMediaPermissionRequest( generation: generation, requestId: requestId, audio: decoded['audio'] == true, video: decoded['video'] == true, ), ); case 'openAppSettings': unawaited(openAppSettings()); case 'keyboard-bridge-ready': unawaited(_syncKeyboardState()); } } catch (_) { // Ignore malformed shell messages from web content. } } void _scheduleShellCoverFallback(int generation) { if (!_isCurrentLineGeneration(generation)) { return; } final slot = _lineSlot; if (!slot.showShellCover) { return; } slot.shellCoverFallbackTimer?.cancel(); slot.shellCoverFallbackTimer = Timer( const Duration(milliseconds: 1800), () => _hideShellCover(generation), ); } void _hideShellCover(int generation) { if (!_isCurrentLineGeneration(generation)) { return; } final slot = _lineSlot; slot.shellCoverFallbackTimer?.cancel(); if (slot.hasPresentedFirstScreen && !slot.showShellCover) { return; } slot.hasPresentedFirstScreen = true; slot.progress = 100; slot.showShellCover = false; if (!mounted) { return; } setState(() {}); } Future _loadShellBrandingIfNeeded() async { if (_shellBrandingLoaded) { return; } final shellBranding = await ShellBranding.load(); _shellBrandingLoaded = true; if (!mounted) { _shellBranding = shellBranding; return; } setState(() { _shellBranding = shellBranding; }); } Future _syncShellBranding() { final payload = jsonEncode({ 'name': _shellBranding.appName, 'logo': _shellBranding.appLogo, }); final script = ''' (() => { try { const brand = $payload; window.sessionStorage.setItem('OPENIM_FLUTTER_SHELL_BRAND', JSON.stringify(brand)); window.dispatchEvent(new CustomEvent('openim-shell-branding-updated', { detail: brand })); } catch (_) {} })(); '''; return _runJavaScriptSafely(script); } Future _syncShellRequestUrl() { final url = AppConfig.originForUrl(_lineSlot.line.url); final payload = jsonEncode({'url': url}); final script = ''' (() => { try { const payload = $payload; window.sessionStorage.setItem('OPENIM_FLUTTER_SHELL_REQUEST_URL', payload.url); window.dispatchEvent(new CustomEvent('openim-shell-request-url-updated', { detail: payload })); } catch (_) {} })(); '''; return _runJavaScriptSafely(script); } void _scheduleKeyboardStateSync(double bottomInset) { final nextInset = bottomInset < 1 ? 0.0 : bottomInset; final nextVisible = nextInset > 0; if ((nextInset - _lastKeyboardInset).abs() < 1 && nextVisible == _lastKeyboardVisible) { return; } _lastKeyboardInset = nextInset; _lastKeyboardVisible = nextVisible; final token = ++_keyboardSyncToken; WidgetsBinding.instance.addPostFrameCallback((_) { if (!mounted || token != _keyboardSyncToken) { return; } unawaited(_syncKeyboardState()); }); } Future _syncKeyboardState() { final payload = jsonEncode({ 'height': _lastKeyboardInset.round(), 'visible': _lastKeyboardVisible, 'duration': _keyboardAnimationDuration.inMilliseconds, }); final script = ''' (() => { try { const keyboard = $payload; if (typeof window.__OPENIM_KEYBOARD_UPDATE__ === 'function') { window.__OPENIM_KEYBOARD_UPDATE__(keyboard); return; } if (typeof window.openIMKeyboardUpdate === 'function') { window.openIMKeyboardUpdate(keyboard); } } catch (_) {} })(); '''; return _runJavaScriptSafely(script); } Future _handleShellMediaPermissionRequest({ required int generation, required String requestId, required bool audio, required bool video, }) async { final result = await _requestNativeMediaPermissions( audio: audio, video: video, ); if (!_isCurrentLineGeneration(generation)) { return; } final payload = jsonEncode({ 'requestId': requestId, 'granted': result.granted, 'permanentlyDenied': result.permanentlyDenied, 'restricted': result.restricted, }); final script = ''' (() => { try { window.dispatchEvent(new CustomEvent('openim-shell-media-permission-result', { detail: $payload })); } catch (_) {} })(); '''; await _runJavaScriptSafely(script); } Future _handleWebViewPermissionRequest( WebViewPermissionRequest request, ) async { final requestsOnlySupportedMediaTypes = request.types.every( (type) => type == WebViewPermissionResourceType.camera || type == WebViewPermissionResourceType.microphone, ); if (!requestsOnlySupportedMediaTypes) { await request.deny(); return; } final result = await _requestNativeMediaPermissions( audio: request.types.contains(WebViewPermissionResourceType.microphone), video: request.types.contains(WebViewPermissionResourceType.camera), ); if (result.granted) { await request.grant(); } else { await request.deny(); } } Future<_NativeMediaPermissionResult> _requestNativeMediaPermissions({ required bool audio, required bool video, }) async { final permissions = [ if (audio) Permission.microphone, if (video) Permission.camera, ]; if (permissions.isEmpty) { return const _NativeMediaPermissionResult(granted: true); } var granted = true; var permanentlyDenied = false; var restricted = false; for (final permission in permissions) { final status = await permission.request(); granted = granted && status.isGranted; permanentlyDenied = permanentlyDenied || status.isPermanentlyDenied; restricted = restricted || status.isRestricted; } return _NativeMediaPermissionResult( granted: granted, permanentlyDenied: permanentlyDenied, restricted: restricted, ); } void _handleMainFrameLoadError(String description) { final slot = _lineSlot; slot.shellCoverFallbackTimer?.cancel(); slot.availability = _LineAvailability.unavailable; slot.loadError = description; slot.showShellCover = false; if (mounted) { setState(() {}); unawaited(_replaceUnavailableLine()); } } Future _replaceUnavailableLine() async { if (_isReplacingUnavailableLine) { return; } _isReplacingUnavailableLine = true; try { await _clearAllH5Caches(); if (mounted) { setState(() { _isPreparingInitialLine = true; _currentSlot.loadError = null; _currentSlot.progress = 0; _currentSlot.showShellCover = true; }); } await _prepareAndLoadH5Line(forceNew: true); } finally { _isReplacingUnavailableLine = false; } } Future _loadCurrentLine({bool forceReload = false}) async { final slot = _lineSlot; if (slot.hasLoadedInitialRequest && !forceReload) { return; } slot.hasLoadedInitialRequest = true; final shouldShowCover = !slot.hasPresentedFirstScreen; if (mounted) { slot.shellCoverFallbackTimer?.cancel(); setState(() { slot.loadError = null; slot.progress = 0; slot.showShellCover = shouldShowCover; }); } await slot.controller.loadRequest(Uri.parse(slot.line.url)); await _syncKeyboardState(); } Future _reloadCurrentLine() async { final slot = _currentSlot; if (slot.availability == _LineAvailability.unavailable || slot.loadError != null) { await _replaceUnavailableLine(); return; } final shouldShowCover = !slot.hasPresentedFirstScreen; slot.shellCoverFallbackTimer?.cancel(); if (mounted) { setState(() { slot.loadError = null; slot.progress = 0; slot.showShellCover = shouldShowCover; }); } if (slot.hasLoadedInitialRequest) { await slot.controller.reload(); } else { await _loadCurrentLine(); } } Widget _buildWebViewWidget() { PlatformWebViewWidgetCreationParams params = PlatformWebViewWidgetCreationParams( controller: _lineSlot.controller.platform, ); if (_lineSlot.controller.platform is AndroidWebViewController) { params = AndroidWebViewWidgetCreationParams .fromPlatformWebViewWidgetCreationParams( params, displayWithHybridComposition: true, ); } return RepaintBoundary( child: WebViewWidget.fromPlatformCreationParams( key: ValueKey(_lineSlot.line.url), params: params, ), ); } Future _handleNavigationRequest( NavigationRequest request, ) async { final uri = Uri.tryParse(request.url); if (uri == null) { return NavigationDecision.prevent; } const webSchemes = {'http', 'https', 'about', 'data'}; if (webSchemes.contains(uri.scheme)) { return NavigationDecision.navigate; } try { await launchUrl(uri, mode: LaunchMode.externalApplication); } catch (_) { // Ignore unsupported custom schemes so the WebView does not navigate to // an error page. } return NavigationDecision.prevent; } Future _handleBackNavigation() async { final controller = _currentSlot.controller; if (await controller.canGoBack()) { await controller.goBack(); } else { await SystemNavigator.pop(); } } @override Widget build(BuildContext context) { final currentSlot = _currentSlot; final topInset = MediaQuery.paddingOf(context).top; final bottomInset = MediaQuery.viewInsetsOf(context).bottom; _scheduleKeyboardStateSync(bottomInset); final shouldPaintShellFallback = (_isPreparingInitialLine || currentSlot.isAwaitingFirstScreen) && currentSlot.loadError == null; return PopScope( canPop: false, onPopInvokedWithResult: (didPop, _) { if (!didPop) { unawaited(_handleBackNavigation()); } }, child: Scaffold( backgroundColor: Colors.white, body: Stack( children: [ SafeArea( bottom: false, child: Stack( children: [ if (shouldPaintShellFallback) Positioned.fill( child: _ShellFallback(progress: currentSlot.progress), ), Positioned.fill( child: _buildWebViewWidget(), ), if (currentSlot.showShellCover && !currentSlot.hasPresentedFirstScreen) Positioned.fill( child: IgnorePointer( child: _ShellFallback(progress: currentSlot.progress), ), ), if (!currentSlot.hasPresentedFirstScreen && !currentSlot.showShellCover && currentSlot.progress < 100) LinearProgressIndicator( value: currentSlot.progress == 0 ? null : currentSlot.progress / 100, minHeight: 2, ), if (currentSlot.loadError != null) _ErrorPanel( message: currentSlot.loadError!, onRetry: () => unawaited(_reloadCurrentLine()), ), ], ), ), Positioned( top: 0, left: 0, right: 0, height: topInset, child: ColoredBox( color: shouldPaintShellFallback ? _shellBackground : Colors.white, ), ), ], ), ), ); } } class _NativeMediaPermissionResult { const _NativeMediaPermissionResult({ required this.granted, this.permanentlyDenied = false, this.restricted = false, }); final bool granted; final bool permanentlyDenied; final bool restricted; } class _H5LineWebViewSlot { _H5LineWebViewSlot({ required this.line, required this.controller, }); final H5Line line; final WebViewController controller; int progress = 0; String? loadError; _LineAvailability availability = _LineAvailability.checking; bool showShellCover = true; bool hasPresentedFirstScreen = false; bool hasLoadedInitialRequest = false; Timer? shellCoverFallbackTimer; bool get isAwaitingFirstScreen => !hasPresentedFirstScreen; void dispose() { shellCoverFallbackTimer?.cancel(); } } class _ErrorPanel extends StatelessWidget { const _ErrorPanel({required this.message, required this.onRetry}); final String message; final VoidCallback onRetry; @override Widget build(BuildContext context) { return ColoredBox( color: Colors.white, child: Center( child: Padding( padding: const EdgeInsets.all(24), child: Column( mainAxisSize: MainAxisSize.min, children: [ const Icon(Icons.wifi_off_rounded, size: 44), const SizedBox(height: 16), const Text( '页面加载失败', style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600), ), const SizedBox(height: 8), Text( message, textAlign: TextAlign.center, style: Theme.of(context).textTheme.bodyMedium, ), const SizedBox(height: 20), FilledButton( onPressed: onRetry, child: const Text('重新加载'), ), ], ), ), ), ); } } class _ShellFallback extends StatelessWidget { const _ShellFallback({required this.progress}); final int progress; @override Widget build(BuildContext context) { final clampedProgress = progress.clamp(0, 100).toDouble(); final progressValue = progress <= 0 ? 0.28 : 0.22 + clampedProgress * 0.0072; return CustomPaint( painter: const _H5LoadingBackgroundPainter(), child: LayoutBuilder( builder: (context, constraints) { final markSize = (constraints.maxWidth * 0.34).clamp(116.0, 148.0); final contentTop = constraints.maxHeight * 0.28; final progressWidth = (constraints.maxWidth * 0.32).clamp(116.0, 152.0); return Align( alignment: Alignment.topCenter, child: Padding( padding: EdgeInsets.only(top: contentTop), child: Column( mainAxisSize: MainAxisSize.min, children: [ _ShellMark(size: markSize), SizedBox(height: markSize * 0.16), const Text( '心有回响', style: TextStyle( color: _shellInk, fontSize: 24, height: 1.2, fontWeight: FontWeight.w700, letterSpacing: 0, ), ), const SizedBox(height: 8), const Text( '正在连接会话', style: TextStyle( color: _shellSubText, fontSize: 12, height: 1.2, letterSpacing: 0, ), ), const SizedBox(height: 30), _H5LoadingProgress( width: progressWidth, value: progressValue.clamp(0.18, 0.96).toDouble(), ), ], ), ), ); }, ), ); } } class _ShellMark extends StatelessWidget { const _ShellMark({required this.size}); final double size; @override Widget build(BuildContext context) { return CustomPaint( size: Size(size, size * 0.92), painter: const _BubbleCheckPainter(), ); } } class _H5LoadingProgress extends StatelessWidget { const _H5LoadingProgress({required this.width, required this.value}); final double width; final double value; @override Widget build(BuildContext context) { return ClipRRect( borderRadius: BorderRadius.circular(99), child: SizedBox( width: width, height: 4, child: Stack( fit: StackFit.expand, children: [ const ColoredBox(color: Color(0xFFD9EAF8)), TweenAnimationBuilder( tween: Tween(end: value), duration: const Duration(milliseconds: 260), curve: Curves.easeOutCubic, builder: (context, animatedValue, _) { return FractionallySizedBox( alignment: Alignment.centerLeft, widthFactor: animatedValue, child: const DecoratedBox( decoration: BoxDecoration( gradient: LinearGradient( colors: [ Color(0xFF48D4C8), _shellAccent, _shellAccentDeep, ], ), ), ), ); }, ), ], ), ), ); } } class _H5LoadingBackgroundPainter extends CustomPainter { const _H5LoadingBackgroundPainter(); static const _sourceSize = Size(1080, 2400); @override void paint(Canvas canvas, Size size) { final scale = (size.width / _sourceSize.width) > (size.height / _sourceSize.height) ? size.width / _sourceSize.width : size.height / _sourceSize.height; final dx = (size.width - _sourceSize.width * scale) / 2; final dy = (size.height - _sourceSize.height * scale) / 2; canvas.save(); canvas.translate(dx, dy); canvas.scale(scale); final pageRect = Offset.zero & _sourceSize; final pagePaint = Paint() ..shader = const LinearGradient( begin: Alignment.topCenter, end: Alignment.bottomCenter, colors: [ Color(0xFFEAF7FF), Color(0xFFF8FCFF), Colors.white, ], stops: [0, 0.52, 1], ).createShader(pageRect); canvas.drawRect(pageRect, pagePaint); final topWave = Path() ..moveTo(0, 0) ..lineTo(1080, 0) ..lineTo(1080, 390) ..cubicTo(906, 332, 756, 330, 630, 386) ..cubicTo(478, 454, 332, 448, 214, 374) ..cubicTo(124, 318, 54, 310, 0, 350) ..close(); canvas.drawPath( topWave, Paint()..color = const Color(0xFFDDF2FF).withValues(alpha: 0.72), ); final bottomWave = Path() ..moveTo(1080, 1510) ..lineTo(1080, 2400) ..lineTo(0, 2400) ..lineTo(0, 1878) ..cubicTo(142, 1802, 294, 1786, 456, 1832) ..cubicTo(632, 1882, 786, 1832, 918, 1682) ..cubicTo(978, 1614, 1032, 1572, 1080, 1510) ..close(); canvas.drawPath( bottomWave, Paint()..color = const Color(0xFFEFF9FF).withValues(alpha: 0.9), ); final softPanelPaint = Paint() ..color = Colors.white.withValues(alpha: 0.42) ..maskFilter = const MaskFilter.blur(BlurStyle.normal, 18); canvas.drawRRect( RRect.fromRectAndRadius( const Rect.fromLTWH(126, 688, 828, 136), const Radius.circular(42), ), softPanelPaint, ); canvas.drawRRect( RRect.fromRectAndRadius( const Rect.fromLTWH(180, 1528, 720, 128), const Radius.circular(40), ), softPanelPaint, ); final linePaint = Paint() ..color = const Color(0xFFB6D7F2).withValues(alpha: 0.32) ..strokeWidth = 2; for (var y = 470.0; y < 840; y += 72) { canvas.drawLine(Offset(130, y), Offset(950, y - 46), linePaint); } void drawMessageCard({ required Rect rect, required Color color, required double alpha, required bool alignRight, }) { final cardPaint = Paint()..color = color.withValues(alpha: alpha); canvas.drawRRect( RRect.fromRectAndRadius(rect, const Radius.circular(44)), cardPaint, ); final firstLineWidth = rect.width * (alignRight ? 0.48 : 0.56); final secondLineWidth = rect.width * 0.34; final x = alignRight ? rect.right - firstLineWidth - 44 : rect.left + 44; final lineY = rect.top + rect.height * 0.38; final messageLinePaint = Paint() ..color = const Color(0xFF9FC2DE).withValues(alpha: 0.38) ..strokeCap = StrokeCap.round ..strokeWidth = 12; canvas.drawLine( Offset(x, lineY), Offset(x + firstLineWidth, lineY), messageLinePaint, ); canvas.drawLine( Offset(x, lineY + 34), Offset(x + secondLineWidth, lineY + 34), messageLinePaint, ); } drawMessageCard( rect: const Rect.fromLTWH(96, 1008, 438, 118), color: Colors.white, alpha: 0.52, alignRight: false, ); drawMessageCard( rect: const Rect.fromLTWH(548, 1160, 436, 118), color: const Color(0xFFE0F7F4), alpha: 0.56, alignRight: true, ); canvas.restore(); } @override bool shouldRepaint(covariant _H5LoadingBackgroundPainter oldDelegate) { return false; } } class _BubbleCheckPainter extends CustomPainter { const _BubbleCheckPainter(); static const _viewBox = Size(420, 360); @override void paint(Canvas canvas, Size size) { final scale = (size.width / _viewBox.width) < (size.height / _viewBox.height) ? size.width / _viewBox.width : size.height / _viewBox.height; final dx = (size.width - _viewBox.width * scale) / 2; final dy = (size.height - _viewBox.height * scale) / 2; canvas.save(); canvas.translate(dx, dy); canvas.scale(scale); final shadowPaint = Paint() ..color = const Color(0xFF026CD5).withValues(alpha: 0.18) ..maskFilter = const MaskFilter.blur(BlurStyle.normal, 32); final backShadowPaint = Paint() ..color = const Color(0xFF0AAE9D).withValues(alpha: 0.12) ..maskFilter = const MaskFilter.blur(BlurStyle.normal, 24); final backBubble = RRect.fromRectAndRadius( const Rect.fromLTWH(34, 42, 176, 124), const Radius.circular(54), ); canvas.save(); canvas.translate(0, 18); canvas.drawRRect(backBubble, backShadowPaint); canvas.restore(); final backBubblePaint = Paint() ..shader = const LinearGradient( begin: Alignment.topLeft, end: Alignment.bottomRight, colors: [ Color(0xFFE8FBF8), Color(0xFFAEECE4), ], ).createShader(backBubble.outerRect); canvas.drawRRect(backBubble, backBubblePaint); final frontRect = const Rect.fromLTWH(78, 86, 284, 190); final frontBubble = RRect.fromRectAndRadius( frontRect, const Radius.circular(72), ); final frontTail = Path() ..moveTo(282, 250) ..cubicTo(316, 284, 352, 300, 388, 310) ..cubicTo(360, 284, 348, 252, 354, 222) ..close(); canvas.save(); canvas.translate(0, 22); canvas.drawRRect(frontBubble, shadowPaint); canvas.drawPath(frontTail, shadowPaint); canvas.restore(); final bubblePaint = Paint() ..shader = const LinearGradient( begin: Alignment.topLeft, end: Alignment.bottomRight, colors: [ Color(0xFFA8DDFF), _shellAccent, _shellAccentDeep, ], stops: [0, 0.48, 1], ).createShader(frontRect); canvas.drawPath(frontTail, bubblePaint); canvas.drawRRect(frontBubble, bubblePaint); final highlightPaint = Paint() ..shader = LinearGradient( begin: Alignment.topLeft, end: Alignment.bottomRight, colors: [ Colors.white.withValues(alpha: 0.38), Colors.white.withValues(alpha: 0), ], ).createShader(frontRect); canvas.drawRRect(frontBubble, highlightPaint); final glyphPaint = Paint() ..color = Colors.white.withValues(alpha: 0.94) ..style = PaintingStyle.stroke ..strokeWidth = 22 ..strokeCap = StrokeCap.round ..strokeJoin = StrokeJoin.round; canvas.drawLine(const Offset(154, 154), const Offset(288, 154), glyphPaint); canvas.drawLine(const Offset(154, 204), const Offset(238, 204), glyphPaint); canvas.drawCircle( const Offset(306, 204), 12, Paint()..color = Colors.white.withValues(alpha: 0.94), ); canvas.restore(); } @override bool shouldRepaint(covariant _BubbleCheckPainter oldDelegate) { return false; } }