feat: complete 20-screen onboarding flow
Full quiz flow s01-s20: splash, intro, value prop, social proof, name/skin-type/age/concerns/goal quiz with insights, analysing loader, result with skin score, AM/PM routines, ingredient science, signup, welcome. Duolingo-style design: flat cards, pressable SButton (Stack trick), 4px progress bar, mascot moods (wave/think/analyze/celebrate), confetti on result+welcome, personalized content throughout.
This commit is contained in:
100
lib/widgets/confetti.dart
Normal file
100
lib/widgets/confetti.dart
Normal file
@@ -0,0 +1,100 @@
|
||||
import 'dart:math';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class ConfettiOverlay extends StatefulWidget {
|
||||
const ConfettiOverlay({super.key});
|
||||
|
||||
@override
|
||||
State<ConfettiOverlay> createState() => _ConfettiOverlayState();
|
||||
}
|
||||
|
||||
class _ConfettiOverlayState extends State<ConfettiOverlay>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late AnimationController _ctrl;
|
||||
final _rand = Random();
|
||||
late List<_Particle> _particles;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_particles = List.generate(40, (_) => _Particle(_rand));
|
||||
_ctrl = AnimationController(
|
||||
vsync: this,
|
||||
duration: const Duration(milliseconds: 2400),
|
||||
)..forward();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_ctrl.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AnimatedBuilder(
|
||||
animation: _ctrl,
|
||||
builder: (_, __) => CustomPaint(
|
||||
painter: _ConfettiPainter(_particles, _ctrl.value),
|
||||
size: Size.infinite,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _Particle {
|
||||
final double x;
|
||||
double y;
|
||||
final double speed;
|
||||
final double size;
|
||||
final Color color;
|
||||
final double rotation;
|
||||
final double rotSpeed;
|
||||
|
||||
_Particle(Random r)
|
||||
: x = r.nextDouble(),
|
||||
y = -0.1 - r.nextDouble() * 0.3,
|
||||
speed = 0.3 + r.nextDouble() * 0.5,
|
||||
size = 6 + r.nextDouble() * 8,
|
||||
color = [
|
||||
const Color(0xFF5B4FCF),
|
||||
const Color(0xFFFF7EB3),
|
||||
const Color(0xFFFFD700),
|
||||
const Color(0xFF4CAF50),
|
||||
const Color(0xFF1CB0F6),
|
||||
const Color(0xFFFF7043),
|
||||
][r.nextInt(6)],
|
||||
rotation = r.nextDouble() * 2 * pi,
|
||||
rotSpeed = (r.nextDouble() - 0.5) * 6;
|
||||
}
|
||||
|
||||
class _ConfettiPainter extends CustomPainter {
|
||||
final List<_Particle> particles;
|
||||
final double t;
|
||||
|
||||
_ConfettiPainter(this.particles, this.t);
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
for (final p in particles) {
|
||||
final y = (p.y + t * p.speed) * size.height;
|
||||
if (y > size.height) continue;
|
||||
final x = p.x * size.width;
|
||||
final rot = p.rotation + t * p.rotSpeed;
|
||||
|
||||
canvas.save();
|
||||
canvas.translate(x, y);
|
||||
canvas.rotate(rot);
|
||||
|
||||
final paint = Paint()..color = p.color;
|
||||
canvas.drawRect(
|
||||
Rect.fromCenter(center: Offset.zero, width: p.size, height: p.size * 0.5),
|
||||
paint,
|
||||
);
|
||||
canvas.restore();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(_ConfettiPainter old) => old.t != t;
|
||||
}
|
||||
126
lib/widgets/mascot.dart
Normal file
126
lib/widgets/mascot.dart
Normal file
@@ -0,0 +1,126 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_animate/flutter_animate.dart';
|
||||
|
||||
enum MascotMood { neutral, wave, celebrate, think, analyze }
|
||||
|
||||
class Mascot extends StatelessWidget {
|
||||
final MascotMood mood;
|
||||
final double size;
|
||||
|
||||
const Mascot({super.key, this.mood = MascotMood.neutral, this.size = 160});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SizedBox(
|
||||
width: size,
|
||||
height: size,
|
||||
child: Stack(
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
_mascotImage(),
|
||||
if (mood == MascotMood.celebrate) ..._sparkles(),
|
||||
if (mood == MascotMood.analyze) _magnifier(),
|
||||
if (mood == MascotMood.think) _thoughtBubble(),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _mascotImage() {
|
||||
final img = Image.asset(
|
||||
'assets/images/shaynee_hero.png',
|
||||
width: size,
|
||||
height: size,
|
||||
fit: BoxFit.contain,
|
||||
);
|
||||
|
||||
return switch (mood) {
|
||||
MascotMood.neutral => img
|
||||
.animate(onPlay: (c) => c.repeat(reverse: true))
|
||||
.moveY(begin: 0, end: -8, duration: 1800.ms, curve: Curves.easeInOut),
|
||||
MascotMood.wave => img
|
||||
.animate(onPlay: (c) => c.repeat(reverse: true))
|
||||
.moveY(begin: 0, end: -6, duration: 1600.ms, curve: Curves.easeInOut),
|
||||
MascotMood.celebrate => img
|
||||
.animate(onPlay: (c) => c.repeat(reverse: true))
|
||||
.scaleXY(begin: 0.95, end: 1.05, duration: 700.ms, curve: Curves.easeInOut)
|
||||
.moveY(begin: 0, end: -10, duration: 700.ms, curve: Curves.easeInOut),
|
||||
MascotMood.think => img
|
||||
.animate(onPlay: (c) => c.repeat(reverse: true))
|
||||
.moveX(begin: 0, end: 4, duration: 2000.ms, curve: Curves.easeInOut),
|
||||
MascotMood.analyze => img
|
||||
.animate(onPlay: (c) => c.repeat(reverse: true))
|
||||
.moveY(begin: 0, end: -5, duration: 2000.ms, curve: Curves.easeInOut),
|
||||
};
|
||||
}
|
||||
|
||||
List<Widget> _sparkles() {
|
||||
const positions = [
|
||||
Offset(-50, -30), Offset(50, -40), Offset(-40, 30),
|
||||
Offset(55, 20), Offset(0, -55),
|
||||
];
|
||||
return positions.asMap().entries.map((e) {
|
||||
final i = e.key;
|
||||
final pos = e.value;
|
||||
return Positioned(
|
||||
left: size / 2 + pos.dx,
|
||||
top: size / 2 + pos.dy,
|
||||
child: _Sparkle(delay: i * 200),
|
||||
);
|
||||
}).toList();
|
||||
}
|
||||
|
||||
Widget _magnifier() {
|
||||
return Positioned(
|
||||
right: 0,
|
||||
bottom: size * 0.15,
|
||||
child: Container(
|
||||
width: size * 0.35,
|
||||
height: size * 0.35,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(color: const Color(0xFF5B4FCF), width: 3),
|
||||
color: Colors.white.withAlpha(180),
|
||||
),
|
||||
child: const Icon(Icons.search, color: Color(0xFF5B4FCF), size: 20),
|
||||
)
|
||||
.animate(onPlay: (c) => c.repeat(reverse: true))
|
||||
.rotate(begin: -0.05, end: 0.05, duration: 1500.ms),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _thoughtBubble() {
|
||||
return Positioned(
|
||||
top: 0,
|
||||
right: 0,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(8),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: const Color(0xFFE8E6F0), width: 1.5),
|
||||
boxShadow: [
|
||||
BoxShadow(color: Colors.black.withAlpha(12), blurRadius: 8),
|
||||
],
|
||||
),
|
||||
child: const Text('🤔', style: TextStyle(fontSize: 18)),
|
||||
)
|
||||
.animate()
|
||||
.fadeIn(duration: 500.ms)
|
||||
.scaleXY(begin: 0.5, end: 1.0, duration: 400.ms, curve: Curves.elasticOut),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _Sparkle extends StatelessWidget {
|
||||
final int delay;
|
||||
const _Sparkle({required this.delay});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return const Text('✨', style: TextStyle(fontSize: 14))
|
||||
.animate(onPlay: (c) => c.repeat(reverse: true), delay: Duration(milliseconds: delay))
|
||||
.scaleXY(begin: 0.5, end: 1.2, duration: 800.ms, curve: Curves.easeInOut)
|
||||
.fadeIn(duration: 400.ms);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user