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 f74085f..c887eb5 100644 --- a/android/app/src/main/res/drawable-v21/launch_background.xml +++ b/android/app/src/main/res/drawable-v21/launch_background.xml @@ -1,12 +1,8 @@ - - - - - + + + + + diff --git a/android/app/src/main/res/drawable/launch_background.xml b/android/app/src/main/res/drawable/launch_background.xml index 304732f..c887eb5 100644 --- a/android/app/src/main/res/drawable/launch_background.xml +++ b/android/app/src/main/res/drawable/launch_background.xml @@ -1,12 +1,8 @@ - - - - - + + + + + diff --git a/ios/Runner/Base.lproj/LaunchScreen.storyboard b/ios/Runner/Base.lproj/LaunchScreen.storyboard index f2e259c..a4ee227 100644 --- a/ios/Runner/Base.lproj/LaunchScreen.storyboard +++ b/ios/Runner/Base.lproj/LaunchScreen.storyboard @@ -19,7 +19,7 @@ - + diff --git a/lib/app.dart b/lib/app.dart index 8b1f83a..cfd596a 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -2,7 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_localizations/flutter_localizations.dart'; import 'l10n/gen/app_localizations.dart'; -import 'ui/screens/home_screen.dart'; +import 'ui/screens/splash_screen.dart'; class BlockSeasonsApp extends StatelessWidget { const BlockSeasonsApp({super.key}); @@ -26,7 +26,7 @@ class BlockSeasonsApp extends StatelessWidget { ), useMaterial3: true, ), - home: const HomeScreen(), + home: const SplashScreen(), ); } } diff --git a/lib/ui/screens/splash_screen.dart b/lib/ui/screens/splash_screen.dart new file mode 100644 index 0000000..4253827 --- /dev/null +++ b/lib/ui/screens/splash_screen.dart @@ -0,0 +1,123 @@ +import 'package:flutter/material.dart'; + +import 'home_screen.dart'; + +/// Logo-assembly splash: four glossy blocks fly in to form a 2x2 mark, the +/// wordmark fades in, then we hand off. SaveRepository is already opened in +/// main() so this doubles as perceived-zero loading time. +class SplashScreen extends StatefulWidget { + const SplashScreen({super.key}); + + /// Where the splash goes when finished; the season title card task + /// repoints this. + static Widget Function() nextScreen = () => const HomeScreen(); + + @override + State createState() => _SplashScreenState(); +} + +class _SplashScreenState extends State + with SingleTickerProviderStateMixin { + late final AnimationController _c = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 1900), + )..addStatusListener((status) { + if (status == AnimationStatus.completed && mounted) { + Navigator.of(context).pushReplacement( + MaterialPageRoute(builder: (_) => SplashScreen.nextScreen()), + ); + } + }); + + /// (color, fly-in direction unit, 2x2 slot unit) per block. + static const _blocks = [ + (Color(0xFFFF7EB3), Offset(-1.2, -0.4), Offset(-0.5, -0.5)), + (Color(0xFFFFD166), Offset(1.2, -0.4), Offset(0.5, -0.5)), + (Color(0xFF6FCDF5), Offset(-1.2, 0.6), Offset(-0.5, 0.5)), + (Color(0xFF7EDB9C), Offset(1.2, 0.6), Offset(0.5, 0.5)), + ]; + + @override + void initState() { + super.initState(); + _c.forward(); + } + + @override + void dispose() { + _c.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + const blockSize = 46.0; + const gap = 3.0; + return Scaffold( + backgroundColor: const Color(0xFF0E1430), + body: AnimatedBuilder( + animation: _c, + builder: (context, _) { + final titleT = const Interval(0.60, 0.88, curve: Curves.easeOut) + .transform(_c.value); + return Stack( + alignment: Alignment.center, + children: [ + for (var i = 0; i < _blocks.length; i++) + _block(i, blockSize, gap), + Transform.translate( + offset: Offset(0, 78 + 12 * (1 - titleT)), + child: Opacity( + opacity: titleT, + child: const Text( + 'BLOCK SEASONS', + style: TextStyle( + fontSize: 26, + fontWeight: FontWeight.w900, + letterSpacing: 4, + color: Colors.white, + ), + ), + ), + ), + ], + ); + }, + ), + ); + } + + Widget _block(int i, double size, double gap) { + final (color, from, to) = _blocks[i]; + final t = Interval(0.06 * i, 0.45 + 0.06 * i, curve: Curves.easeOutBack) + .transform(_c.value); + final begin = Offset(from.dx * 160, from.dy * 280); + final end = Offset(to.dx * (size + gap), to.dy * (size + gap)); + final pos = Offset.lerp(begin, end, t)!; + return Transform.translate( + offset: pos, + child: Container( + width: size, + height: size, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(11), + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [ + Color.lerp(color, Colors.white, 0.28)!, + color, + Color.lerp(color, Colors.black, 0.22)!, + ], + ), + boxShadow: [ + BoxShadow( + color: color.withValues(alpha: 0.55), + blurRadius: 18, + ), + ], + ), + ), + ); + } +} diff --git a/test/widget_test.dart b/test/widget_test.dart index 05be38c..8183482 100644 --- a/test/widget_test.dart +++ b/test/widget_test.dart @@ -16,6 +16,10 @@ void main() { child: const BlockSeasonsApp(), ), ); + // Splash (~1.9s) → (later: season title card) → home. + await tester.pump(const Duration(milliseconds: 2100)); + await tester.pump(const Duration(milliseconds: 2000)); + await tester.pump(const Duration(milliseconds: 2000)); await tester.pumpAndSettle(); expect(find.text('Block Seasons'), findsOneWidget);