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 _showH5DebugOverlay = bool.fromEnvironment( 'H5_SHELL_DEBUG', defaultValue: true, ); const _shellBrandingChannel = MethodChannel('io.openim.flutter.im_webview_app/shell_branding'); const _h5MountedCheckScript = r''' (() => document.body?.classList.contains('app-mounted') === true)(); '''; const _h5SnapshotScript = r''' (() => { const shrinkAssetUrl = (value) => { try { const absolute = new URL(value, window.location.href).href; const match = absolute.match(/\/assets\/[^?#]+/); return match ? match[0] : absolute; } catch (_) { return value || ''; } }; const scripts = Array.from(document.scripts) .map((script) => script.src) .filter(Boolean) .map(shrinkAssetUrl) .filter((src) => src.includes('/assets/')); const bodyText = (document.body?.innerText || '') .replace(/\s+/g, ' ') .slice(0, 180); let shellBrand = ''; try { shellBrand = window.sessionStorage.getItem('OPENIM_FLUTTER_SHELL_BRAND') || ''; } catch (_) {} return JSON.stringify({ href: window.location.href, scripts, bodyText, shellBrand, }); })(); '''; 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; String _h5DebugText = 'H5 loading...'; bool _showShellCover = true; bool _shellBrandingLoaded = false; late ShellBranding _shellBranding; @override void initState() { super.initState(); _shellBranding = widget.initialShellBranding; _controller = _buildController(); unawaited(_loadHome()); } WebViewController _buildController() { return WebViewController() ..setJavaScriptMode(JavaScriptMode.unrestricted) ..setBackgroundColor(_shellBackground) ..setNavigationDelegate( NavigationDelegate( onProgress: (progress) { if (mounted) { setState(() => _progress = progress); } }, onPageStarted: (_) { if (mounted) { setState(() { _loadError = null; _progress = 0; _showShellCover = true; }); } }, onPageFinished: (_) { unawaited(_handlePageFinished()); }, onWebResourceError: (error) { if (error.isForMainFrame ?? true) { if (mounted) { setState(() { _loadError = error.description; _showShellCover = false; }); } } }, onNavigationRequest: _handleNavigationRequest, ), ); } 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 _waitForH5Mounted(); await _syncShellBranding(); await _updateH5DebugSnapshot(); if (mounted) { 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 _waitForH5Mounted() async { for (var index = 0; index < 20; index += 1) { try { final result = await _controller.runJavaScriptReturningResult( _h5MountedCheckScript, ); if (result == true || result.toString() == 'true') { return; } } catch (_) {} await Future.delayed(const Duration(milliseconds: 100)); } } Future _updateH5DebugSnapshot() async { if (!_showH5DebugOverlay) { return; } try { final result = await _controller.runJavaScriptReturningResult( _h5SnapshotScript, ); final snapshot = _decodeJavaScriptStringResult(result); debugPrint('[H5Shell] snapshot: $snapshot'); if (mounted) { setState(() => _h5DebugText = snapshot); } } catch (error) { debugPrint('[H5Shell] snapshot failed: $error'); if (mounted) { setState(() => _h5DebugText = 'H5 snapshot failed: $error'); } } } String _decodeJavaScriptStringResult(Object? result) { if (result == null) { return ''; } if (result is String) { try { final decoded = jsonDecode(result); if (decoded is String) { return decoded; } } catch (_) {} return result; } return result.toString(); } Future _loadHome() async { await _loadUrl(_freshHomeUrl()); } Future _loadUrl(String url) async { if (mounted) { setState(() { _loadError = null; _progress = 0; _showShellCover = true; }); } await _clearWebViewHttpCache(); await _controller.loadRequest(Uri.parse(url)); } Future _clearWebViewHttpCache() async { try { await _controller.clearCache(); } catch (_) { // Some WebView implementations can reject cache operations before first use. } } 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 { 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: [ const Positioned.fill(child: _ShellFallback()), WebViewWidget(controller: _controller), if (_showShellCover) const Positioned.fill( child: IgnorePointer(child: _ShellFallback()), ), if (_progress < 100) LinearProgressIndicator( value: _progress == 0 ? null : _progress / 100, minHeight: 2, ), if (_loadError != null) _ErrorPanel( message: _loadError!, onRetry: () => unawaited(_loadHome()), ), if (_showH5DebugOverlay) Positioned( left: 8, right: 8, bottom: 10, child: IgnorePointer( child: _H5DebugOverlay(text: _h5DebugText), ), ), ], ), ), ), ); } } 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 _H5DebugOverlay extends StatelessWidget { const _H5DebugOverlay({required this.text}); final String text; @override Widget build(BuildContext context) { return DecoratedBox( decoration: BoxDecoration( color: Colors.black.withValues(alpha: 0.72), borderRadius: BorderRadius.circular(6), ), child: Padding( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6), child: Text( text, maxLines: 5, overflow: TextOverflow.ellipsis, style: const TextStyle( color: Colors.white, fontSize: 10, height: 1.25, letterSpacing: 0, ), ), ), ); } } class _ShellFallback extends StatelessWidget { const _ShellFallback(); @override Widget build(BuildContext context) { return DecoratedBox( decoration: const BoxDecoration( gradient: LinearGradient( begin: Alignment.topCenter, end: Alignment.bottomCenter, colors: [ Color(0xFFE7F4FF), _shellBackground, Colors.white, ], stops: [0, 0.44, 1], ), ), child: Stack( children: [ const Positioned( top: 44, right: -72, child: _SoftCircle(size: 190, opacity: 0.42), ), const Positioned( top: 210, left: -44, child: _SoftCircle(size: 160, opacity: 0.34), ), const Positioned( right: -54, bottom: 250, child: _SoftCircle(size: 220, opacity: 0.38), ), Center( child: Column( mainAxisSize: MainAxisSize.min, children: const [ _ShellMark(), SizedBox(height: 26), Text( '心有回响', style: TextStyle( color: _shellAccent, fontSize: 26, height: 1.2, fontWeight: FontWeight.w700, letterSpacing: 0, ), ), SizedBox(height: 10), Text( '正在为你唤醒会话', style: TextStyle( color: _shellSubText, fontSize: 13, height: 1.2, letterSpacing: 0, ), ), SizedBox(height: 32), SizedBox( width: 110, child: LinearProgressIndicator( minHeight: 3, backgroundColor: Color(0xFFDCEEFF), color: _shellAccent, ), ), ], ), ), ], ), ); } } class _ShellMark extends StatelessWidget { const _ShellMark(); @override Widget build(BuildContext context) { return Container( width: 126, height: 126, decoration: BoxDecoration( shape: BoxShape.circle, gradient: const LinearGradient( begin: Alignment.topLeft, end: Alignment.bottomRight, colors: [Color(0xFFA8DDFF), _shellAccent, Color(0xFF0066E8)], ), boxShadow: [ BoxShadow( color: _shellAccent.withValues(alpha: 0.2), blurRadius: 34, offset: const Offset(0, 18), ), ], ), child: const Icon( Icons.check_rounded, color: Colors.white, size: 82, ), ); } } class _SoftCircle extends StatelessWidget { const _SoftCircle({required this.size, required this.opacity}); final double size; final double opacity; @override Widget build(BuildContext context) { return Container( width: size, height: size, decoration: BoxDecoration( shape: BoxShape.circle, color: const Color(0xFFBFE4FF).withValues(alpha: opacity), ), ); } }