From 15570f117afd277b06339a42a57e9b43c4de1973 Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Tue, 20 Oct 2026 17:20:06 +0800 Subject: [PATCH] =?UTF-8?q?feat(flutter):=20=E9=87=8D=E5=81=9A=E5=93=81?= =?UTF-8?q?=E7=89=8C=E5=BC=80=E5=B1=8F=E5=8A=A8=E7=94=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/pages/splash/splash_page.dart | 220 +++++++++++++++++++++++++----- test/splash/splash_gate_test.dart | 79 +++++++---- 2 files changed, 243 insertions(+), 56 deletions(-) diff --git a/lib/pages/splash/splash_page.dart b/lib/pages/splash/splash_page.dart index 2eac25c..f3f3709 100644 --- a/lib/pages/splash/splash_page.dart +++ b/lib/pages/splash/splash_page.dart @@ -1,52 +1,210 @@ // Author: fruit.com.cn +import 'dart:ui'; + import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import '../../core/theme/app_colors.dart'; -const double _markSize = 84; -const double _markRadius = 18; +const Color _splashBackground = Color(0xFF100C0A); +const Color _markGlow = Color(0x33FF7A1A); +const double _markSize = 96; +const double _markRadius = 24; +const double _logoGlowSize = 144; +const double _logoGlowBlur = 40; const double _loaderSize = 22; -const double _loaderStroke = 2.5; +const double _loaderStroke = 2; const String _markAsset = 'assets/icon/splash_mark.png'; +const Duration _introDuration = Duration(milliseconds: 1200); -/// 系统开屏已经放好同一块底和图标。这一页接上加载,直到首页数据回来。 -class SplashPage extends StatelessWidget { +/// 品牌开屏:深暖底上依次呈现水果标记、FruitShorts 与中文标语。 +class SplashPage extends StatefulWidget { const SplashPage({super.key}); + @override + State createState() => _SplashPageState(); +} + +class _SplashPageState extends State + with SingleTickerProviderStateMixin { + late final AnimationController _controller; + late final Animation _logoScale; + late final Animation _logoOpacity; + late final Animation _brandReveal; + late final Animation _taglineReveal; + late final Animation _loadingReveal; + + @override + void initState() { + super.initState(); + _controller = AnimationController(vsync: this, duration: _introDuration) + ..forward(); + _logoScale = Tween(begin: 0.86, end: 1).animate( + CurvedAnimation( + parent: _controller, + curve: const Interval(0, 0.42, curve: Curves.easeOutBack), + ), + ); + _logoOpacity = Tween(begin: 0, end: 1).animate( + CurvedAnimation( + parent: _controller, + curve: const Interval(0, 0.42, curve: Curves.easeOut), + ), + ); + _brandReveal = _reveal(0.125, 0.542); + _taglineReveal = _reveal(0.25, 0.667); + _loadingReveal = _reveal(0.417, 0.833); + } + + Animation _reveal(double begin, double end) { + return Tween(begin: 0, end: 1).animate( + CurvedAnimation( + parent: _controller, + curve: Interval(begin, end, curve: Curves.easeOutCubic), + ), + ); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + @override Widget build(BuildContext context) { - return ColoredBox( - key: Key('splash-screen'), - color: AppColors.ink, - child: Stack( - fit: StackFit.expand, - children: [ - Center( - child: ClipRRect( - borderRadius: BorderRadius.circular(_markRadius), - child: const Image( - image: AssetImage(_markAsset), - width: _markSize, - height: _markSize, - filterQuality: FilterQuality.medium, - gaplessPlayback: true, + return AnnotatedRegion( + value: SystemUiOverlayStyle.light, + child: ColoredBox( + key: const Key('splash-screen'), + color: _splashBackground, + child: Stack( + fit: StackFit.expand, + children: [ + Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + _logo(), + const SizedBox(height: 24), + _brand(), + const SizedBox(height: 6), + _tagline(), + ], ), ), - ), - Align( - alignment: Alignment(0, 0.24), - child: SizedBox( - width: _loaderSize, - height: _loaderSize, - child: CircularProgressIndicator( - key: Key('splash-loading'), - strokeWidth: _loaderStroke, - color: AppColors.gold, + Align( + alignment: Alignment.bottomCenter, + child: Padding( + padding: const EdgeInsets.only(bottom: 48), + child: FadeTransition( + key: const Key('splash-loading-fade'), + opacity: _loadingReveal, + child: const SizedBox.square( + dimension: _loaderSize, + child: CircularProgressIndicator( + key: Key('splash-loading'), + strokeWidth: _loaderStroke, + color: AppColors.blue, + ), + ), + ), ), ), + ], + ), + ), + ); + } + + Widget _logo() { + return FadeTransition( + key: const Key('splash-logo-fade'), + opacity: _logoOpacity, + child: ScaleTransition( + scale: _logoScale, + child: SizedBox.square( + dimension: _logoGlowSize, + child: Stack( + alignment: Alignment.center, + children: [ + ImageFiltered( + imageFilter: ImageFilter.blur( + sigmaX: _logoGlowBlur, + sigmaY: _logoGlowBlur, + ), + child: Container( + width: _markSize, + height: _markSize, + decoration: const BoxDecoration( + shape: BoxShape.circle, + gradient: RadialGradient( + colors: [_markGlow, Color(0x00FF7A1A)], + ), + ), + ), + ), + ClipRRect( + borderRadius: BorderRadius.circular(_markRadius), + child: Image.asset( + _markAsset, + width: _markSize, + height: _markSize, + filterQuality: FilterQuality.medium, + gaplessPlayback: true, + ), + ), + ], ), - ], + ), + ), + ); + } + + Widget _brand() { + return FadeTransition( + key: const Key('splash-brand-fade'), + opacity: _brandReveal, + child: AnimatedBuilder( + animation: _brandReveal, + builder: (context, child) => Transform.translate( + offset: Offset(0, 12 * (1 - _brandReveal.value)), + child: child, + ), + child: const Text( + 'FruitShorts', + key: Key('splash-brand'), + style: TextStyle( + color: Colors.white, + fontSize: 28, + fontWeight: FontWeight.w900, + fontFamily: 'Outfit', + ), + ), + ), + ); + } + + Widget _tagline() { + return FadeTransition( + key: const Key('splash-tagline-fade'), + opacity: _taglineReveal, + child: AnimatedBuilder( + animation: _taglineReveal, + builder: (context, child) => Transform.translate( + offset: Offset(0, 8 * (1 - _taglineReveal.value)), + child: child, + ), + child: const Text( + '水果短剧', + key: Key('splash-tagline'), + style: TextStyle( + color: Colors.white70, + fontSize: 13, + letterSpacing: 4, + ), + ), ), ); } diff --git a/test/splash/splash_gate_test.dart b/test/splash/splash_gate_test.dart index 0806c82..51729cd 100644 --- a/test/splash/splash_gate_test.dart +++ b/test/splash/splash_gate_test.dart @@ -3,6 +3,7 @@ import 'dart:async'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:fruitshorts/core/data/models/drama.dart'; import 'package:fruitshorts/core/data/models/home_feed.dart'; @@ -10,38 +11,66 @@ import 'package:fruitshorts/core/data/repositories/catalog_repository.dart'; import 'package:fruitshorts/core/data/services/catalog_controller.dart'; import 'package:fruitshorts/l10n/app_localizations.dart'; import 'package:fruitshorts/pages/home/home_view.dart'; +import 'package:fruitshorts/pages/splash/splash_page.dart'; import 'package:provider/provider.dart'; void main() { - testWidgets( - 'should_keep_splash_with_loading_until_home_feed_settles', - (tester) async { - final repository = _PendingCatalog(); - await tester.pumpWidget( - MaterialApp( - localizationsDelegates: AppLocalizations.localizationsDelegates, - supportedLocales: AppLocalizations.supportedLocales, - home: ChangeNotifierProvider( - create: (_) => CatalogController(repository)..load(), - child: const HomePage(), - ), + testWidgets('should_keep_splash_with_loading_until_home_feed_settles', ( + tester, + ) async { + final repository = _PendingCatalog(); + await tester.pumpWidget( + MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: ChangeNotifierProvider( + create: (_) => CatalogController(repository)..load(), + child: const HomePage(), ), - ); + ), + ); - expect(find.byKey(const Key('splash-screen')), findsOneWidget); - expect(find.byKey(const Key('splash-loading')), findsOneWidget); - expect(find.byKey(const Key('home-empty')), findsNothing); - final clip = tester.widget(find.byType(ClipRRect)); - expect(clip.borderRadius, BorderRadius.circular(18)); + expect(find.byKey(const Key('splash-screen')), findsOneWidget); + expect(find.byKey(const Key('splash-loading')), findsOneWidget); + expect(find.byKey(const Key('home-empty')), findsNothing); + final clip = tester.widget(find.byType(ClipRRect)); + expect(clip.borderRadius, BorderRadius.circular(24)); - repository.pending.complete(const HomeFeed()); - await tester.pump(); - await tester.pump(); + repository.pending.complete(const HomeFeed()); + await tester.pump(); + await tester.pump(); - expect(find.byKey(const Key('splash-screen')), findsNothing); - expect(find.byKey(const Key('home-empty')), findsOneWidget); - }, - ); + expect(find.byKey(const Key('splash-screen')), findsNothing); + expect(find.byKey(const Key('home-empty')), findsOneWidget); + }); + + testWidgets('should_animate_brand_copy_and_loading_to_full_opacity', ( + tester, + ) async { + await tester.pumpWidget(const MaterialApp(home: SplashPage())); + + expect(find.byKey(const Key('splash-screen')), findsOneWidget); + expect(find.byKey(const Key('splash-brand')), findsOneWidget); + expect(find.byKey(const Key('splash-tagline')), findsOneWidget); + expect(find.byKey(const Key('splash-loading')), findsOneWidget); + expect( + tester.widget(find.byKey(const Key('splash-screen'))).color, + const Color(0xFF100C0A), + ); + final overlay = tester.widget>( + find.byType(AnnotatedRegion).first, + ); + expect(overlay.value.statusBarIconBrightness, Brightness.light); + + await tester.pump(const Duration(milliseconds: 1200)); + + double opacity(String key) => + tester.widget(find.byKey(Key(key))).opacity.value; + expect(opacity('splash-logo-fade'), 1); + expect(opacity('splash-brand-fade'), 1); + expect(opacity('splash-tagline-fade'), 1); + expect(opacity('splash-loading-fade'), 1); + }); } class _PendingCatalog implements CatalogRepository {