import 'dart:async'; import 'dart:convert'; import 'package:flutter/material.dart'; import 'package:flutter/services.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'; 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 _keyboardAnimationDuration = Duration(milliseconds: 250); 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() ?? ''; } 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 { late final List _h5Lines; late final List<_H5LineWebViewSlot> _lineSlots; bool _shellBrandingLoaded = false; int _currentLineIndex = 0; double _lastKeyboardInset = 0; bool _lastKeyboardVisible = false; int _keyboardSyncToken = 0; late ShellBranding _shellBranding; H5Line get _currentLine => _h5Lines[_currentLineIndex]; _H5LineWebViewSlot get _currentSlot => _lineSlots[_currentLineIndex]; @override void initState() { super.initState(); _h5Lines = AppConfig.h5Lines; _shellBranding = widget.initialShellBranding; _lineSlots = [ for (var index = 0; index < _h5Lines.length; index += 1) _H5LineWebViewSlot( line: _h5Lines[index], controller: _buildController(index), ), ]; unawaited(_ensureLineLoaded(_currentLineIndex)); } WebViewController _buildController(int lineIndex) { final controller = WebViewController( onPermissionRequest: (request) { unawaited(_handleWebViewPermissionRequest(request)); }, ) ..setJavaScriptMode(JavaScriptMode.unrestricted) ..addJavaScriptChannel( 'OpenIMShell', onMessageReceived: (message) => _handleShellMessage(lineIndex, message), ) ..addJavaScriptChannel( 'OpenIMFlutterShell', onMessageReceived: (message) => _handleFlutterShellMessage(lineIndex, message), ) ..setBackgroundColor(Colors.white) ..setNavigationDelegate( NavigationDelegate( onProgress: (progress) { final slot = _lineSlots[lineIndex]; final nextProgress = progress.clamp(0, 100).toInt(); if (slot.progress == nextProgress) { return; } slot.progress = nextProgress; if (mounted && lineIndex == _currentLineIndex && slot.isAwaitingFirstScreen) { setState(() {}); } }, onPageStarted: (url) { final slot = _lineSlots[lineIndex]; final shouldShowCover = !slot.hasPresentedFirstScreen; slot.shellCoverFallbackTimer?.cancel(); slot.setCurrentUrl(url); slot.loadError = null; slot.progress = 0; slot.showShellCover = shouldShowCover; if (mounted && lineIndex == _currentLineIndex) { setState(() {}); } }, onPageFinished: (_) { unawaited(_handlePageFinished(lineIndex)); }, onUrlChange: (change) { _updateSlotUrl(lineIndex, change.url); }, onWebResourceError: (error) { if (error.isForMainFrame ?? true) { final slot = _lineSlots[lineIndex]; slot.shellCoverFallbackTimer?.cancel(); slot.loadError = error.description; slot.showShellCover = false; if (mounted && lineIndex == _currentLineIndex) { setState(() {}); } } }, onNavigationRequest: _handleNavigationRequest, ), ); _configurePlatformController(controller); return controller; } void _configurePlatformController(WebViewController controller) { final platformController = controller.platform; if (platformController is AndroidWebViewController) { unawaited( platformController.setOnShowFileSelector(_handleAndroidFileSelection), ); } } 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, }, ); return result ?? []; } catch (_) { return []; } } @override void dispose() { for (final slot in _lineSlots) { slot.dispose(); } super.dispose(); } Future _runJavaScriptSafely(int lineIndex, String source) async { try { await _lineSlots[lineIndex].controller.runJavaScript(source); } catch (_) { // WebView can reject JavaScript while a page is still navigating. } } Future _handlePageFinished(int lineIndex) async { await _loadShellBrandingIfNeeded(); await _syncShellBranding(lineIndex); await _installRouteObserver(lineIndex); await _syncKeyboardState(lineIndex); final slot = _lineSlots[lineIndex]; slot.progress = 100; if (mounted && lineIndex == _currentLineIndex && slot.isAwaitingFirstScreen) { setState(() {}); } if (slot.showShellCover) { _scheduleShellCoverFallback(lineIndex); } else { slot.shellCoverFallbackTimer?.cancel(); } } void _handleShellMessage(int lineIndex, JavaScriptMessage message) { try { final decoded = jsonDecode(message.message); if (decoded is Map && decoded['type'] == 'first-screen-ready') { _hideShellCover(lineIndex); } else if (decoded is Map && decoded['type'] == 'route-changed') { _updateSlotUrl(lineIndex, decoded['url']?.toString()); } else if (decoded is Map && decoded['type'] == 'keyboard-bridge-ready') { unawaited(_syncKeyboardState(lineIndex)); } } catch (_) { if (message.message == 'first-screen-ready') { _hideShellCover(lineIndex); } } } void _handleFlutterShellMessage(int lineIndex, JavaScriptMessage message) { 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( lineIndex: lineIndex, requestId: requestId, audio: decoded['audio'] == true, video: decoded['video'] == true, ), ); case 'openAppSettings': unawaited(openAppSettings()); case 'keyboard-bridge-ready': unawaited(_syncKeyboardState(lineIndex)); } } catch (_) { // Ignore malformed shell messages from web content. } } void _scheduleShellCoverFallback(int lineIndex) { final slot = _lineSlots[lineIndex]; if (!slot.showShellCover) { return; } slot.shellCoverFallbackTimer?.cancel(); slot.shellCoverFallbackTimer = Timer( const Duration(milliseconds: 1800), () => _hideShellCover(lineIndex), ); } void _hideShellCover(int lineIndex) { final slot = _lineSlots[lineIndex]; slot.shellCoverFallbackTimer?.cancel(); if (slot.hasPresentedFirstScreen && !slot.showShellCover) { return; } slot.hasPresentedFirstScreen = true; slot.progress = 100; slot.showShellCover = false; if (!mounted || lineIndex != _currentLineIndex) { 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(int lineIndex) { 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(lineIndex, 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(_currentLineIndex)); }); } Future _syncKeyboardState(int lineIndex) { if (lineIndex < 0 || lineIndex >= _lineSlots.length) { return Future.value(); } 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(lineIndex, script); } Future _installRouteObserver(int lineIndex) { const script = ''' (() => { try { const notify = () => { try { window.OpenIMShell.postMessage(JSON.stringify({ type: 'route-changed', url: window.location.href })); } catch (_) {} }; if (window.__OPENIM_FLUTTER_ROUTE_OBSERVER__) { window.__OPENIM_FLUTTER_ROUTE_OBSERVER__.notify(); return; } const originalPushState = window.history.pushState; const originalReplaceState = window.history.replaceState; window.history.pushState = function() { const result = originalPushState.apply(this, arguments); notify(); return result; }; window.history.replaceState = function() { const result = originalReplaceState.apply(this, arguments); notify(); return result; }; window.addEventListener('popstate', notify); window.__OPENIM_FLUTTER_ROUTE_OBSERVER__ = { notify }; notify(); } catch (_) {} })(); '''; return _runJavaScriptSafely(lineIndex, script); } Future _handleShellMediaPermissionRequest({ required int lineIndex, required String requestId, required bool audio, required bool video, }) async { final result = await _requestNativeMediaPermissions( audio: audio, video: video, ); 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(lineIndex, 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 _updateSlotUrl(int lineIndex, String? url) { if (url == null || lineIndex < 0 || lineIndex >= _lineSlots.length) { return; } final slot = _lineSlots[lineIndex]; final changed = slot.setCurrentUrl(url); if (changed && mounted && lineIndex == _currentLineIndex) { setState(() {}); } } Future _ensureLineLoaded(int lineIndex) async { final slot = _lineSlots[lineIndex]; if (slot.hasLoadedInitialRequest) { return; } slot.hasLoadedInitialRequest = true; final shouldShowCover = !slot.hasPresentedFirstScreen; if (mounted) { slot.shellCoverFallbackTimer?.cancel(); setState(() { slot.setCurrentUrl(slot.line.url); slot.loadError = null; slot.progress = 0; slot.showShellCover = shouldShowCover; }); } await slot.controller.loadRequest(Uri.parse(slot.line.url)); } Future _reloadCurrentLine() async { final slot = _currentSlot; 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 _ensureLineLoaded(_currentLineIndex); } } Future _loadLine(int index) async { final safeIndex = index < 0 || index >= _h5Lines.length ? 0 : index; if (mounted) { setState(() { _currentLineIndex = safeIndex; }); } await _ensureLineLoaded(safeIndex); await _syncKeyboardState(safeIndex); } Widget _buildWebViewWidget(int index) { PlatformWebViewWidgetCreationParams params = PlatformWebViewWidgetCreationParams( controller: _lineSlots[index].controller.platform, ); if (_lineSlots[index].controller.platform is AndroidWebViewController) { params = AndroidWebViewWidgetCreationParams .fromPlatformWebViewWidgetCreationParams( params, displayWithHybridComposition: true, ); } return RepaintBoundary( child: WebViewWidget.fromPlatformCreationParams( key: ValueKey(index), params: params, ), ); } void _showLineSwitcher() { showModalBottomSheet( context: context, useSafeArea: true, showDragHandle: true, backgroundColor: Colors.white, shape: const RoundedRectangleBorder( borderRadius: BorderRadius.vertical(top: Radius.circular(20)), ), builder: (sheetContext) { return _LineSwitcherSheet( lines: _h5Lines, currentIndex: _currentLineIndex, onSelect: (index) { Navigator.of(sheetContext).pop(); if (index != _currentLineIndex) { unawaited(_loadLine(index)); } }, ); }, ); } 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 showLineSwitch = !currentSlot.showShellCover && currentSlot.loadError == null && currentSlot.isLoginPage && bottomInset == 0; final shouldPaintShellFallback = 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: IndexedStack( index: _currentLineIndex, children: [ for (var index = 0; index < _lineSlots.length; index += 1) _buildWebViewWidget(index), ], ), ), 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()), ), if (showLineSwitch) Positioned( left: 20, right: 20, bottom: MediaQuery.paddingOf(context).bottom + 14, child: _LineSwitchBar( currentLine: _currentLine, onSwitch: _showLineSwitcher, ), ), ], ), ), 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; bool showShellCover = true; bool hasPresentedFirstScreen = false; bool hasLoadedInitialRequest = false; bool isLoginPage = false; String? currentUrl; Timer? shellCoverFallbackTimer; bool get isAwaitingFirstScreen => !hasPresentedFirstScreen; bool setCurrentUrl(String url) { final nextIsLoginPage = AppConfig.isLoginPageUrl(url); if (currentUrl == url && isLoginPage == nextIsLoginPage) { return false; } currentUrl = url; isLoginPage = nextIsLoginPage; return true; } 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 _LineSwitchBar extends StatelessWidget { const _LineSwitchBar({ required this.currentLine, required this.onSwitch, }); final H5Line currentLine; final VoidCallback onSwitch; @override Widget build(BuildContext context) { return Center( child: ConstrainedBox( constraints: const BoxConstraints(maxWidth: 560), child: SizedBox( width: double.infinity, child: Material( color: Colors.white.withValues(alpha: 0.94), elevation: 8, shadowColor: const Color(0xFF0F4C81).withValues(alpha: 0.12), borderRadius: BorderRadius.circular(32), child: InkWell( onTap: onSwitch, borderRadius: BorderRadius.circular(32), child: Container( height: 64, padding: const EdgeInsets.symmetric(horizontal: 18), decoration: BoxDecoration( borderRadius: BorderRadius.circular(32), border: Border.all(color: const Color(0xFFE1EAF5)), ), child: Row( mainAxisAlignment: MainAxisAlignment.center, children: [ const Icon( Icons.sync_rounded, size: 19, color: _shellSubText, ), const SizedBox(width: 8), const Text( '当前: ', style: TextStyle( color: _shellSubText, fontSize: 15, height: 1.2, fontWeight: FontWeight.w600, letterSpacing: 0, ), ), Flexible( child: Text( currentLine.label, overflow: TextOverflow.ellipsis, style: const TextStyle( color: Color(0xFF17233D), fontSize: 16, height: 1.2, fontWeight: FontWeight.w700, letterSpacing: 0, ), ), ), const SizedBox(width: 12), const Text( '切换', style: TextStyle( color: _shellAccent, fontSize: 15, height: 1.2, fontWeight: FontWeight.w700, letterSpacing: 0, ), ), ], ), ), ), ), ), ), ); } } class _LineSwitcherSheet extends StatelessWidget { const _LineSwitcherSheet({ required this.lines, required this.currentIndex, required this.onSelect, }); final List lines; final int currentIndex; final ValueChanged onSelect; @override Widget build(BuildContext context) { final sheetHeight = MediaQuery.sizeOf(context).height * 0.58; final maxHeight = sheetHeight > 420 ? 420.0 : sheetHeight; return Padding( padding: const EdgeInsets.fromLTRB(20, 0, 20, 16), child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ const Text( '切换线路', style: TextStyle( color: Color(0xFF17233D), fontSize: 18, height: 1.2, fontWeight: FontWeight.w700, letterSpacing: 0, ), ), const SizedBox(height: 14), ConstrainedBox( constraints: BoxConstraints(maxHeight: maxHeight), child: ListView.separated( shrinkWrap: true, itemCount: lines.length, separatorBuilder: (context, index) => const SizedBox(height: 10), itemBuilder: (context, index) { final line = lines[index]; final selected = index == currentIndex; return _LineOptionTile( line: line, selected: selected, onTap: () => onSelect(index), ); }, ), ), ], ), ); } } class _LineOptionTile extends StatelessWidget { const _LineOptionTile({ required this.line, required this.selected, required this.onTap, }); final H5Line line; final bool selected; final VoidCallback onTap; @override Widget build(BuildContext context) { return Material( color: selected ? const Color(0xFFEFF7FF) : Colors.white, borderRadius: BorderRadius.circular(8), child: InkWell( onTap: onTap, borderRadius: BorderRadius.circular(8), child: Container( constraints: const BoxConstraints(minHeight: 64), padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), decoration: BoxDecoration( borderRadius: BorderRadius.circular(8), border: Border.all( color: selected ? _shellAccent : const Color(0xFFE1EAF5), ), ), child: Row( children: [ Icon( selected ? Icons.radio_button_checked_rounded : Icons.radio_button_unchecked_rounded, color: selected ? _shellAccent : _shellSubText, size: 22, ), const SizedBox(width: 12), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisAlignment: MainAxisAlignment.center, children: [ Text( line.label, maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle( color: Color(0xFF17233D), fontSize: 16, height: 1.2, fontWeight: FontWeight.w700, letterSpacing: 0, ), ), const SizedBox(height: 4), Text( line.displayAddress, maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle( color: _shellSubText, fontSize: 12, height: 1.2, fontWeight: FontWeight.w500, letterSpacing: 0, ), ), ], ), ), const SizedBox(width: 8), const Icon( Icons.chevron_right_rounded, color: _shellSubText, size: 22, ), ], ), ), ), ); } } 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; } }