| .. | .. |
|---|
| 1 | +import 'dart:math' as math; |
|---|
| 2 | +import 'package:flutter/material.dart'; |
|---|
| 3 | + |
|---|
| 4 | +/// PAILot animated splash screen. |
|---|
| 5 | +/// |
|---|
| 6 | +/// Phase 1 (~0-1500ms): The P logo reveals itself progressively from top to |
|---|
| 7 | +/// bottom, as if being drawn by an invisible pen. |
|---|
| 8 | +/// |
|---|
| 9 | +/// Phase 2 (~1000-1700ms): A paper airplane flies in from the left, arcing |
|---|
| 10 | +/// across the P. |
|---|
| 11 | +/// |
|---|
| 12 | +/// After animation completes the [onComplete] callback fires. |
|---|
| 13 | +class SplashScreen extends StatefulWidget { |
|---|
| 14 | + final VoidCallback onComplete; |
|---|
| 15 | + |
|---|
| 16 | + const SplashScreen({super.key, required this.onComplete}); |
|---|
| 17 | + |
|---|
| 18 | + @override |
|---|
| 19 | + State<SplashScreen> createState() => _SplashScreenState(); |
|---|
| 20 | +} |
|---|
| 21 | + |
|---|
| 22 | +class _SplashScreenState extends State<SplashScreen> |
|---|
| 23 | + with TickerProviderStateMixin { |
|---|
| 24 | + late final AnimationController _drawController; |
|---|
| 25 | + late final AnimationController _planeController; |
|---|
| 26 | + late final AnimationController _fadeController; |
|---|
| 27 | + |
|---|
| 28 | + static const Duration _drawDuration = Duration(milliseconds: 1500); |
|---|
| 29 | + static const Duration _planeDuration = Duration(milliseconds: 700); |
|---|
| 30 | + static const Duration _fadeDuration = Duration(milliseconds: 300); |
|---|
| 31 | + |
|---|
| 32 | + @override |
|---|
| 33 | + void initState() { |
|---|
| 34 | + super.initState(); |
|---|
| 35 | + |
|---|
| 36 | + _drawController = |
|---|
| 37 | + AnimationController(vsync: this, duration: _drawDuration); |
|---|
| 38 | + _planeController = |
|---|
| 39 | + AnimationController(vsync: this, duration: _planeDuration); |
|---|
| 40 | + _fadeController = |
|---|
| 41 | + AnimationController(vsync: this, duration: _fadeDuration); |
|---|
| 42 | + |
|---|
| 43 | + _startSequence(); |
|---|
| 44 | + } |
|---|
| 45 | + |
|---|
| 46 | + Future<void> _startSequence() async { |
|---|
| 47 | + await Future.delayed(const Duration(milliseconds: 50)); |
|---|
| 48 | + if (!mounted) return; |
|---|
| 49 | + |
|---|
| 50 | + _drawController.forward(); |
|---|
| 51 | + |
|---|
| 52 | + await Future.delayed(const Duration(milliseconds: 1000)); |
|---|
| 53 | + if (!mounted) return; |
|---|
| 54 | + _planeController.forward(); |
|---|
| 55 | + |
|---|
| 56 | + await Future.delayed(const Duration(milliseconds: 800)); |
|---|
| 57 | + if (!mounted) return; |
|---|
| 58 | + |
|---|
| 59 | + await _fadeController.forward(); |
|---|
| 60 | + if (!mounted) return; |
|---|
| 61 | + widget.onComplete(); |
|---|
| 62 | + } |
|---|
| 63 | + |
|---|
| 64 | + @override |
|---|
| 65 | + void dispose() { |
|---|
| 66 | + _drawController.dispose(); |
|---|
| 67 | + _planeController.dispose(); |
|---|
| 68 | + _fadeController.dispose(); |
|---|
| 69 | + super.dispose(); |
|---|
| 70 | + } |
|---|
| 71 | + |
|---|
| 72 | + @override |
|---|
| 73 | + Widget build(BuildContext context) { |
|---|
| 74 | + return Scaffold( |
|---|
| 75 | + backgroundColor: const Color(0xFF111217), |
|---|
| 76 | + body: FadeTransition( |
|---|
| 77 | + opacity: Tween<double>(begin: 1.0, end: 0.0).animate( |
|---|
| 78 | + CurvedAnimation(parent: _fadeController, curve: Curves.easeIn), |
|---|
| 79 | + ), |
|---|
| 80 | + child: Center( |
|---|
| 81 | + child: Column( |
|---|
| 82 | + mainAxisSize: MainAxisSize.min, |
|---|
| 83 | + children: [ |
|---|
| 84 | + AnimatedBuilder( |
|---|
| 85 | + animation: |
|---|
| 86 | + Listenable.merge([_drawController, _planeController]), |
|---|
| 87 | + builder: (context, child) { |
|---|
| 88 | + return CustomPaint( |
|---|
| 89 | + size: const Size(300, 300), |
|---|
| 90 | + painter: _PLogoWithPlanePainter( |
|---|
| 91 | + drawProgress: _drawController.value, |
|---|
| 92 | + planeProgress: _planeController.value, |
|---|
| 93 | + ), |
|---|
| 94 | + ); |
|---|
| 95 | + }, |
|---|
| 96 | + ), |
|---|
| 97 | + const SizedBox(height: 24), |
|---|
| 98 | + AnimatedBuilder( |
|---|
| 99 | + animation: _drawController, |
|---|
| 100 | + builder: (context, child) { |
|---|
| 101 | + return Opacity( |
|---|
| 102 | + opacity: _drawController.value.clamp(0.0, 1.0), |
|---|
| 103 | + child: const Text( |
|---|
| 104 | + 'PAILot', |
|---|
| 105 | + style: TextStyle( |
|---|
| 106 | + color: Color(0xFF00C7FF), |
|---|
| 107 | + fontSize: 28, |
|---|
| 108 | + fontWeight: FontWeight.w300, |
|---|
| 109 | + letterSpacing: 6, |
|---|
| 110 | + ), |
|---|
| 111 | + ), |
|---|
| 112 | + ); |
|---|
| 113 | + }, |
|---|
| 114 | + ), |
|---|
| 115 | + ], |
|---|
| 116 | + ), |
|---|
| 117 | + ), |
|---|
| 118 | + ), |
|---|
| 119 | + ); |
|---|
| 120 | + } |
|---|
| 121 | +} |
|---|
| 122 | + |
|---|
| 123 | +class _PLogoWithPlanePainter extends CustomPainter { |
|---|
| 124 | + final double drawProgress; |
|---|
| 125 | + final double planeProgress; |
|---|
| 126 | + |
|---|
| 127 | + const _PLogoWithPlanePainter({ |
|---|
| 128 | + required this.drawProgress, |
|---|
| 129 | + required this.planeProgress, |
|---|
| 130 | + }); |
|---|
| 131 | + |
|---|
| 132 | + @override |
|---|
| 133 | + void paint(Canvas canvas, Size size) { |
|---|
| 134 | + const double svgW = 519.2; |
|---|
| 135 | + const double svgH = 519.3; |
|---|
| 136 | + |
|---|
| 137 | + final double scale = math.min(size.width / svgW, size.height / svgH); |
|---|
| 138 | + final double offsetX = (size.width - svgW * scale) / 2; |
|---|
| 139 | + final double offsetY = (size.height - svgH * scale) / 2; |
|---|
| 140 | + |
|---|
| 141 | + canvas.save(); |
|---|
| 142 | + canvas.translate(offsetX, offsetY); |
|---|
| 143 | + canvas.scale(scale, scale); |
|---|
| 144 | + |
|---|
| 145 | + _drawPLogo(canvas); |
|---|
| 146 | + _drawPlane(canvas); |
|---|
| 147 | + |
|---|
| 148 | + canvas.restore(); |
|---|
| 149 | + } |
|---|
| 150 | + |
|---|
| 151 | + void _drawPLogo(Canvas canvas) { |
|---|
| 152 | + if (drawProgress <= 0) return; |
|---|
| 153 | + |
|---|
| 154 | + final paths = _buildPPaths(); |
|---|
| 155 | + |
|---|
| 156 | + Rect bounds = paths.first.getBounds(); |
|---|
| 157 | + for (final p in paths.skip(1)) { |
|---|
| 158 | + bounds = bounds.expandToInclude(p.getBounds()); |
|---|
| 159 | + } |
|---|
| 160 | + |
|---|
| 161 | + final double revealY = bounds.top + |
|---|
| 162 | + bounds.height * Curves.easeInOut.transform(drawProgress); |
|---|
| 163 | + |
|---|
| 164 | + canvas.save(); |
|---|
| 165 | + canvas.clipRect(Rect.fromLTRB( |
|---|
| 166 | + bounds.left - 10, |
|---|
| 167 | + bounds.top - 10, |
|---|
| 168 | + bounds.right + 10, |
|---|
| 169 | + revealY + 6, |
|---|
| 170 | + )); |
|---|
| 171 | + |
|---|
| 172 | + final Paint gradientPaint = Paint() |
|---|
| 173 | + ..shader = const LinearGradient( |
|---|
| 174 | + begin: Alignment.topLeft, |
|---|
| 175 | + end: Alignment.bottomRight, |
|---|
| 176 | + colors: [Color(0xFF0000FF), Color(0xFF00C7FF)], |
|---|
| 177 | + ).createShader(Rect.fromLTWH(0, 0, 519.2, 519.3)) |
|---|
| 178 | + ..style = PaintingStyle.fill |
|---|
| 179 | + ..isAntiAlias = true; |
|---|
| 180 | + |
|---|
| 181 | + for (final p in paths) { |
|---|
| 182 | + canvas.drawPath(p, gradientPaint); |
|---|
| 183 | + } |
|---|
| 184 | + |
|---|
| 185 | + canvas.restore(); |
|---|
| 186 | + } |
|---|
| 187 | + |
|---|
| 188 | + List<Path> _buildPPaths() { |
|---|
| 189 | + final path1 = Path(); |
|---|
| 190 | + path1.moveTo(149.4, 68.3); |
|---|
| 191 | + path1.lineTo(191.1, 155.4); |
|---|
| 192 | + path1.lineTo(300.6, 151.2); |
|---|
| 193 | + path1.cubicTo(301.3, 151.2, 302.0, 151.1, 302.7, 151.1); |
|---|
| 194 | + path1.cubicTo(324.8, 151.1, 343.0, 169.0, 343.0, 191.4); |
|---|
| 195 | + path1.cubicTo(343.0, 201.3, 339.4, 210.4, 333.5, 217.4); |
|---|
| 196 | + path1.cubicTo(366.8, 193.1, 389.3, 153.8, 389.3, 109.5); |
|---|
| 197 | + path1.cubicTo(389.3, 69.5, 371.7, 24.8, 343.9, 0.3); |
|---|
| 198 | + path1.cubicTo(339.9, 0.0, 335.8, -0.1, 331.7, -0.1); |
|---|
| 199 | + path1.lineTo(0, -0.1); |
|---|
| 200 | + path1.lineTo(0, 5.4); |
|---|
| 201 | + path1.cubicTo(0, 81.7, 59.8, 152.7, 134.9, 156.8); |
|---|
| 202 | + path1.lineTo(107.3, 68.3); |
|---|
| 203 | + path1.close(); |
|---|
| 204 | + |
|---|
| 205 | + final path2 = Path(); |
|---|
| 206 | + path2.moveTo(518.9, 175.6); |
|---|
| 207 | + path2.cubicTo(515.9, 128.6, 495.7, 86.3, 464.4, 55.0); |
|---|
| 208 | + path2.cubicTo(433.2, 23.8, 391.1, 3.6, 344.4, 0.5); |
|---|
| 209 | + path2.cubicTo(344.3, 0.5, 344.2, 0.6, 344.3, 0.7); |
|---|
| 210 | + path2.cubicTo(372.0, 25.2, 389.5, 69.8, 389.5, 109.6); |
|---|
| 211 | + path2.cubicTo(389.5, 151.9, 364.4, 195.1, 333.7, 217.5); |
|---|
| 212 | + path2.cubicTo(327.9, 224.3, 319.9, 229.2, 310.9, 231.0); |
|---|
| 213 | + path2.cubicTo(293.9, 238.9, 275.2, 243.4, 255.9, 243.4); |
|---|
| 214 | + path2.cubicTo(274.9, 243.4, 293.3, 239.1, 310.1, 231.4); |
|---|
| 215 | + path2.cubicTo(310.2, 231.3, 310.2, 231.1, 310.0, 231.2); |
|---|
| 216 | + path2.cubicTo(307.1, 231.7, 304.0, 231.9, 300.9, 231.8); |
|---|
| 217 | + path2.lineTo(191.5, 227.6); |
|---|
| 218 | + path2.lineTo(191.4, 227.7); |
|---|
| 219 | + path2.lineTo(149.7, 314.7); |
|---|
| 220 | + path2.lineTo(149.6, 314.8); |
|---|
| 221 | + path2.lineTo(107.7, 314.8); |
|---|
| 222 | + path2.cubicTo(107.6, 314.8, 107.6, 314.7, 107.6, 314.6); |
|---|
| 223 | + path2.lineTo(135.1, 226.2); |
|---|
| 224 | + path2.cubicTo(135.1, 226.1, 135.1, 226.0, 135.0, 226.0); |
|---|
| 225 | + path2.cubicTo(59.7, 230.2, 0, 283.6, 0, 359.9); |
|---|
| 226 | + path2.lineTo(0, 375.0); |
|---|
| 227 | + path2.lineTo(0, 516.6); |
|---|
| 228 | + path2.cubicTo(0, 516.7, 0.2, 516.8, 0.2, 516.6); |
|---|
| 229 | + path2.cubicTo(29.6, 429.6, 111.9, 374.7, 208.9, 374.7); |
|---|
| 230 | + path2.lineTo(298.2, 374.7); |
|---|
| 231 | + path2.lineTo(298.4, 375.0); |
|---|
| 232 | + path2.lineTo(329.8, 375.0); |
|---|
| 233 | + path2.cubicTo(439.7, 375.0, 525.7, 285.2, 518.9, 175.6); |
|---|
| 234 | + path2.close(); |
|---|
| 235 | + |
|---|
| 236 | + final path3 = Path(); |
|---|
| 237 | + path3.moveTo(208.9, 374.5); |
|---|
| 238 | + path3.cubicTo(111.7, 374.5, 29.2, 429.6, 0, 517.0); |
|---|
| 239 | + path3.lineTo(0, 519.2); |
|---|
| 240 | + path3.lineTo(158.7, 519.2); |
|---|
| 241 | + path3.lineTo(159.0, 417.6); |
|---|
| 242 | + path3.cubicTo(158.8, 393.9, 178.0, 374.6, 201.7, 374.6); |
|---|
| 243 | + path3.lineTo(298.4, 374.6); |
|---|
| 244 | + |
|---|
| 245 | + return [path1, path2, path3]; |
|---|
| 246 | + } |
|---|
| 247 | + |
|---|
| 248 | + void _drawPlane(Canvas canvas) { |
|---|
| 249 | + if (planeProgress <= 0) return; |
|---|
| 250 | + |
|---|
| 251 | + const double startX = -80.0, startY = 290.0; |
|---|
| 252 | + const double ctrlX = 260.0, ctrlY = 100.0; |
|---|
| 253 | + const double endX = 480.0, endY = 250.0; |
|---|
| 254 | + |
|---|
| 255 | + final double t = planeProgress; |
|---|
| 256 | + final double mt = 1.0 - t; |
|---|
| 257 | + |
|---|
| 258 | + final double px = mt * mt * startX + 2 * mt * t * ctrlX + t * t * endX; |
|---|
| 259 | + final double py = mt * mt * startY + 2 * mt * t * ctrlY + t * t * endY; |
|---|
| 260 | + |
|---|
| 261 | + final double dx = 2 * mt * (ctrlX - startX) + 2 * t * (endX - ctrlX); |
|---|
| 262 | + final double dy = 2 * mt * (ctrlY - startY) + 2 * t * (endY - ctrlY); |
|---|
| 263 | + final double angle = math.atan2(dy, dx); |
|---|
| 264 | + |
|---|
| 265 | + double alpha = 1.0; |
|---|
| 266 | + if (t < 0.15) { |
|---|
| 267 | + alpha = t / 0.15; |
|---|
| 268 | + } else if (t > 0.9) { |
|---|
| 269 | + alpha = (1.0 - t) / 0.1; |
|---|
| 270 | + } |
|---|
| 271 | + |
|---|
| 272 | + canvas.save(); |
|---|
| 273 | + canvas.translate(px, py); |
|---|
| 274 | + canvas.rotate(angle); |
|---|
| 275 | + |
|---|
| 276 | + const double s = 30.0; |
|---|
| 277 | + final Paint bodyPaint = Paint() |
|---|
| 278 | + ..color = Color.fromRGBO(0, 199, 255, alpha) |
|---|
| 279 | + ..style = PaintingStyle.fill |
|---|
| 280 | + ..isAntiAlias = true; |
|---|
| 281 | + final Paint edgePaint = Paint() |
|---|
| 282 | + ..color = Color.fromRGBO(255, 255, 255, alpha * 0.6) |
|---|
| 283 | + ..style = PaintingStyle.stroke |
|---|
| 284 | + ..strokeWidth = 1.5 |
|---|
| 285 | + ..isAntiAlias = true; |
|---|
| 286 | + |
|---|
| 287 | + final Path plane = Path() |
|---|
| 288 | + ..moveTo(s, 0) |
|---|
| 289 | + ..lineTo(-s * 0.6, -s * 0.55) |
|---|
| 290 | + ..lineTo(-s * 0.2, 0) |
|---|
| 291 | + ..lineTo(-s * 0.6, s * 0.55) |
|---|
| 292 | + ..close(); |
|---|
| 293 | + |
|---|
| 294 | + canvas.drawPath(plane, bodyPaint); |
|---|
| 295 | + canvas.drawPath(plane, edgePaint); |
|---|
| 296 | + |
|---|
| 297 | + canvas.drawLine( |
|---|
| 298 | + Offset(s, 0), |
|---|
| 299 | + Offset(-s * 0.2, 0), |
|---|
| 300 | + Paint() |
|---|
| 301 | + ..color = Color.fromRGBO(255, 255, 255, alpha * 0.4) |
|---|
| 302 | + ..strokeWidth = 1.0, |
|---|
| 303 | + ); |
|---|
| 304 | + |
|---|
| 305 | + canvas.restore(); |
|---|
| 306 | + } |
|---|
| 307 | + |
|---|
| 308 | + @override |
|---|
| 309 | + bool shouldRepaint(_PLogoWithPlanePainter old) => |
|---|
| 310 | + old.drawProgress != drawProgress || old.planeProgress != planeProgress; |
|---|
| 311 | +} |
|---|