import 'dart:async'; import 'dart:convert'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:url_launcher/url_launcher.dart'; import 'package:webview_flutter/webview_flutter.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'); Future main() async { WidgetsFlutterBinding.ensureInitialized(); SystemChrome.setSystemUIOverlayStyle( const SystemUiOverlayStyle( statusBarColor: Colors.transparent, 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: _shellBackground, 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 WebViewController _controller; int _progress = 0; String? _loadError; bool _showShellCover = true; bool _shellBrandingLoaded = false; Timer? _shellCoverFallbackTimer; late ShellBranding _shellBranding; @override void initState() { super.initState(); _shellBranding = widget.initialShellBranding; _controller = _buildController(); unawaited(_loadHome()); } WebViewController _buildController() { return WebViewController() ..setJavaScriptMode(JavaScriptMode.unrestricted) ..addJavaScriptChannel( 'OpenIMShell', onMessageReceived: _handleShellMessage, ) ..setBackgroundColor(_shellBackground) ..setNavigationDelegate( NavigationDelegate( onProgress: (progress) { if (mounted) { setState(() => _progress = progress); } }, onPageStarted: (_) { _shellCoverFallbackTimer?.cancel(); if (mounted) { setState(() { _loadError = null; _progress = 0; _showShellCover = true; }); } }, onPageFinished: (_) { unawaited(_handlePageFinished()); }, onWebResourceError: (error) { if (error.isForMainFrame ?? true) { _shellCoverFallbackTimer?.cancel(); if (mounted) { setState(() { _loadError = error.description; _showShellCover = false; }); } } }, onNavigationRequest: _handleNavigationRequest, ), ); } @override void dispose() { _shellCoverFallbackTimer?.cancel(); super.dispose(); } Future _runJavaScriptSafely(String source) async { try { await _controller.runJavaScript(source); } catch (_) { // WebView can reject JavaScript while a page is still navigating. } } String _freshHomeUrl() { return AppConfig.homeUrl(); } Future _handlePageFinished() async { await _loadShellBrandingIfNeeded(); await _syncShellBranding(); if (mounted) { setState(() { _progress = 100; }); } _scheduleShellCoverFallback(); } void _handleShellMessage(JavaScriptMessage message) { try { final decoded = jsonDecode(message.message); if (decoded is Map && decoded['type'] == 'first-screen-ready') { _hideShellCover(); } } catch (_) { if (message.message == 'first-screen-ready') { _hideShellCover(); } } } void _scheduleShellCoverFallback() { _shellCoverFallbackTimer?.cancel(); _shellCoverFallbackTimer = Timer( const Duration(seconds: 4), _hideShellCover, ); } void _hideShellCover() { _shellCoverFallbackTimer?.cancel(); if (!mounted || !_showShellCover) { return; } setState(() { _progress = 100; _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() { 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 _loadHome() async { await _loadUrl(_freshHomeUrl()); } Future _loadUrl(String url) async { final targetUrl = AppConfig.canonicalizeMainFrameUrl(url); if (mounted) { _shellCoverFallbackTimer?.cancel(); setState(() { _loadError = null; _progress = 0; _showShellCover = true; }); } await _controller.loadRequest(Uri.parse(targetUrl)); } 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)) { if (request.isMainFrame && AppConfig.shouldRewriteMainFrameUrl(request.url)) { unawaited(_loadUrl(AppConfig.canonicalizeMainFrameUrl(request.url))); return NavigationDecision.prevent; } 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 { if (await _controller.canGoBack()) { await _controller.goBack(); } else { await SystemNavigator.pop(); } } @override Widget build(BuildContext context) { return PopScope( canPop: false, onPopInvokedWithResult: (didPop, _) { if (!didPop) { unawaited(_handleBackNavigation()); } }, child: Scaffold( backgroundColor: _shellBackground, body: SafeArea( bottom: false, child: Stack( children: [ Positioned.fill(child: _ShellFallback(progress: _progress)), WebViewWidget(controller: _controller), if (_showShellCover) Positioned.fill( child: IgnorePointer( child: _ShellFallback(progress: _progress), ), ), if (!_showShellCover && _progress < 100) LinearProgressIndicator( value: _progress == 0 ? null : _progress / 100, minHeight: 2, ), if (_loadError != null) _ErrorPanel( message: _loadError!, onRetry: () => unawaited(_loadHome()), ), ], ), ), ), ); } } 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 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; } }