diff --git a/android/app/src/main/res/drawable-nodpi/splash_android12_icon.png b/android/app/src/main/res/drawable-nodpi/splash_android12_icon.png index 0b9336c..bc77ab8 100644 Binary files a/android/app/src/main/res/drawable-nodpi/splash_android12_icon.png and b/android/app/src/main/res/drawable-nodpi/splash_android12_icon.png differ diff --git a/android/app/src/main/res/drawable-nodpi/splash_content.png b/android/app/src/main/res/drawable-nodpi/splash_content.png index 66e7749..9eec6a4 100644 Binary files a/android/app/src/main/res/drawable-nodpi/splash_content.png and b/android/app/src/main/res/drawable-nodpi/splash_content.png differ diff --git a/android/app/src/main/res/drawable-v21/launch_background.xml b/android/app/src/main/res/drawable-v21/launch_background.xml index 2ea5839..b891b80 100644 --- a/android/app/src/main/res/drawable-v21/launch_background.xml +++ b/android/app/src/main/res/drawable-v21/launch_background.xml @@ -4,8 +4,8 @@ diff --git a/android/app/src/main/res/drawable/launch_background.xml b/android/app/src/main/res/drawable/launch_background.xml index 2ea5839..b891b80 100644 --- a/android/app/src/main/res/drawable/launch_background.xml +++ b/android/app/src/main/res/drawable/launch_background.xml @@ -4,8 +4,8 @@ diff --git a/android/app/src/main/res/values-night-v31/styles.xml b/android/app/src/main/res/values-night-v31/styles.xml index 821b936..55b8c5f 100644 --- a/android/app/src/main/res/values-night-v31/styles.xml +++ b/android/app/src/main/res/values-night-v31/styles.xml @@ -2,13 +2,13 @@ diff --git a/android/app/src/main/res/values-night/styles.xml b/android/app/src/main/res/values-night/styles.xml index 1695eb2..8d08103 100644 --- a/android/app/src/main/res/values-night/styles.xml +++ b/android/app/src/main/res/values-night/styles.xml @@ -13,7 +13,7 @@ This Theme is only used starting with V2 of Flutter's Android embedding. --> diff --git a/android/app/src/main/res/values-v31/styles.xml b/android/app/src/main/res/values-v31/styles.xml index 821b936..55b8c5f 100644 --- a/android/app/src/main/res/values-v31/styles.xml +++ b/android/app/src/main/res/values-v31/styles.xml @@ -2,13 +2,13 @@ diff --git a/android/app/src/main/res/values/styles.xml b/android/app/src/main/res/values/styles.xml index 5f4ffa8..262eacb 100644 --- a/android/app/src/main/res/values/styles.xml +++ b/android/app/src/main/res/values/styles.xml @@ -13,7 +13,7 @@ This Theme is only used starting with V2 of Flutter's Android embedding. --> diff --git a/lib/main.dart b/lib/main.dart index 6c6da7d..6ac7d8a 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -10,9 +10,11 @@ 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 _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 = @@ -138,24 +140,33 @@ class _H5ShellPageState extends State { onMessageReceived: (message) => _handleFlutterShellMessage(lineIndex, message), ) - ..setBackgroundColor(_shellBackground) + ..setBackgroundColor(Colors.white) ..setNavigationDelegate( NavigationDelegate( onProgress: (progress) { - if (mounted) { - setState(() => _lineSlots[lineIndex].progress = 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(); - if (mounted) { - setState(() { - slot.setCurrentUrl(url); - slot.loadError = null; - slot.progress = 0; - slot.showShellCover = true; - }); + slot.setCurrentUrl(url); + slot.loadError = null; + slot.progress = 0; + slot.showShellCover = shouldShowCover; + if (mounted && lineIndex == _currentLineIndex) { + setState(() {}); } }, onPageFinished: (_) { @@ -168,11 +179,10 @@ class _H5ShellPageState extends State { if (error.isForMainFrame ?? true) { final slot = _lineSlots[lineIndex]; slot.shellCoverFallbackTimer?.cancel(); - if (mounted) { - setState(() { - slot.loadError = error.description; - slot.showShellCover = false; - }); + slot.loadError = error.description; + slot.showShellCover = false; + if (mounted && lineIndex == _currentLineIndex) { + setState(() {}); } } }, @@ -234,12 +244,18 @@ class _H5ShellPageState extends State { await _loadShellBrandingIfNeeded(); await _syncShellBranding(lineIndex); await _installRouteObserver(lineIndex); - if (mounted) { - setState(() { - _lineSlots[lineIndex].progress = 100; - }); + final slot = _lineSlots[lineIndex]; + slot.progress = 100; + if (mounted && + lineIndex == _currentLineIndex && + slot.isAwaitingFirstScreen) { + setState(() {}); + } + if (slot.showShellCover) { + _scheduleShellCoverFallback(lineIndex); + } else { + slot.shellCoverFallbackTimer?.cancel(); } - _scheduleShellCoverFallback(lineIndex); } void _handleShellMessage(int lineIndex, JavaScriptMessage message) { @@ -288,9 +304,13 @@ class _H5ShellPageState extends State { void _scheduleShellCoverFallback(int lineIndex) { final slot = _lineSlots[lineIndex]; + if (!slot.showShellCover) { + return; + } + slot.shellCoverFallbackTimer?.cancel(); slot.shellCoverFallbackTimer = Timer( - const Duration(seconds: 4), + const Duration(milliseconds: 1800), () => _hideShellCover(lineIndex), ); } @@ -298,14 +318,18 @@ class _H5ShellPageState extends State { void _hideShellCover(int lineIndex) { final slot = _lineSlots[lineIndex]; slot.shellCoverFallbackTimer?.cancel(); - if (!mounted || !slot.showShellCover) { + if (slot.hasPresentedFirstScreen && !slot.showShellCover) { return; } - setState(() { - slot.progress = 100; - slot.showShellCover = false; - }); + slot.hasPresentedFirstScreen = true; + slot.progress = 100; + slot.showShellCover = false; + if (!mounted || lineIndex != _currentLineIndex) { + return; + } + + setState(() {}); } Future _loadShellBrandingIfNeeded() async { @@ -473,7 +497,7 @@ class _H5ShellPageState extends State { final slot = _lineSlots[lineIndex]; final changed = slot.setCurrentUrl(url); - if (changed && mounted) { + if (changed && mounted && lineIndex == _currentLineIndex) { setState(() {}); } } @@ -485,13 +509,14 @@ class _H5ShellPageState extends State { } 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 = true; + slot.showShellCover = shouldShowCover; }); } @@ -500,12 +525,13 @@ class _H5ShellPageState extends State { Future _reloadCurrentLine() async { final slot = _currentSlot; + final shouldShowCover = !slot.hasPresentedFirstScreen; slot.shellCoverFallbackTimer?.cancel(); if (mounted) { setState(() { slot.loadError = null; slot.progress = 0; - slot.showShellCover = true; + slot.showShellCover = shouldShowCover; }); } @@ -527,6 +553,28 @@ class _H5ShellPageState extends State { await _ensureLineLoaded(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, @@ -591,6 +639,8 @@ class _H5ShellPageState extends State { currentSlot.loadError == null && currentSlot.isLoginPage && bottomInset == 0; + final shouldPaintShellFallback = + currentSlot.isAwaitingFirstScreen && currentSlot.loadError == null; return PopScope( canPop: false, @@ -607,9 +657,10 @@ class _H5ShellPageState extends State { bottom: false, child: Stack( children: [ - Positioned.fill( - child: _ShellFallback(progress: currentSlot.progress), - ), + if (shouldPaintShellFallback) + Positioned.fill( + child: _ShellFallback(progress: currentSlot.progress), + ), Positioned.fill( child: IndexedStack( index: _currentLineIndex, @@ -617,20 +668,20 @@ class _H5ShellPageState extends State { for (var index = 0; index < _lineSlots.length; index += 1) - WebViewWidget( - key: ValueKey(index), - controller: _lineSlots[index].controller, - ), + _buildWebViewWidget(index), ], ), ), - if (currentSlot.showShellCover) + if (currentSlot.showShellCover && + !currentSlot.hasPresentedFirstScreen) Positioned.fill( child: IgnorePointer( child: _ShellFallback(progress: currentSlot.progress), ), ), - if (!currentSlot.showShellCover && currentSlot.progress < 100) + if (!currentSlot.hasPresentedFirstScreen && + !currentSlot.showShellCover && + currentSlot.progress < 100) LinearProgressIndicator( value: currentSlot.progress == 0 ? null @@ -660,7 +711,10 @@ class _H5ShellPageState extends State { left: 0, right: 0, height: topInset, - child: const ColoredBox(color: Colors.white), + child: ColoredBox( + color: + shouldPaintShellFallback ? _shellBackground : Colors.white, + ), ), ], ), @@ -693,11 +747,14 @@ class _H5LineWebViewSlot { 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) { @@ -989,17 +1046,18 @@ class _ShellFallback extends StatelessWidget { @override Widget build(BuildContext context) { + final clampedProgress = progress.clamp(0, 100).toDouble(); final progressValue = - progress <= 0 ? 0.42 : (0.18 + progress.clamp(0, 100) * 0.0074); + progress <= 0 ? 0.28 : 0.22 + clampedProgress * 0.0072; 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 markSize = (constraints.maxWidth * 0.34).clamp(116.0, 148.0); + final contentTop = constraints.maxHeight * 0.28; final progressWidth = - (constraints.maxWidth * 0.28).clamp(104.0, 132.0); + (constraints.maxWidth * 0.32).clamp(116.0, 152.0); return Align( alignment: Alignment.topCenter, @@ -1013,7 +1071,7 @@ class _ShellFallback extends StatelessWidget { const Text( '心有回响', style: TextStyle( - color: _shellAccent, + color: _shellInk, fontSize: 24, height: 1.2, fontWeight: FontWeight.w700, @@ -1022,7 +1080,7 @@ class _ShellFallback extends StatelessWidget { ), const SizedBox(height: 8), const Text( - '正在为你唤醒会话', + '正在连接会话', style: TextStyle( color: _shellSubText, fontSize: 12, @@ -1033,7 +1091,7 @@ class _ShellFallback extends StatelessWidget { const SizedBox(height: 30), _H5LoadingProgress( width: progressWidth, - value: progressValue.clamp(0.18, 0.96), + value: progressValue.clamp(0.18, 0.96).toDouble(), ), ], ), @@ -1075,7 +1133,7 @@ class _H5LoadingProgress extends StatelessWidget { child: Stack( fit: StackFit.expand, children: [ - const ColoredBox(color: Color(0xFFDCEEFF)), + const ColoredBox(color: Color(0xFFD9EAF8)), TweenAnimationBuilder( tween: Tween(end: value), duration: const Duration(milliseconds: 260), @@ -1084,7 +1142,17 @@ class _H5LoadingProgress extends StatelessWidget { return FractionallySizedBox( alignment: Alignment.centerLeft, widthFactor: animatedValue, - child: const ColoredBox(color: _shellAccent), + child: const DecoratedBox( + decoration: BoxDecoration( + gradient: LinearGradient( + colors: [ + Color(0xFF48D4C8), + _shellAccent, + _shellAccentDeep, + ], + ), + ), + ), ); }, ), @@ -1119,39 +1187,109 @@ class _H5LoadingBackgroundPainter extends CustomPainter { begin: Alignment.topCenter, end: Alignment.bottomCenter, colors: [ - Color(0xFFE7F4FF), - _shellBackground, + Color(0xFFEAF7FF), + Color(0xFFF8FCFF), Colors.white, ], - stops: [0, 0.46, 1], + stops: [0, 0.52, 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) + ..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(0xFFDDF1FF).withValues(alpha: 0.68), + Paint()..color = const Color(0xFFDDF2FF).withValues(alpha: 0.72), ); final bottomWave = Path() - ..moveTo(1080, 855) + ..moveTo(1080, 1510) ..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) + ..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(0xFFF4FAFF).withValues(alpha: 0.86), + 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(); @@ -1166,7 +1304,7 @@ class _H5LoadingBackgroundPainter extends CustomPainter { class _BubbleCheckPainter extends CustomPainter { const _BubbleCheckPainter(); - static const _viewBox = Size(400, 363.33); + static const _viewBox = Size(420, 360); @override void paint(Canvas canvas, Size size) { @@ -1181,23 +1319,48 @@ class _BubbleCheckPainter extends CustomPainter { 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); + ..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, 30); - canvas.drawPath(bubble, shadowPaint); + 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() @@ -1207,11 +1370,12 @@ class _BubbleCheckPainter extends CustomPainter { colors: [ Color(0xFFA8DDFF), _shellAccent, - Color(0xFF0066E8), + _shellAccentDeep, ], stops: [0, 0.48, 1], - ).createShader(Offset.zero & _viewBox); - canvas.drawPath(bubble, bubblePaint); + ).createShader(frontRect); + canvas.drawPath(frontTail, bubblePaint); + canvas.drawRRect(frontBubble, bubblePaint); final highlightPaint = Paint() ..shader = LinearGradient( @@ -1221,21 +1385,21 @@ class _BubbleCheckPainter extends CustomPainter { Colors.white.withValues(alpha: 0.38), Colors.white.withValues(alpha: 0), ], - ).createShader(Offset.zero & _viewBox); - canvas.drawPath(bubble, highlightPaint); + ).createShader(frontRect); + canvas.drawRRect(frontBubble, 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, + 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();