From 525030c3caccef27a1da44605d28e259dc1cc17c Mon Sep 17 00:00:00 2001
From: Matthias Nott <mnott@mnsoft.org>
Date: Mon, 06 Apr 2026 12:33:53 +0200
Subject: [PATCH] feat: animated splash screen with P logo reveal and plane fly-in
---
lib/main.dart | 13 ++
lib/screens/splash_screen.dart | 311 +++++++++++++++++++++++++++++++++++++++++++++++++++
2 files changed, 324 insertions(+), 0 deletions(-)
diff --git a/lib/main.dart b/lib/main.dart
index 5bab6bb..af7844b 100644
--- a/lib/main.dart
+++ b/lib/main.dart
@@ -8,6 +8,7 @@
import 'providers/providers.dart';
import 'services/audio_service.dart';
import 'services/purchase_service.dart';
+import 'screens/splash_screen.dart';
void main() async {
WidgetsFlutterBinding.ensureInitialized();
@@ -48,6 +49,7 @@
class _PAILotAppState extends ConsumerState<PAILotApp> {
late final GoRouter _router;
+ bool _showSplash = true;
@override
void initState() {
@@ -78,6 +80,17 @@
themeMode: themeMode,
routerConfig: _router,
debugShowCheckedModeBanner: false,
+ builder: (context, child) {
+ return Stack(
+ children: [
+ child ?? const SizedBox.shrink(),
+ if (_showSplash)
+ SplashScreen(onComplete: () {
+ if (mounted) setState(() => _showSplash = false);
+ }),
+ ],
+ );
+ },
);
}
}
diff --git a/lib/screens/splash_screen.dart b/lib/screens/splash_screen.dart
new file mode 100644
index 0000000..2e210d2
--- /dev/null
+++ b/lib/screens/splash_screen.dart
@@ -0,0 +1,311 @@
+import 'dart:math' as math;
+import 'package:flutter/material.dart';
+
+/// PAILot animated splash screen.
+///
+/// Phase 1 (~0-1500ms): The P logo reveals itself progressively from top to
+/// bottom, as if being drawn by an invisible pen.
+///
+/// Phase 2 (~1000-1700ms): A paper airplane flies in from the left, arcing
+/// across the P.
+///
+/// After animation completes the [onComplete] callback fires.
+class SplashScreen extends StatefulWidget {
+ final VoidCallback onComplete;
+
+ const SplashScreen({super.key, required this.onComplete});
+
+ @override
+ State<SplashScreen> createState() => _SplashScreenState();
+}
+
+class _SplashScreenState extends State<SplashScreen>
+ with TickerProviderStateMixin {
+ late final AnimationController _drawController;
+ late final AnimationController _planeController;
+ late final AnimationController _fadeController;
+
+ static const Duration _drawDuration = Duration(milliseconds: 1500);
+ static const Duration _planeDuration = Duration(milliseconds: 700);
+ static const Duration _fadeDuration = Duration(milliseconds: 300);
+
+ @override
+ void initState() {
+ super.initState();
+
+ _drawController =
+ AnimationController(vsync: this, duration: _drawDuration);
+ _planeController =
+ AnimationController(vsync: this, duration: _planeDuration);
+ _fadeController =
+ AnimationController(vsync: this, duration: _fadeDuration);
+
+ _startSequence();
+ }
+
+ Future<void> _startSequence() async {
+ await Future.delayed(const Duration(milliseconds: 50));
+ if (!mounted) return;
+
+ _drawController.forward();
+
+ await Future.delayed(const Duration(milliseconds: 1000));
+ if (!mounted) return;
+ _planeController.forward();
+
+ await Future.delayed(const Duration(milliseconds: 800));
+ if (!mounted) return;
+
+ await _fadeController.forward();
+ if (!mounted) return;
+ widget.onComplete();
+ }
+
+ @override
+ void dispose() {
+ _drawController.dispose();
+ _planeController.dispose();
+ _fadeController.dispose();
+ super.dispose();
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ return Scaffold(
+ backgroundColor: const Color(0xFF111217),
+ body: FadeTransition(
+ opacity: Tween<double>(begin: 1.0, end: 0.0).animate(
+ CurvedAnimation(parent: _fadeController, curve: Curves.easeIn),
+ ),
+ child: Center(
+ child: Column(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ AnimatedBuilder(
+ animation:
+ Listenable.merge([_drawController, _planeController]),
+ builder: (context, child) {
+ return CustomPaint(
+ size: const Size(300, 300),
+ painter: _PLogoWithPlanePainter(
+ drawProgress: _drawController.value,
+ planeProgress: _planeController.value,
+ ),
+ );
+ },
+ ),
+ const SizedBox(height: 24),
+ AnimatedBuilder(
+ animation: _drawController,
+ builder: (context, child) {
+ return Opacity(
+ opacity: _drawController.value.clamp(0.0, 1.0),
+ child: const Text(
+ 'PAILot',
+ style: TextStyle(
+ color: Color(0xFF00C7FF),
+ fontSize: 28,
+ fontWeight: FontWeight.w300,
+ letterSpacing: 6,
+ ),
+ ),
+ );
+ },
+ ),
+ ],
+ ),
+ ),
+ ),
+ );
+ }
+}
+
+class _PLogoWithPlanePainter extends CustomPainter {
+ final double drawProgress;
+ final double planeProgress;
+
+ const _PLogoWithPlanePainter({
+ required this.drawProgress,
+ required this.planeProgress,
+ });
+
+ @override
+ void paint(Canvas canvas, Size size) {
+ const double svgW = 519.2;
+ const double svgH = 519.3;
+
+ final double scale = math.min(size.width / svgW, size.height / svgH);
+ final double offsetX = (size.width - svgW * scale) / 2;
+ final double offsetY = (size.height - svgH * scale) / 2;
+
+ canvas.save();
+ canvas.translate(offsetX, offsetY);
+ canvas.scale(scale, scale);
+
+ _drawPLogo(canvas);
+ _drawPlane(canvas);
+
+ canvas.restore();
+ }
+
+ void _drawPLogo(Canvas canvas) {
+ if (drawProgress <= 0) return;
+
+ final paths = _buildPPaths();
+
+ Rect bounds = paths.first.getBounds();
+ for (final p in paths.skip(1)) {
+ bounds = bounds.expandToInclude(p.getBounds());
+ }
+
+ final double revealY = bounds.top +
+ bounds.height * Curves.easeInOut.transform(drawProgress);
+
+ canvas.save();
+ canvas.clipRect(Rect.fromLTRB(
+ bounds.left - 10,
+ bounds.top - 10,
+ bounds.right + 10,
+ revealY + 6,
+ ));
+
+ final Paint gradientPaint = Paint()
+ ..shader = const LinearGradient(
+ begin: Alignment.topLeft,
+ end: Alignment.bottomRight,
+ colors: [Color(0xFF0000FF), Color(0xFF00C7FF)],
+ ).createShader(Rect.fromLTWH(0, 0, 519.2, 519.3))
+ ..style = PaintingStyle.fill
+ ..isAntiAlias = true;
+
+ for (final p in paths) {
+ canvas.drawPath(p, gradientPaint);
+ }
+
+ canvas.restore();
+ }
+
+ List<Path> _buildPPaths() {
+ final path1 = Path();
+ path1.moveTo(149.4, 68.3);
+ path1.lineTo(191.1, 155.4);
+ path1.lineTo(300.6, 151.2);
+ path1.cubicTo(301.3, 151.2, 302.0, 151.1, 302.7, 151.1);
+ path1.cubicTo(324.8, 151.1, 343.0, 169.0, 343.0, 191.4);
+ path1.cubicTo(343.0, 201.3, 339.4, 210.4, 333.5, 217.4);
+ path1.cubicTo(366.8, 193.1, 389.3, 153.8, 389.3, 109.5);
+ path1.cubicTo(389.3, 69.5, 371.7, 24.8, 343.9, 0.3);
+ path1.cubicTo(339.9, 0.0, 335.8, -0.1, 331.7, -0.1);
+ path1.lineTo(0, -0.1);
+ path1.lineTo(0, 5.4);
+ path1.cubicTo(0, 81.7, 59.8, 152.7, 134.9, 156.8);
+ path1.lineTo(107.3, 68.3);
+ path1.close();
+
+ final path2 = Path();
+ path2.moveTo(518.9, 175.6);
+ path2.cubicTo(515.9, 128.6, 495.7, 86.3, 464.4, 55.0);
+ path2.cubicTo(433.2, 23.8, 391.1, 3.6, 344.4, 0.5);
+ path2.cubicTo(344.3, 0.5, 344.2, 0.6, 344.3, 0.7);
+ path2.cubicTo(372.0, 25.2, 389.5, 69.8, 389.5, 109.6);
+ path2.cubicTo(389.5, 151.9, 364.4, 195.1, 333.7, 217.5);
+ path2.cubicTo(327.9, 224.3, 319.9, 229.2, 310.9, 231.0);
+ path2.cubicTo(293.9, 238.9, 275.2, 243.4, 255.9, 243.4);
+ path2.cubicTo(274.9, 243.4, 293.3, 239.1, 310.1, 231.4);
+ path2.cubicTo(310.2, 231.3, 310.2, 231.1, 310.0, 231.2);
+ path2.cubicTo(307.1, 231.7, 304.0, 231.9, 300.9, 231.8);
+ path2.lineTo(191.5, 227.6);
+ path2.lineTo(191.4, 227.7);
+ path2.lineTo(149.7, 314.7);
+ path2.lineTo(149.6, 314.8);
+ path2.lineTo(107.7, 314.8);
+ path2.cubicTo(107.6, 314.8, 107.6, 314.7, 107.6, 314.6);
+ path2.lineTo(135.1, 226.2);
+ path2.cubicTo(135.1, 226.1, 135.1, 226.0, 135.0, 226.0);
+ path2.cubicTo(59.7, 230.2, 0, 283.6, 0, 359.9);
+ path2.lineTo(0, 375.0);
+ path2.lineTo(0, 516.6);
+ path2.cubicTo(0, 516.7, 0.2, 516.8, 0.2, 516.6);
+ path2.cubicTo(29.6, 429.6, 111.9, 374.7, 208.9, 374.7);
+ path2.lineTo(298.2, 374.7);
+ path2.lineTo(298.4, 375.0);
+ path2.lineTo(329.8, 375.0);
+ path2.cubicTo(439.7, 375.0, 525.7, 285.2, 518.9, 175.6);
+ path2.close();
+
+ final path3 = Path();
+ path3.moveTo(208.9, 374.5);
+ path3.cubicTo(111.7, 374.5, 29.2, 429.6, 0, 517.0);
+ path3.lineTo(0, 519.2);
+ path3.lineTo(158.7, 519.2);
+ path3.lineTo(159.0, 417.6);
+ path3.cubicTo(158.8, 393.9, 178.0, 374.6, 201.7, 374.6);
+ path3.lineTo(298.4, 374.6);
+
+ return [path1, path2, path3];
+ }
+
+ void _drawPlane(Canvas canvas) {
+ if (planeProgress <= 0) return;
+
+ const double startX = -80.0, startY = 290.0;
+ const double ctrlX = 260.0, ctrlY = 100.0;
+ const double endX = 480.0, endY = 250.0;
+
+ final double t = planeProgress;
+ final double mt = 1.0 - t;
+
+ final double px = mt * mt * startX + 2 * mt * t * ctrlX + t * t * endX;
+ final double py = mt * mt * startY + 2 * mt * t * ctrlY + t * t * endY;
+
+ final double dx = 2 * mt * (ctrlX - startX) + 2 * t * (endX - ctrlX);
+ final double dy = 2 * mt * (ctrlY - startY) + 2 * t * (endY - ctrlY);
+ final double angle = math.atan2(dy, dx);
+
+ double alpha = 1.0;
+ if (t < 0.15) {
+ alpha = t / 0.15;
+ } else if (t > 0.9) {
+ alpha = (1.0 - t) / 0.1;
+ }
+
+ canvas.save();
+ canvas.translate(px, py);
+ canvas.rotate(angle);
+
+ const double s = 30.0;
+ final Paint bodyPaint = Paint()
+ ..color = Color.fromRGBO(0, 199, 255, alpha)
+ ..style = PaintingStyle.fill
+ ..isAntiAlias = true;
+ final Paint edgePaint = Paint()
+ ..color = Color.fromRGBO(255, 255, 255, alpha * 0.6)
+ ..style = PaintingStyle.stroke
+ ..strokeWidth = 1.5
+ ..isAntiAlias = true;
+
+ final Path plane = Path()
+ ..moveTo(s, 0)
+ ..lineTo(-s * 0.6, -s * 0.55)
+ ..lineTo(-s * 0.2, 0)
+ ..lineTo(-s * 0.6, s * 0.55)
+ ..close();
+
+ canvas.drawPath(plane, bodyPaint);
+ canvas.drawPath(plane, edgePaint);
+
+ canvas.drawLine(
+ Offset(s, 0),
+ Offset(-s * 0.2, 0),
+ Paint()
+ ..color = Color.fromRGBO(255, 255, 255, alpha * 0.4)
+ ..strokeWidth = 1.0,
+ );
+
+ canvas.restore();
+ }
+
+ @override
+ bool shouldRepaint(_PLogoWithPlanePainter old) =>
+ old.drawProgress != drawProgress || old.planeProgress != planeProgress;
+}
--
Gitblit v1.3.1