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