Matthias Nott
9 days ago 525030c3caccef27a1da44605d28e259dc1cc17c
feat: animated splash screen with P logo reveal and plane fly-in
1 files added
1 files modified
changed files
lib/main.dart patch | view | blame | history
lib/screens/splash_screen.dart patch | view | blame | history
lib/main.dart
....@@ -8,6 +8,7 @@
88 import 'providers/providers.dart';
99 import 'services/audio_service.dart';
1010 import 'services/purchase_service.dart';
11
+import 'screens/splash_screen.dart';
1112
1213 void main() async {
1314 WidgetsFlutterBinding.ensureInitialized();
....@@ -48,6 +49,7 @@
4849
4950 class _PAILotAppState extends ConsumerState<PAILotApp> {
5051 late final GoRouter _router;
52
+ bool _showSplash = true;
5153
5254 @override
5355 void initState() {
....@@ -78,6 +80,17 @@
7880 themeMode: themeMode,
7981 routerConfig: _router,
8082 debugShowCheckedModeBanner: false,
83
+ builder: (context, child) {
84
+ return Stack(
85
+ children: [
86
+ child ?? const SizedBox.shrink(),
87
+ if (_showSplash)
88
+ SplashScreen(onComplete: () {
89
+ if (mounted) setState(() => _showSplash = false);
90
+ }),
91
+ ],
92
+ );
93
+ },
8194 );
8295 }
8396 }
lib/screens/splash_screen.dart
....@@ -0,0 +1,311 @@
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
+}