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(0xFFF8FBFF); const _shellAccent = Color(0xFF0089FF); const _shellSubText = Color(0xFF8E9AB0); const _shellBrandingChannel = MethodChannel('io.openim.flutter.im_webview_app/shell_branding'); const _androidFilePickerChannel = MethodChannel('io.openim.flutter.openim/file_picker'); 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; 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(_shellBackground) ..setNavigationDelegate( NavigationDelegate( onProgress: (progress) { if (mounted) { setState(() => _lineSlots[lineIndex].progress = progress); } }, onPageStarted: (url) { final slot = _lineSlots[lineIndex]; slot.shellCoverFallbackTimer?.cancel(); if (mounted) { setState(() { slot.setCurrentUrl(url); slot.loadError = null; slot.progress = 0; slot.showShellCover = true; }); } }, onPageFinished: (_) { unawaited(_handlePageFinished(lineIndex)); }, onUrlChange: (change) { _updateSlotUrl(lineIndex, change.url); }, onWebResourceError: (error) { if (error.isForMainFrame ?? true) { final slot = _lineSlots[lineIndex]; slot.shellCoverFallbackTimer?.cancel(); if (mounted) { setState(() { slot.loadError = error.description; slot.showShellCover = false; }); } } }, 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); if (mounted) { setState(() { _lineSlots[lineIndex].progress = 100; }); } _scheduleShellCoverFallback(lineIndex); } 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()); } } 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()); } } catch (_) { // Ignore malformed shell messages from web content. } } void _scheduleShellCoverFallback(int lineIndex) { final slot = _lineSlots[lineIndex]; slot.shellCoverFallbackTimer?.cancel(); slot.shellCoverFallbackTimer = Timer( const Duration(seconds: 4), () => _hideShellCover(lineIndex), ); } void _hideShellCover(int lineIndex) { final slot = _lineSlots[lineIndex]; slot.shellCoverFallbackTimer?.cancel(); if (!mounted || !slot.showShellCover) { return; } setState(() { slot.progress = 100; slot.showShellCover = false; }); } 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); } 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) { setState(() {}); } } Future _ensureLineLoaded(int lineIndex) async { final slot = _lineSlots[lineIndex]; if (slot.hasLoadedInitialRequest) { return; } slot.hasLoadedInitialRequest = true; if (mounted) { slot.shellCoverFallbackTimer?.cancel(); setState(() { slot.setCurrentUrl(slot.line.url); slot.loadError = null; slot.progress = 0; slot.showShellCover = true; }); } await slot.controller.loadRequest(Uri.parse(slot.line.url)); } Future _reloadCurrentLine() async { final slot = _currentSlot; slot.shellCoverFallbackTimer?.cancel(); if (mounted) { setState(() { slot.loadError = null; slot.progress = 0; slot.showShellCover = true; }); } 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); } 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; final showLineSwitch = !currentSlot.showShellCover && currentSlot.loadError == null && currentSlot.isLoginPage && bottomInset == 0; return PopScope( canPop: false, onPopInvokedWithResult: (didPop, _) { if (!didPop) { unawaited(_handleBackNavigation()); } }, child: Scaffold( backgroundColor: Colors.white, body: Stack( children: [ SafeArea( bottom: false, child: Stack( children: [ Positioned.fill( child: _ShellFallback(progress: currentSlot.progress), ), Positioned.fill( child: IndexedStack( index: _currentLineIndex, children: [ for (var index = 0; index < _lineSlots.length; index += 1) WebViewWidget( key: ValueKey(index), controller: _lineSlots[index].controller, ), ], ), ), if (currentSlot.showShellCover) Positioned.fill( child: IgnorePointer( child: _ShellFallback(progress: currentSlot.progress), ), ), if (!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: const ColoredBox(color: 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 hasLoadedInitialRequest = false; bool isLoginPage = false; String? currentUrl; Timer? shellCoverFallbackTimer; 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 progressValue = progress <= 0 ? 0.42 : (0.18 + progress.clamp(0, 100) * 0.0074); return CustomPaint( painter: const _H5LoadingBackgroundPainter(), child: LayoutBuilder( builder: (context, constraints) { final markSize = (constraints.maxWidth * 0.37).clamp(118.0, 152.0); final contentTop = constraints.maxHeight * 0.29; final progressWidth = (constraints.maxWidth * 0.28).clamp(104.0, 132.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: _shellAccent, 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), ), ], ), ), ); }, ), ); } } 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(0xFFDCEEFF)), 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 ColoredBox(color: _shellAccent), ); }, ), ], ), ), ); } } 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(0xFFE7F4FF), _shellBackground, Colors.white, ], stops: [0, 0.46, 1], ).createShader(pageRect); canvas.drawRect(pageRect, pagePaint); final topWave = Path() ..moveTo(0, 0) ..lineTo(1080, 0) ..lineTo(1080, 318) ..cubicTo(920, 246, 780, 246, 660, 318) ..cubicTo(512, 407, 387, 410, 284, 348) ..cubicTo(174, 282, 79, 281, 0, 344) ..close(); canvas.drawPath( topWave, Paint()..color = const Color(0xFFDDF1FF).withValues(alpha: 0.68), ); final bottomWave = Path() ..moveTo(1080, 855) ..lineTo(1080, 2400) ..lineTo(0, 2400) ..lineTo(0, 1942) ..cubicTo(124, 1854, 265, 1834, 423, 1883) ..cubicTo(598, 1937, 744, 1905, 862, 1788) ..cubicTo(934, 1717, 1007, 1678, 1080, 1672) ..close(); canvas.drawPath( bottomWave, Paint()..color = const Color(0xFFF4FAFF).withValues(alpha: 0.86), ); canvas.restore(); } @override bool shouldRepaint(covariant _H5LoadingBackgroundPainter oldDelegate) { return false; } } class _BubbleCheckPainter extends CustomPainter { const _BubbleCheckPainter(); static const _viewBox = Size(400, 363.33); @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 bubble = Path() ..moveTo(200, 0) ..cubicTo(89.54, 0, 0, 81.33, 0, 181.67) ..cubicTo(0, 282, 89.54, 363.33, 200, 363.33) ..cubicTo(237.89, 363.33, 273.32, 353.77, 303.52, 337.16) ..lineTo(386.67, 363.33) ..lineTo(355.95, 290.69) ..cubicTo(383.61, 260.37, 400, 222.36, 400, 181.67) ..cubicTo(400, 81.33, 310.46, 0, 200, 0) ..close(); final shadowPaint = Paint() ..color = const Color(0xFF026CD5).withValues(alpha: 0.18) ..maskFilter = const MaskFilter.blur(BlurStyle.normal, 34); canvas.save(); canvas.translate(0, 30); canvas.drawPath(bubble, shadowPaint); canvas.restore(); final bubblePaint = Paint() ..shader = const LinearGradient( begin: Alignment.topLeft, end: Alignment.bottomRight, colors: [ Color(0xFFA8DDFF), _shellAccent, Color(0xFF0066E8), ], stops: [0, 0.48, 1], ).createShader(Offset.zero & _viewBox); canvas.drawPath(bubble, 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(Offset.zero & _viewBox); canvas.drawPath(bubble, highlightPaint); final check = Path() ..moveTo(101, 163.5) ..lineTo(170.5, 233) ..lineTo(306, 97.5); canvas.drawPath( check, Paint() ..color = Colors.white ..style = PaintingStyle.stroke ..strokeWidth = 58 ..strokeCap = StrokeCap.round ..strokeJoin = StrokeJoin.round, ); canvas.restore(); } @override bool shouldRepaint(covariant _BubbleCheckPainter oldDelegate) { return false; } }