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:
mav
2026-05-28 17:11:27 +02:00
parent 6311088256
commit 93042cbb4c
26 changed files with 2582 additions and 2 deletions

126
lib/widgets/mascot.dart Normal file
View 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);
}
}