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

View File

@@ -1,7 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'theme.dart'; import 'theme.dart';
import 'screens/splash_screen.dart'; import 'screens/onboarding/s01_splash.dart';
void main() { void main() {
WidgetsFlutterBinding.ensureInitialized(); WidgetsFlutterBinding.ensureInitialized();
@@ -21,7 +21,7 @@ class ShayneeApp extends StatelessWidget {
title: 'Shaynee', title: 'Shaynee',
debugShowCheckedModeBanner: false, debugShowCheckedModeBanner: false,
theme: buildTheme(), theme: buildTheme(),
home: const SplashScreen(), home: const SplashScreen(), // s01
); );
} }
} }

View File

@@ -0,0 +1,8 @@
class QuizState {
String name = '';
String skinType = '';
String age = '';
List<String> concerns = [];
String goal = '';
String referral = '';
}

27
lib/router.dart Normal file
View File

@@ -0,0 +1,27 @@
import 'package:flutter/material.dart';
Route<T> slideRoute<T>(Widget page) => PageRouteBuilder<T>(
pageBuilder: (_, a, __) => page,
transitionsBuilder: (_, a, __, child) => SlideTransition(
position: Tween<Offset>(begin: const Offset(1, 0), end: Offset.zero)
.animate(CurvedAnimation(parent: a, curve: Curves.easeOut)),
child: child,
),
transitionDuration: const Duration(milliseconds: 320),
);
Route<T> fadeRoute<T>(Widget page) => PageRouteBuilder<T>(
pageBuilder: (_, a, __) => page,
transitionsBuilder: (_, a, __, child) => FadeTransition(opacity: a, child: child),
transitionDuration: const Duration(milliseconds: 400),
);
Route<T> scaleRoute<T>(Widget page) => PageRouteBuilder<T>(
pageBuilder: (_, a, __) => page,
transitionsBuilder: (_, a, __, child) => ScaleTransition(
scale: Tween<double>(begin: 0.88, end: 1.0)
.animate(CurvedAnimation(parent: a, curve: Curves.easeOut)),
child: FadeTransition(opacity: a, child: child),
),
transitionDuration: const Duration(milliseconds: 350),
);

View File

@@ -0,0 +1,51 @@
import 'package:flutter/material.dart';
import '../../theme.dart';
class QuizNav extends StatelessWidget {
final int step;
final int total;
final VoidCallback onBack;
final String? skipLabel;
final VoidCallback? onSkip;
const QuizNav({
super.key,
required this.step,
required this.total,
required this.onBack,
this.skipLabel,
this.onSkip,
});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 0),
child: Row(
children: [
GestureDetector(
onTap: onBack,
child: Container(
width: 36, height: 36,
decoration: BoxDecoration(
color: SColors.bgSurface,
borderRadius: BorderRadius.circular(10),
border: Border.all(color: SColors.borderLight, width: 1.5),
),
child: const Icon(Icons.arrow_back_ios_new_rounded, size: 15, color: SColors.textBody),
),
),
const Spacer(),
Text('$step of $total', style: SText.caption(SColors.textSub).copyWith(fontWeight: FontWeight.w600)),
if (onSkip != null) ...[
const SizedBox(width: 16),
GestureDetector(
onTap: onSkip,
child: Text(skipLabel ?? 'Skip', style: SText.caption(SColors.textSub)),
),
],
],
),
);
}
}

View File

@@ -0,0 +1,47 @@
import 'package:flutter/material.dart';
import 'package:flutter_animate/flutter_animate.dart';
import '../../theme.dart';
import '../../widgets/mascot.dart';
import 's02_hi_im_shaynee.dart';
import '../../router.dart';
class SplashScreen extends StatefulWidget {
const SplashScreen({super.key});
@override
State<SplashScreen> createState() => _State();
}
class _State extends State<SplashScreen> {
@override
void initState() {
super.initState();
Future.delayed(const Duration(milliseconds: 2600), () {
if (mounted) Navigator.of(context).pushReplacement(fadeRoute(const HiImShayneeScreen()));
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: SColors.bgSurface,
body: SafeArea(
child: Column(
children: [
const Spacer(flex: 2),
Mascot(mood: MascotMood.neutral, size: 200)
.animate()
.fadeIn(duration: 700.ms, delay: 300.ms)
.slideY(begin: 0.1, end: 0, duration: 700.ms, curve: Curves.easeOut),
const SizedBox(height: 28),
Text('shaynee', style: SText.headline1(SColors.textHead).copyWith(fontSize: 44, letterSpacing: -1))
.animate().fadeIn(duration: 600.ms, delay: 600.ms),
const SizedBox(height: 6),
Text('your ai skincare companion', style: SText.subtitle(SColors.textSub))
.animate().fadeIn(duration: 600.ms, delay: 800.ms),
const Spacer(flex: 3),
],
),
),
);
}
}

View File

@@ -0,0 +1,99 @@
import 'package:flutter/material.dart';
import 'package:flutter_animate/flutter_animate.dart';
import '../../theme.dart';
import '../../widgets/mascot.dart';
import '../../widgets/s_button.dart';
import '../../router.dart';
import 's03_value_prop.dart';
class HiImShayneeScreen extends StatelessWidget {
const HiImShayneeScreen({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: SColors.bgSurface,
body: SafeArea(
child: Column(
children: [
// Illustration zone (top 45%)
Expanded(
flex: 45,
child: Stack(
alignment: Alignment.center,
children: [
Mascot(mood: MascotMood.wave, size: 180)
.animate()
.fadeIn(duration: 600.ms, delay: 200.ms)
.slideY(begin: 0.08, end: 0, duration: 600.ms, curve: Curves.easeOut),
// Speech bubble
Positioned(
top: 30,
right: 40,
child: _SpeechBubble(text: 'Hi there! 👋')
.animate()
.fadeIn(duration: 500.ms, delay: 800.ms)
.scaleXY(begin: 0.6, end: 1.0, duration: 400.ms, curve: Curves.elasticOut),
),
],
),
),
// Content zone
Expanded(
flex: 55,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(height: 8),
Text("I'm Shaynee,\nyour skin bestie ✨",
style: SText.headline2(SColors.textHead))
.animate().fadeIn(duration: 400.ms, delay: 300.ms)
.slideY(begin: 0.06, end: 0, duration: 400.ms),
const SizedBox(height: 10),
Text("I'll analyse your skin, build a routine that\nactually works, and guide you daily.",
style: SText.subtitle(SColors.textSub))
.animate().fadeIn(duration: 400.ms, delay: 420.ms),
const Spacer(),
SButton(
label: "LET'S GO",
onTap: () => Navigator.of(context).push(slideRoute(const ValuePropScreen())),
)
.animate().fadeIn(duration: 400.ms, delay: 600.ms)
.slideY(begin: 0.1, end: 0, duration: 400.ms),
const SizedBox(height: 20),
],
),
),
),
],
),
),
);
}
}
class _SpeechBubble extends StatelessWidget {
final String text;
const _SpeechBubble({required this.text});
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
decoration: BoxDecoration(
color: SColors.bgSurface,
borderRadius: const BorderRadius.only(
topLeft: Radius.circular(16),
topRight: Radius.circular(16),
bottomRight: Radius.circular(16),
bottomLeft: Radius.circular(4),
),
border: Border.all(color: SColors.borderLight, width: 1.5),
boxShadow: [BoxShadow(color: Colors.black.withAlpha(10), blurRadius: 8, offset: const Offset(0, 2))],
),
child: Text(text, style: SText.cardLabel(SColors.textHead).copyWith(fontSize: 14)),
);
}
}

View File

@@ -0,0 +1,135 @@
import 'package:flutter/material.dart';
import 'package:flutter_animate/flutter_animate.dart';
import '../../theme.dart';
import '../../widgets/s_button.dart';
import '../../router.dart';
import 's04_social_proof.dart';
class ValuePropScreen extends StatefulWidget {
const ValuePropScreen({super.key});
@override
State<ValuePropScreen> createState() => _State();
}
class _State extends State<ValuePropScreen> {
int _page = 0;
static const _props = [
(Icons.auto_awesome_rounded, '🔬', 'AI-powered\nskin analysis', 'Upload a selfie or answer quick\nquestions — Shaynee reads your skin.'),
(Icons.science_rounded, '💧', 'Ingredient\nscience', 'Know what every product does to\nyour skin before you buy it.'),
(Icons.trending_up_rounded, '📈', 'Routine that\ngets results', '94% of users see visible improvement\nwithin 4 weeks.'),
];
@override
Widget build(BuildContext context) {
final (_, emoji, title, sub) = _props[_page];
final isLast = _page == _props.length - 1;
return Scaffold(
backgroundColor: SColors.bgSurface,
body: SafeArea(
child: Column(
children: [
// Illustration zone
Expanded(
flex: 45,
child: AnimatedSwitcher(
duration: const Duration(milliseconds: 350),
child: _IllustrationCard(key: ValueKey(_page), emoji: emoji),
),
),
// Content zone
Expanded(
flex: 55,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(height: 8),
// Dots
Row(
children: List.generate(_props.length, (i) => _dot(i)),
).animate().fadeIn(duration: 400.ms),
const SizedBox(height: 20),
AnimatedSwitcher(
duration: const Duration(milliseconds: 300),
child: Column(
key: ValueKey(_page),
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title, style: SText.headline2(SColors.textHead))
.animate().fadeIn(duration: 350.ms).slideX(begin: 0.04, end: 0),
const SizedBox(height: 10),
Text(sub, style: SText.subtitle(SColors.textSub))
.animate().fadeIn(duration: 350.ms, delay: 80.ms),
],
),
),
const Spacer(),
SButton(
label: isLast ? 'SOUNDS GOOD' : 'NEXT',
onTap: () {
if (isLast) {
Navigator.of(context).push(slideRoute(const SocialProofScreen()));
} else {
setState(() => _page++);
}
},
).animate().fadeIn(duration: 400.ms, delay: 200.ms),
const SizedBox(height: 12),
if (!isLast)
Center(
child: GestureDetector(
onTap: () => Navigator.of(context).push(slideRoute(const SocialProofScreen())),
child: Text('Skip', style: SText.caption(SColors.textSub)),
),
),
const SizedBox(height: 20),
],
),
),
),
],
),
),
);
}
Widget _dot(int i) {
return AnimatedContainer(
duration: const Duration(milliseconds: 250),
margin: const EdgeInsets.only(right: 6),
width: _page == i ? 20 : 8,
height: 8,
decoration: BoxDecoration(
color: _page == i ? SColors.primary : SColors.borderLight,
borderRadius: BorderRadius.circular(4),
),
);
}
}
class _IllustrationCard extends StatelessWidget {
final String emoji;
const _IllustrationCard({super.key, required this.emoji});
@override
Widget build(BuildContext context) {
return Center(
child: Container(
width: 160,
height: 160,
decoration: BoxDecoration(
color: SColors.primaryLight,
borderRadius: BorderRadius.circular(40),
border: Border.all(color: SColors.borderLight, width: 1.5),
),
alignment: Alignment.center,
child: Text(emoji, style: const TextStyle(fontSize: 72)),
)
.animate(onPlay: (c) => c.repeat(reverse: true))
.moveY(begin: 0, end: -8, duration: 2000.ms, curve: Curves.easeInOut),
);
}
}

View File

@@ -0,0 +1,129 @@
import 'package:flutter/material.dart';
import 'package:flutter_animate/flutter_animate.dart';
import '../../theme.dart';
import '../../widgets/s_button.dart';
import '../../router.dart';
import 's05_name.dart';
class SocialProofScreen extends StatelessWidget {
const SocialProofScreen({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: SColors.bgSurface,
body: SafeArea(
child: Column(
children: [
// Illustration zone
Expanded(
flex: 40,
child: Center(
child: _StatsVisual()
.animate()
.fadeIn(duration: 500.ms, delay: 100.ms)
.scaleXY(begin: 0.9, end: 1.0, duration: 500.ms, curve: Curves.easeOut),
),
),
// Content zone
Expanded(
flex: 60,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(height: 8),
Text('Trusted by\n50,000+ women', style: SText.headline2(SColors.textHead))
.animate().fadeIn(duration: 400.ms, delay: 200.ms)
.slideY(begin: 0.06, end: 0, duration: 400.ms),
const SizedBox(height: 12),
_testimonial().animate().fadeIn(duration: 400.ms, delay: 360.ms),
const Spacer(),
Row(
children: List.generate(3, (i) => _statChip(i)),
).animate().fadeIn(duration: 400.ms, delay: 480.ms),
const SizedBox(height: 20),
SButton(
label: "BUILD MY ROUTINE",
onTap: () => Navigator.of(context).push(slideRoute(NameScreen())),
).animate().fadeIn(duration: 400.ms, delay: 560.ms),
const SizedBox(height: 20),
],
),
),
),
],
),
),
);
}
Widget _testimonial() {
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: SColors.primaryLight,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: SColors.borderLight),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('⭐⭐⭐⭐⭐', style: TextStyle(fontSize: 16)),
const SizedBox(height: 8),
Text(
'"My skin cleared up in 3 weeks. The routine Shaynee built actually works — I finally know what I\'m putting on my face."',
style: SText.body(SColors.textBody).copyWith(fontStyle: FontStyle.italic),
),
const SizedBox(height: 8),
Text('— Ayla K., 28', style: SText.caption(SColors.textSub)),
],
),
);
}
Widget _statChip(int i) {
const data = [('50K+', 'Users'), ('4.9★', 'Rating'), ('94%', 'Results')];
final (value, label) = data[i];
return Expanded(
child: Container(
margin: EdgeInsets.only(right: i < 2 ? 8 : 0),
padding: const EdgeInsets.symmetric(vertical: 14),
decoration: BoxDecoration(
color: SColors.bgSurface,
borderRadius: BorderRadius.circular(14),
border: Border.all(color: SColors.borderLight, width: 1.5),
),
child: Column(
children: [
Text(value, style: SText.title(SColors.primary)),
const SizedBox(height: 2),
Text(label, style: SText.caption(SColors.textSub)),
],
),
),
);
}
}
class _StatsVisual extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Stack(
alignment: Alignment.center,
children: [
Container(
width: 160, height: 160,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: SColors.primaryLight,
),
),
const Text('💜', style: TextStyle(fontSize: 64))
.animate(onPlay: (c) => c.repeat(reverse: true))
.scaleXY(begin: 0.95, end: 1.05, duration: 1800.ms, curve: Curves.easeInOut),
],
);
}
}

View File

@@ -0,0 +1,134 @@
import 'package:flutter/material.dart';
import 'package:flutter_animate/flutter_animate.dart';
import '../../theme.dart';
import '../../widgets/mascot.dart';
import '../../widgets/s_button.dart';
import '../../widgets/s_progress_bar.dart';
import '../../model/quiz_state.dart';
import '../../router.dart';
import 's06_skin_type.dart';
class NameScreen extends StatefulWidget {
final QuizState state;
NameScreen({super.key, QuizState? state}) : state = state ?? QuizState();
@override
State<NameScreen> createState() => _State();
}
class _State extends State<NameScreen> {
late final QuizState _quiz;
final _ctrl = TextEditingController();
bool _ready = false;
@override
void initState() {
super.initState();
_quiz = widget.state;
_ctrl.addListener(() => setState(() => _ready = _ctrl.text.trim().length >= 2));
}
@override
void dispose() { _ctrl.dispose(); super.dispose(); }
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: SColors.bgSurface,
resizeToAvoidBottomInset: true,
body: SafeArea(
child: Column(
children: [
_nav(),
const SizedBox(height: 10),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: SProgressBar(value: 1 / 7),
),
Expanded(
child: SingleChildScrollView(
padding: const EdgeInsets.symmetric(horizontal: 24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(height: 24),
Center(
child: Mascot(mood: MascotMood.wave, size: 130)
.animate().fadeIn(duration: 500.ms).slideY(begin: 0.05, end: 0),
),
const SizedBox(height: 24),
Text("Hey! What's\nyour name? 👋", style: SText.headline2(SColors.textHead))
.animate().fadeIn(duration: 400.ms, delay: 150.ms),
const SizedBox(height: 8),
Text("I'll personalise everything just for you.", style: SText.subtitle(SColors.textSub))
.animate().fadeIn(duration: 400.ms, delay: 220.ms),
const SizedBox(height: 28),
_nameInput().animate().fadeIn(duration: 400.ms, delay: 300.ms),
const SizedBox(height: 16),
],
),
),
),
Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 20),
child: SButton(
label: 'CONTINUE',
enabled: _ready,
onTap: _ready ? () {
_quiz.name = _ctrl.text.trim();
Navigator.of(context).push(slideRoute(SkinTypeScreen(state: _quiz)));
} : null,
).animate().fadeIn(duration: 400.ms, delay: 400.ms),
),
],
),
),
);
}
Widget _nav() {
return Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 0),
child: Row(
children: [
GestureDetector(
onTap: () => Navigator.of(context).pop(),
child: Container(
width: 36, height: 36,
decoration: BoxDecoration(
color: SColors.bgSurface,
borderRadius: BorderRadius.circular(10),
border: Border.all(color: SColors.borderLight, width: 1.5),
),
child: const Icon(Icons.arrow_back_ios_new_rounded, size: 15, color: SColors.textBody),
),
),
const Spacer(),
Text('1 of 7', style: SText.caption(SColors.textSub).copyWith(fontWeight: FontWeight.w600)),
],
),
);
}
Widget _nameInput() {
return Container(
decoration: BoxDecoration(
color: SColors.bgSurface,
borderRadius: BorderRadius.circular(14),
border: Border.all(color: SColors.borderMedium, width: 1.5),
),
child: TextField(
controller: _ctrl,
autofocus: true,
textCapitalization: TextCapitalization.words,
style: SText.cardLabel(SColors.textHead).copyWith(fontSize: 18),
decoration: InputDecoration(
hintText: 'Your first name',
hintStyle: SText.cardLabel(SColors.textDisabled).copyWith(fontSize: 18, fontWeight: FontWeight.w500),
border: InputBorder.none,
contentPadding: const EdgeInsets.symmetric(horizontal: 18, vertical: 16),
),
),
);
}
}

View File

@@ -0,0 +1,96 @@
import 'package:flutter/material.dart';
import 'package:flutter_animate/flutter_animate.dart';
import '../../theme.dart';
import '../../widgets/s_card.dart';
import '../../widgets/s_button.dart';
import '../../widgets/s_progress_bar.dart';
import '../../model/quiz_state.dart';
import '../../router.dart';
import 's07_skin_insight.dart';
import '_quiz_nav.dart';
class SkinTypeScreen extends StatefulWidget {
final QuizState state;
const SkinTypeScreen({super.key, required this.state});
@override
State<SkinTypeScreen> createState() => _State();
}
class _State extends State<SkinTypeScreen> {
String _selected = '';
static const _opts = [
(Icons.water_drop_rounded, 'Oily', 'Shiny by midday, large pores'),
(Icons.grass_rounded, 'Dry', 'Tight, flaky, rough patches'),
(Icons.blur_on_rounded, 'Combination', 'Oily T-zone, dry cheeks'),
(Icons.sentiment_satisfied_alt_rounded, 'Normal', 'Balanced, minimal issues'),
(Icons.help_outline_rounded, 'Not sure', "Help me figure it out"),
];
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: SColors.bgSurface,
body: SafeArea(
child: Column(
children: [
QuizNav(step: 2, total: 7, onBack: () => Navigator.of(context).pop()),
const SizedBox(height: 10),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: SProgressBar(value: 2 / 7),
),
Expanded(
child: SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(16, 24, 16, 0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text("What's your\nskin type?", style: SText.headline2(SColors.textHead))
.animate().fadeIn(duration: 350.ms),
const SizedBox(height: 6),
Text('Pick the one that fits best.', style: SText.subtitle(SColors.textSub))
.animate().fadeIn(duration: 350.ms, delay: 80.ms),
const SizedBox(height: 20),
],
),
),
..._opts.asMap().entries.map((e) {
final i = e.key;
final (icon, label, sub) = e.value;
return Padding(
padding: const EdgeInsets.only(bottom: 8),
child: SCard(
icon: icon, label: label, sub: sub,
isSelected: _selected == label,
onTap: () => setState(() => _selected = label),
).animate().fadeIn(duration: 280.ms, delay: (100 + i * 55).ms)
.slideX(begin: 0.03, end: 0, duration: 280.ms),
);
}),
],
),
),
),
Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 20),
child: SButton(
label: 'CONTINUE',
enabled: _selected.isNotEmpty,
onTap: _selected.isNotEmpty ? () {
widget.state.skinType = _selected;
Navigator.of(context).push(slideRoute(SkinInsightScreen(state: widget.state)));
} : null,
),
),
],
),
),
);
}
}

View File

@@ -0,0 +1,106 @@
import 'package:flutter/material.dart';
import 'package:flutter_animate/flutter_animate.dart';
import '../../theme.dart';
import '../../widgets/mascot.dart';
import '../../widgets/s_button.dart';
import '../../model/quiz_state.dart';
import '../../router.dart';
import 's08_age.dart';
class SkinInsightScreen extends StatelessWidget {
final QuizState state;
const SkinInsightScreen({super.key, required this.state});
static const _insights = {
'Oily': ('💧', 'Oily skin ages slower!', 'The extra sebum keeps skin moisturised naturally. With the right routine, you can have a balanced, glowing complexion.'),
'Dry': ('🌸', 'Dry skin loves rich textures.', 'Ceramides and hyaluronic acid are your BFFs. We\'ll build a routine that keeps your moisture barrier strong.'),
'Combination': ('⚖️', 'Combination skin needs balance.', 'The trick is targeted treatment — light hydration for the T-zone, richer care for dry areas.'),
'Normal': ('', 'Normal skin is rare!', 'Less than 15% of people have it. The goal is maintaining what you\'ve got with a simple, consistent routine.'),
'Not sure': ('🔍', 'Let\'s figure it out together.', 'Your skin tells a story — we\'ll decode it for you with a quick deep dive into your habits and concerns.'),
};
@override
Widget build(BuildContext context) {
final (emoji, title, body) = _insights[state.skinType] ?? _insights['Normal']!;
return Scaffold(
backgroundColor: SColors.bgSurface,
body: SafeArea(
child: Column(
children: [
// Illustration zone
Expanded(
flex: 42,
child: Stack(
alignment: Alignment.center,
children: [
Mascot(mood: MascotMood.think, size: 160)
.animate().fadeIn(duration: 500.ms).slideY(begin: 0.06, end: 0),
Positioned(
top: 24,
right: 50,
child: _bubble(emoji)
.animate().fadeIn(duration: 400.ms, delay: 600.ms)
.scaleXY(begin: 0.5, end: 1.0, duration: 400.ms, curve: Curves.elasticOut),
),
],
),
),
// Content zone
Expanded(
flex: 58,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(height: 8),
_badge('Did you know?')
.animate().fadeIn(duration: 350.ms, delay: 100.ms),
const SizedBox(height: 14),
Text(title, style: SText.headline2(SColors.textHead))
.animate().fadeIn(duration: 400.ms, delay: 180.ms)
.slideY(begin: 0.05, end: 0),
const SizedBox(height: 10),
Text(body, style: SText.subtitle(SColors.textSub))
.animate().fadeIn(duration: 400.ms, delay: 260.ms),
const Spacer(),
SButton(
label: 'COOL, GOT IT',
onTap: () => Navigator.of(context).push(slideRoute(AgeScreen(state: state))),
).animate().fadeIn(duration: 400.ms, delay: 400.ms),
const SizedBox(height: 20),
],
),
),
),
],
),
),
);
}
Widget _bubble(String emoji) {
return Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: SColors.bgSurface,
shape: BoxShape.circle,
border: Border.all(color: SColors.borderLight, width: 1.5),
boxShadow: [BoxShadow(color: Colors.black.withAlpha(10), blurRadius: 8)],
),
child: Text(emoji, style: const TextStyle(fontSize: 28)),
);
}
Widget _badge(String label) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: SColors.primaryLight,
borderRadius: BorderRadius.circular(20),
),
child: Text(label, style: SText.caption(SColors.primary).copyWith(fontWeight: FontWeight.w700)),
);
}
}

View File

@@ -0,0 +1,92 @@
import 'package:flutter/material.dart';
import 'package:flutter_animate/flutter_animate.dart';
import '../../theme.dart';
import '../../widgets/s_card.dart';
import '../../widgets/s_button.dart';
import '../../widgets/s_progress_bar.dart';
import '../../model/quiz_state.dart';
import '../../router.dart';
import 's09_concerns.dart';
import '_quiz_nav.dart';
class AgeScreen extends StatefulWidget {
final QuizState state;
const AgeScreen({super.key, required this.state});
@override
State<AgeScreen> createState() => _State();
}
class _State extends State<AgeScreen> {
String _selected = '';
static const _opts = [
(Icons.star_rounded, 'Under 25', 'Young & preventive care'),
(Icons.hourglass_bottom_rounded, '2535', 'Early anti-aging focus'),
(Icons.wb_sunny_rounded, '3545', 'Targeted treatments'),
(Icons.nightlight_round, '4560', 'Deep renewal & firming'),
(Icons.diamond_rounded, '60+', 'Intensive care & radiance'),
];
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: SColors.bgSurface,
body: SafeArea(
child: Column(
children: [
QuizNav(step: 3, total: 7, onBack: () => Navigator.of(context).pop()),
const SizedBox(height: 10),
Padding(padding: const EdgeInsets.symmetric(horizontal: 16), child: SProgressBar(value: 3 / 7)),
Expanded(
child: SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(16, 24, 16, 0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('How old\nare you?', style: SText.headline2(SColors.textHead))
.animate().fadeIn(duration: 350.ms),
const SizedBox(height: 6),
Text('Skin changes with age — helps us tailor advice.', style: SText.subtitle(SColors.textSub))
.animate().fadeIn(duration: 350.ms, delay: 80.ms),
const SizedBox(height: 20),
],
),
),
..._opts.asMap().entries.map((e) {
final i = e.key; final (icon, label, sub) = e.value;
return Padding(
padding: const EdgeInsets.only(bottom: 8),
child: SCard(
icon: icon, label: label, sub: sub,
isSelected: _selected == label,
onTap: () => setState(() => _selected = label),
).animate().fadeIn(duration: 280.ms, delay: (100 + i * 55).ms)
.slideX(begin: 0.03, end: 0, duration: 280.ms),
);
}),
],
),
),
),
Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 20),
child: SButton(
label: 'CONTINUE',
enabled: _selected.isNotEmpty,
onTap: _selected.isNotEmpty ? () {
widget.state.age = _selected;
Navigator.of(context).push(slideRoute(ConcernsScreen(state: widget.state)));
} : null,
),
),
],
),
),
);
}
}

View File

@@ -0,0 +1,128 @@
import 'package:flutter/material.dart';
import 'package:flutter_animate/flutter_animate.dart';
import '../../theme.dart';
import '../../widgets/s_card.dart';
import '../../widgets/s_button.dart';
import '../../widgets/s_progress_bar.dart';
import '../../model/quiz_state.dart';
import '../../router.dart';
import 's10_concern_insight.dart';
import '_quiz_nav.dart';
class ConcernsScreen extends StatefulWidget {
final QuizState state;
const ConcernsScreen({super.key, required this.state});
@override
State<ConcernsScreen> createState() => _State();
}
class _State extends State<ConcernsScreen> {
final _selected = <String>[];
static const _opts = [
(Icons.face_retouching_natural, 'Breakouts', 'Acne, blackheads, congestion'),
(Icons.cloud_rounded, 'Dullness', 'Tired, uneven, grey skin'),
(Icons.air_rounded, 'Dryness', 'Tight, rough, flaking'),
(Icons.format_align_justify_rounded, 'Fine lines', 'Wrinkles, loss of firmness'),
(Icons.adjust_rounded, 'Dark spots', 'Hyperpigmentation, melasma'),
(Icons.local_fire_department_rounded, 'Redness', 'Sensitivity, flushing, rosacea'),
];
void _toggle(String label) {
setState(() {
if (_selected.contains(label)) {
_selected.remove(label);
} else if (_selected.length < 3) {
_selected.add(label);
}
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: SColors.bgSurface,
body: SafeArea(
child: Column(
children: [
QuizNav(step: 4, total: 7, onBack: () => Navigator.of(context).pop()),
const SizedBox(height: 10),
Padding(padding: const EdgeInsets.symmetric(horizontal: 16), child: SProgressBar(value: 4 / 7)),
Expanded(
child: SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(16, 24, 16, 0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Text('Your skin\nconcerns?', style: SText.headline2(SColors.textHead))
.animate().fadeIn(duration: 350.ms),
const Spacer(),
AnimatedContainer(
duration: const Duration(milliseconds: 200),
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5),
decoration: BoxDecoration(
color: _selected.isNotEmpty ? SColors.primary : SColors.borderLight,
borderRadius: BorderRadius.circular(20),
),
child: Text(
'${_selected.length}/3',
style: SText.caption(
_selected.isNotEmpty ? Colors.white : SColors.textSub,
).copyWith(fontWeight: FontWeight.w700),
),
),
],
),
const SizedBox(height: 6),
Text('Pick up to 3 that bother you most.', style: SText.subtitle(SColors.textSub))
.animate().fadeIn(duration: 350.ms, delay: 80.ms),
const SizedBox(height: 20),
],
),
),
..._opts.asMap().entries.map((e) {
final i = e.key; final (icon, label, sub) = e.value;
final isSel = _selected.contains(label);
final disabled = !isSel && _selected.length >= 3;
return Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Opacity(
opacity: disabled ? 0.35 : 1.0,
child: SCard(
icon: icon, label: label, sub: sub,
isSelected: isSel,
onTap: disabled ? () {} : () => _toggle(label),
).animate().fadeIn(duration: 280.ms, delay: (100 + i * 55).ms)
.slideX(begin: 0.03, end: 0, duration: 280.ms),
),
);
}),
],
),
),
),
Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 20),
child: SButton(
label: 'CONTINUE',
enabled: _selected.isNotEmpty,
onTap: _selected.isNotEmpty ? () {
widget.state.concerns = List.from(_selected);
Navigator.of(context).push(slideRoute(ConcernInsightScreen(state: widget.state)));
} : null,
),
),
],
),
),
);
}
}

View File

@@ -0,0 +1,131 @@
import 'package:flutter/material.dart';
import 'package:flutter_animate/flutter_animate.dart';
import '../../theme.dart';
import '../../widgets/mascot.dart';
import '../../widgets/s_button.dart';
import '../../model/quiz_state.dart';
import '../../router.dart';
import 's11_goal.dart';
class ConcernInsightScreen extends StatelessWidget {
final QuizState state;
const ConcernInsightScreen({super.key, required this.state});
@override
Widget build(BuildContext context) {
final first = state.concerns.isNotEmpty ? state.concerns.first : 'skin concerns';
final emoji = _emoji(first);
final tip = _tip(first);
return Scaffold(
backgroundColor: SColors.bgSurface,
body: SafeArea(
child: Column(
children: [
Expanded(
flex: 42,
child: Stack(
alignment: Alignment.center,
children: [
Mascot(mood: MascotMood.analyze, size: 160)
.animate().fadeIn(duration: 500.ms).slideY(begin: 0.05, end: 0),
Positioned(
bottom: 20,
right: 40,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
decoration: BoxDecoration(
color: SColors.primaryLight,
borderRadius: BorderRadius.circular(20),
border: Border.all(color: SColors.borderLight),
),
child: Text(emoji, style: const TextStyle(fontSize: 22)),
).animate().fadeIn(duration: 400.ms, delay: 700.ms)
.scaleXY(begin: 0.6, end: 1.0, duration: 350.ms, curve: Curves.elasticOut),
),
],
),
),
Expanded(
flex: 58,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(height: 8),
_badge()
.animate().fadeIn(duration: 300.ms, delay: 100.ms),
const SizedBox(height: 14),
Text('I see you picked\n"$first" 👀', style: SText.headline2(SColors.textHead))
.animate().fadeIn(duration: 400.ms, delay: 180.ms)
.slideY(begin: 0.05, end: 0),
const SizedBox(height: 10),
Text(tip, style: SText.subtitle(SColors.textSub))
.animate().fadeIn(duration: 400.ms, delay: 260.ms),
const SizedBox(height: 16),
_concernBubbles()
.animate().fadeIn(duration: 400.ms, delay: 350.ms),
const Spacer(),
SButton(
label: "WE'VE GOT YOU",
onTap: () => Navigator.of(context).push(slideRoute(GoalScreen(state: state))),
).animate().fadeIn(duration: 400.ms, delay: 500.ms),
const SizedBox(height: 20),
],
),
),
),
],
),
),
);
}
String _emoji(String concern) => switch (concern) {
'Breakouts' => '🫧',
'Dullness' => '🌤️',
'Dryness' => '💧',
'Fine lines' => '',
'Dark spots' => '🎯',
'Redness' => '🌹',
_ => '🔍',
};
String _tip(String concern) => switch (concern) {
'Breakouts' => '80% of breakouts are triggered by products you\'re already using. We\'ll audit your routine and clear the way.',
'Dullness' => 'Dullness is usually dead skin buildup. The right exfoliation schedule can reveal your glow in just 2 weeks.',
'Dryness' => 'True dryness means your barrier is compromised. We\'ll focus on healing and long-lasting moisture — not just drinking water.',
'Fine lines' => 'Starting prevention at any age works. Retinol, SPF, and peptides are your long-game trio.',
'Dark spots' => 'Vitamin C in the morning + SPF every day is the combo that fades spots fastest. We\'ll build around that.',
'Redness' => 'Redness often signals a disrupted barrier. Gentle, fragrance-free products and niacinamide will calm things down.',
_ => 'We\'ll address each concern with targeted ingredients and a smart routine.',
};
Widget _badge() {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: SColors.primaryLight,
borderRadius: BorderRadius.circular(20),
),
child: Text('Shaynee insight', style: SText.caption(SColors.primary).copyWith(fontWeight: FontWeight.w700)),
);
}
Widget _concernBubbles() {
return Wrap(
spacing: 8,
runSpacing: 8,
children: state.concerns.map((c) => Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: SColors.bgSurface,
borderRadius: BorderRadius.circular(20),
border: Border.all(color: SColors.primary.withAlpha(80)),
),
child: Text(c, style: SText.caption(SColors.primary).copyWith(fontWeight: FontWeight.w600)),
)).toList(),
);
}
}

View File

@@ -0,0 +1,91 @@
import 'package:flutter/material.dart';
import 'package:flutter_animate/flutter_animate.dart';
import '../../theme.dart';
import '../../widgets/s_card.dart';
import '../../widgets/s_button.dart';
import '../../widgets/s_progress_bar.dart';
import '../../model/quiz_state.dart';
import '../../router.dart';
import 's12_almost_there.dart';
import '_quiz_nav.dart';
class GoalScreen extends StatefulWidget {
final QuizState state;
const GoalScreen({super.key, required this.state});
@override
State<GoalScreen> createState() => _State();
}
class _State extends State<GoalScreen> {
String _selected = '';
static const _opts = [
(Icons.auto_awesome_rounded, 'Healthy glow', 'Radiant, dewy, lit-from-within'),
(Icons.shield_rounded, 'Clear skin', 'Blemish-free, smooth texture'),
(Icons.access_time_rounded, 'Slow aging', 'Firm, youthful, preventive'),
(Icons.palette_rounded, 'Even tone', 'Balanced, bright complexion'),
];
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: SColors.bgSurface,
body: SafeArea(
child: Column(
children: [
QuizNav(step: 5, total: 7, onBack: () => Navigator.of(context).pop()),
const SizedBox(height: 10),
Padding(padding: const EdgeInsets.symmetric(horizontal: 16), child: SProgressBar(value: 5 / 7)),
Expanded(
child: SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(16, 24, 16, 0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Your main\nskin goal?', style: SText.headline2(SColors.textHead))
.animate().fadeIn(duration: 350.ms),
const SizedBox(height: 6),
Text("We'll build your entire plan around this.", style: SText.subtitle(SColors.textSub))
.animate().fadeIn(duration: 350.ms, delay: 80.ms),
const SizedBox(height: 20),
],
),
),
..._opts.asMap().entries.map((e) {
final i = e.key; final (icon, label, sub) = e.value;
return Padding(
padding: const EdgeInsets.only(bottom: 8),
child: SCard(
icon: icon, label: label, sub: sub,
isSelected: _selected == label,
onTap: () => setState(() => _selected = label),
).animate().fadeIn(duration: 280.ms, delay: (100 + i * 55).ms)
.slideX(begin: 0.03, end: 0, duration: 280.ms),
);
}),
],
),
),
),
Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 20),
child: SButton(
label: 'CONTINUE',
enabled: _selected.isNotEmpty,
onTap: _selected.isNotEmpty ? () {
widget.state.goal = _selected;
Navigator.of(context).push(slideRoute(AlmostThereScreen(state: widget.state)));
} : null,
),
),
],
),
),
);
}
}

View File

@@ -0,0 +1,107 @@
import 'package:flutter/material.dart';
import 'package:flutter_animate/flutter_animate.dart';
import '../../theme.dart';
import '../../widgets/mascot.dart';
import '../../widgets/s_button.dart';
import '../../model/quiz_state.dart';
import '../../router.dart';
import 's13_referral.dart';
class AlmostThereScreen extends StatelessWidget {
final QuizState state;
const AlmostThereScreen({super.key, required this.state});
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: SColors.bgSurface,
body: SafeArea(
child: Column(
children: [
Expanded(
flex: 45,
child: Stack(
alignment: Alignment.center,
children: [
Mascot(mood: MascotMood.celebrate, size: 180)
.animate().fadeIn(duration: 600.ms).slideY(begin: 0.06, end: 0),
Positioned(
top: 20, left: 50,
child: const Text('🎉', style: TextStyle(fontSize: 32))
.animate(onPlay: (c) => c.repeat(reverse: true), delay: 400.ms)
.moveY(begin: 0, end: -8, duration: 1200.ms),
),
Positioned(
top: 30, right: 45,
child: const Text('', style: TextStyle(fontSize: 24))
.animate(onPlay: (c) => c.repeat(reverse: true), delay: 200.ms)
.moveY(begin: 0, end: -10, duration: 1000.ms),
),
],
),
),
Expanded(
flex: 55,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(height: 8),
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: const Color(0xFFFFF3CD),
borderRadius: BorderRadius.circular(20),
),
child: Text('Almost there! 🔥', style: SText.caption(const Color(0xFF856404)).copyWith(fontWeight: FontWeight.w700)),
).animate().fadeIn(duration: 350.ms, delay: 200.ms),
const SizedBox(height: 14),
Text("You're doing\ngreat, ${state.name.split(' ').first}! 💪",
style: SText.headline2(SColors.textHead))
.animate().fadeIn(duration: 400.ms, delay: 300.ms)
.slideY(begin: 0.05, end: 0),
const SizedBox(height: 10),
Text("Just one more question and your personalised skin report will be ready.",
style: SText.subtitle(SColors.textSub))
.animate().fadeIn(duration: 400.ms, delay: 400.ms),
const SizedBox(height: 20),
_summaryChips(state)
.animate().fadeIn(duration: 400.ms, delay: 500.ms),
const Spacer(),
SButton(
label: "ONE MORE THING",
onTap: () => Navigator.of(context).push(slideRoute(ReferralScreen(state: state))),
).animate().fadeIn(duration: 400.ms, delay: 600.ms),
const SizedBox(height: 20),
],
),
),
),
],
),
),
);
}
Widget _summaryChips(QuizState state) {
final items = [
state.skinType, state.age, state.goal,
...state.concerns,
].where((s) => s.isNotEmpty).take(4);
return Wrap(
spacing: 8,
runSpacing: 8,
children: items.map((item) => Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5),
decoration: BoxDecoration(
color: SColors.primaryLight,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: SColors.borderLight),
),
child: Text(item, style: SText.caption(SColors.primary).copyWith(fontWeight: FontWeight.w600)),
)).toList(),
);
}
}

View File

@@ -0,0 +1,101 @@
import 'package:flutter/material.dart';
import 'package:flutter_animate/flutter_animate.dart';
import '../../theme.dart';
import '../../widgets/s_card.dart';
import '../../widgets/s_button.dart';
import '../../widgets/s_progress_bar.dart';
import '../../model/quiz_state.dart';
import '../../router.dart';
import 's14_analysing.dart';
import '_quiz_nav.dart';
class ReferralScreen extends StatefulWidget {
final QuizState state;
const ReferralScreen({super.key, required this.state});
@override
State<ReferralScreen> createState() => _State();
}
class _State extends State<ReferralScreen> {
String _selected = '';
static const _opts = [
(Icons.people_rounded, 'Friend or family', 'They recommended it'),
(Icons.play_circle_rounded, 'TikTok / Instagram', 'Saw it on social media'),
(Icons.search_rounded, 'Google search', 'Found it myself'),
(Icons.star_rounded, 'App Store', 'Featured or reviewed'),
(Icons.more_horiz_rounded, 'Other', 'Somewhere else'),
];
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: SColors.bgSurface,
body: SafeArea(
child: Column(
children: [
QuizNav(step: 6, total: 7, onBack: () => Navigator.of(context).pop()),
const SizedBox(height: 10),
Padding(padding: const EdgeInsets.symmetric(horizontal: 16), child: SProgressBar(value: 6 / 7)),
Expanded(
child: SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(16, 24, 16, 0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('How did you\nhear about us?', style: SText.headline2(SColors.textHead))
.animate().fadeIn(duration: 350.ms),
const SizedBox(height: 6),
Text("Helps us understand where our community comes from.", style: SText.subtitle(SColors.textSub))
.animate().fadeIn(duration: 350.ms, delay: 80.ms),
const SizedBox(height: 20),
],
),
),
..._opts.asMap().entries.map((e) {
final i = e.key; final (icon, label, sub) = e.value;
return Padding(
padding: const EdgeInsets.only(bottom: 8),
child: SCard(
icon: icon, label: label, sub: sub,
isSelected: _selected == label,
onTap: () => setState(() => _selected = label),
).animate().fadeIn(duration: 280.ms, delay: (100 + i * 55).ms)
.slideX(begin: 0.03, end: 0, duration: 280.ms),
);
}),
],
),
),
),
Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 20),
child: Column(
children: [
SButton(
label: 'BUILD MY REPORT',
enabled: _selected.isNotEmpty,
onTap: _selected.isNotEmpty ? () {
widget.state.referral = _selected;
Navigator.of(context).push(slideRoute(AnalysingScreen(state: widget.state)));
} : null,
),
const SizedBox(height: 12),
GestureDetector(
onTap: () => Navigator.of(context).push(slideRoute(AnalysingScreen(state: widget.state))),
child: Text('Skip', style: SText.caption(SColors.textSub)),
),
],
),
),
],
),
),
);
}
}

View File

@@ -0,0 +1,104 @@
import 'package:flutter/material.dart';
import 'package:flutter_animate/flutter_animate.dart';
import '../../theme.dart';
import '../../widgets/mascot.dart';
import '../../model/quiz_state.dart';
import '../../router.dart';
import 's15_result.dart';
class AnalysingScreen extends StatefulWidget {
final QuizState state;
const AnalysingScreen({super.key, required this.state});
@override
State<AnalysingScreen> createState() => _State();
}
class _State extends State<AnalysingScreen> {
int _tipIndex = 0;
double _progress = 0.0;
static const _tips = [
'Reading your skin profile...',
'Matching 10,000+ ingredients...',
'Building your morning routine...',
'Building your evening routine...',
'Finalising your report... ✨',
];
@override
void initState() {
super.initState();
_cycle();
// Progress animation
Future.delayed(const Duration(milliseconds: 200), () {
if (mounted) setState(() => _progress = 1.0);
});
Future.delayed(const Duration(milliseconds: 3200), () {
if (mounted) Navigator.of(context).pushReplacement(fadeRoute(ResultScreen(state: widget.state)));
});
}
void _cycle() async {
for (var i = 0; i < _tips.length; i++) {
await Future.delayed(const Duration(milliseconds: 600));
if (mounted) setState(() => _tipIndex = i);
}
}
Route<dynamic> fadeRoute(Widget page) => PageRouteBuilder(
pageBuilder: (_, a, __) => page,
transitionsBuilder: (_, a, __, child) => FadeTransition(opacity: a, child: child),
transitionDuration: const Duration(milliseconds: 500),
);
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: SColors.bgSurface,
body: SafeArea(
child: Column(
children: [
const Spacer(flex: 2),
Mascot(mood: MascotMood.analyze, size: 180)
.animate().fadeIn(duration: 600.ms),
const SizedBox(height: 40),
// Progress bar
Padding(
padding: const EdgeInsets.symmetric(horizontal: 48),
child: ClipRRect(
borderRadius: BorderRadius.circular(4),
child: AnimatedContainer(
duration: const Duration(milliseconds: 3000),
curve: Curves.easeInOut,
height: 4,
child: LinearProgressIndicator(
value: _progress,
backgroundColor: SColors.borderLight,
valueColor: const AlwaysStoppedAnimation<Color>(SColors.primary),
minHeight: 4,
),
),
),
),
const SizedBox(height: 24),
AnimatedSwitcher(
duration: const Duration(milliseconds: 300),
child: Text(
_tips[_tipIndex],
key: ValueKey(_tipIndex),
style: SText.title(SColors.textHead),
textAlign: TextAlign.center,
).animate().fadeIn(duration: 250.ms).slideY(begin: 0.1, end: 0),
),
const SizedBox(height: 8),
Text(
'This takes just a few seconds',
style: SText.subtitle(SColors.textSub),
),
const Spacer(flex: 3),
],
),
),
);
}
}

View File

@@ -0,0 +1,171 @@
import 'package:flutter/material.dart';
import 'package:flutter_animate/flutter_animate.dart';
import '../../theme.dart';
import '../../widgets/mascot.dart';
import '../../widgets/s_button.dart';
import '../../widgets/confetti.dart';
import '../../model/quiz_state.dart';
import '../../router.dart';
import 's16_routine_am.dart';
class ResultScreen extends StatefulWidget {
final QuizState state;
const ResultScreen({super.key, required this.state});
@override
State<ResultScreen> createState() => _State();
}
class _State extends State<ResultScreen> {
bool _showConfetti = true;
@override
void initState() {
super.initState();
Future.delayed(const Duration(milliseconds: 2500), () {
if (mounted) setState(() => _showConfetti = false);
});
}
String get _firstName => widget.state.name.split(' ').first;
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: SColors.bgSurface,
body: Stack(
children: [
SafeArea(
child: Column(
children: [
// Mascot zone — KEY MOMENT
Expanded(
flex: 38,
child: Container(
width: double.infinity,
color: SColors.primaryLight,
child: Center(
child: Mascot(mood: MascotMood.celebrate, size: 180)
.animate()
.fadeIn(duration: 700.ms)
.slideY(begin: 0.1, end: 0, duration: 700.ms, curve: Curves.easeOut),
),
),
),
// Content
Expanded(
flex: 62,
child: SingleChildScrollView(
padding: const EdgeInsets.symmetric(horizontal: 24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(height: 20),
Text("Your skin report\nis ready, $_firstName! ✨",
style: SText.headline2(SColors.textHead))
.animate().fadeIn(duration: 500.ms, delay: 300.ms)
.slideY(begin: 0.06, end: 0),
const SizedBox(height: 6),
Text("Based on your answers, here's what we found.",
style: SText.subtitle(SColors.textSub))
.animate().fadeIn(duration: 500.ms, delay: 420.ms),
const SizedBox(height: 20),
_profileCard()
.animate().fadeIn(duration: 500.ms, delay: 500.ms)
.slideY(begin: 0.05, end: 0),
const SizedBox(height: 12),
_skinScoreCard()
.animate().fadeIn(duration: 500.ms, delay: 600.ms)
.slideY(begin: 0.05, end: 0),
const SizedBox(height: 24),
SButton(
label: 'SEE MY ROUTINE',
onTap: () => Navigator.of(context).push(slideRoute(RoutineAmScreen(state: widget.state))),
).animate().fadeIn(duration: 400.ms, delay: 700.ms),
const SizedBox(height: 24),
],
),
),
),
],
),
),
if (_showConfetti) const Positioned.fill(child: IgnorePointer(child: ConfettiOverlay())),
],
),
);
}
Widget _profileCard() {
final rows = [
('Skin type', widget.state.skinType),
('Age range', widget.state.age),
('Concerns', widget.state.concerns.join(', ')),
('Main goal', widget.state.goal),
];
return Container(
padding: const EdgeInsets.all(18),
decoration: BoxDecoration(
color: SColors.bgSurface,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: SColors.borderLight, width: 1.5),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('YOUR SKIN PROFILE', style: SText.caption(SColors.textSub).copyWith(fontWeight: FontWeight.w700, letterSpacing: 0.8)),
const SizedBox(height: 12),
...rows.where((r) => r.$2.isNotEmpty).map((r) => Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(r.$1, style: SText.body(SColors.textSub)),
const Spacer(),
Flexible(child: Text(r.$2, textAlign: TextAlign.end,
style: SText.body(SColors.textHead).copyWith(fontWeight: FontWeight.w700))),
],
),
)),
],
),
);
}
Widget _skinScoreCard() {
return Container(
padding: const EdgeInsets.all(18),
decoration: BoxDecoration(
color: SColors.primaryLight,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: SColors.borderLight),
),
child: Row(
children: [
Container(
width: 64, height: 64,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: SColors.bgSurface,
border: Border.all(color: SColors.primary, width: 2),
),
child: Center(
child: Text('87', style: SText.headline2(SColors.primary).copyWith(fontSize: 22)),
),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Skin potential score', style: SText.cardLabel(SColors.textHead)),
const SizedBox(height: 4),
Text('With the right routine, you can reach 95+ in 4 weeks.',
style: SText.cardSub(SColors.textSub)),
],
),
),
],
),
);
}
}

View File

@@ -0,0 +1,129 @@
import 'package:flutter/material.dart';
import 'package:flutter_animate/flutter_animate.dart';
import '../../theme.dart';
import '../../widgets/s_button.dart';
import '../../model/quiz_state.dart';
import '../../router.dart';
import 's17_routine_pm.dart';
class RoutineAmScreen extends StatelessWidget {
final QuizState state;
const RoutineAmScreen({super.key, required this.state});
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: SColors.bgSurface,
body: Column(
children: [
SizedBox(
height: MediaQuery.of(context).size.height * 0.30,
child: _IllustrationZone().animate().fadeIn(duration: 500.ms),
),
Expanded(
child: SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(24, 8, 24, 0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_badge('🌅 Morning routine')
.animate().fadeIn(duration: 350.ms, delay: 100.ms),
const SizedBox(height: 14),
Text('Your AM\nroutine 🌞', style: SText.headline2(SColors.textHead))
.animate().fadeIn(duration: 400.ms, delay: 180.ms)
.slideY(begin: 0.05, end: 0),
const SizedBox(height: 6),
Text('4 steps · ~5 minutes · every morning', style: SText.subtitle(SColors.textSub))
.animate().fadeIn(duration: 400.ms, delay: 260.ms),
const SizedBox(height: 20),
..._amSteps(state).asMap().entries.map((e) =>
_stepRow(e.value.$1, e.value.$2, e.value.$3, e.key)
),
const SizedBox(height: 8),
],
),
),
),
Padding(
padding: const EdgeInsets.fromLTRB(24, 8, 24, 32),
child: SButton(
label: 'SEE EVENING ROUTINE',
onTap: () => Navigator.of(context).push(slideRoute(RoutinePmScreen(state: state))),
).animate().fadeIn(duration: 400.ms, delay: 600.ms),
),
],
),
);
}
List<(String, String, String)> _amSteps(QuizState state) {
final hasDark = state.concerns.contains('Dark spots');
final hasBreakouts = state.concerns.contains('Breakouts');
return [
('1', 'Cleanser', hasBreakouts ? 'Gentle foaming, fragrance-free' : 'Hydrating, pH-balanced'),
('2', 'Vitamin C serum', hasDark ? 'High-priority for dark spots' : 'Brightening & antioxidant'),
('3', 'Moisturiser', state.skinType == 'Oily' ? 'Lightweight gel formula' : 'Cream formula, hydrating'),
('4', 'SPF 50+', 'Non-negotiable. Protects & prevents'),
];
}
Widget _stepRow(String num, String name, String sub, int i) {
return Container(
margin: const EdgeInsets.only(bottom: 10),
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: SColors.bgSurface,
borderRadius: BorderRadius.circular(14),
border: Border.all(color: SColors.borderLight, width: 1.5),
),
child: Row(
children: [
Container(
width: 32, height: 32,
decoration: const BoxDecoration(color: SColors.primaryLight, shape: BoxShape.circle),
child: Center(child: Text(num, style: SText.cardLabel(SColors.primary).copyWith(fontSize: 14))),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(name, style: SText.cardLabel(SColors.textHead)),
Text(sub, style: SText.cardSub(SColors.textSub)),
],
),
),
const Icon(Icons.chevron_right_rounded, color: SColors.textSub, size: 20),
],
),
)
.animate().fadeIn(duration: 280.ms, delay: (300 + i * 80).ms)
.slideX(begin: 0.03, end: 0, duration: 280.ms);
}
Widget _badge(String label) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: const Color(0xFFFFF3CD),
borderRadius: BorderRadius.circular(20),
),
child: Text(label, style: SText.caption(const Color(0xFF856404)).copyWith(fontWeight: FontWeight.w700)),
);
}
}
class _IllustrationZone extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Container(
color: const Color(0xFFFFF9F0),
width: double.infinity,
child: Center(
child: const Text('🌅', style: TextStyle(fontSize: 80))
.animate(onPlay: (c) => c.repeat(reverse: true))
.moveY(begin: 0, end: -8, duration: 2000.ms, curve: Curves.easeInOut),
),
);
}
}

View File

@@ -0,0 +1,128 @@
import 'package:flutter/material.dart';
import 'package:flutter_animate/flutter_animate.dart';
import '../../theme.dart';
import '../../widgets/s_button.dart';
import '../../model/quiz_state.dart';
import '../../router.dart';
import 's18_ingredient_science.dart';
class RoutinePmScreen extends StatelessWidget {
final QuizState state;
const RoutinePmScreen({super.key, required this.state});
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: SColors.bgSurface,
body: Column(
children: [
SizedBox(
height: MediaQuery.of(context).size.height * 0.30,
child: _IllustrationZone().animate().fadeIn(duration: 500.ms),
),
Expanded(
child: SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(24, 8, 24, 0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_badge('🌙 Evening routine')
.animate().fadeIn(duration: 350.ms, delay: 100.ms),
const SizedBox(height: 14),
Text('Your PM\nroutine 🌙', style: SText.headline2(SColors.textHead))
.animate().fadeIn(duration: 400.ms, delay: 180.ms)
.slideY(begin: 0.05, end: 0),
const SizedBox(height: 6),
Text('4 steps · ~7 minutes · every night', style: SText.subtitle(SColors.textSub))
.animate().fadeIn(duration: 400.ms, delay: 260.ms),
const SizedBox(height: 20),
..._pmSteps(state).asMap().entries.map((e) =>
_stepRow(e.value.$1, e.value.$2, e.value.$3, e.key)),
const SizedBox(height: 8),
],
),
),
),
Padding(
padding: const EdgeInsets.fromLTRB(24, 8, 24, 32),
child: SButton(
label: 'LEARN THE SCIENCE',
onTap: () => Navigator.of(context).push(slideRoute(IngredientScienceScreen(state: state))),
).animate().fadeIn(duration: 400.ms, delay: 600.ms),
),
],
),
);
}
List<(String, String, String)> _pmSteps(QuizState state) {
final hasLines = state.concerns.contains('Fine lines');
final hasAcne = state.concerns.contains('Breakouts');
return [
('1', 'Double cleanse', 'Oil cleanser → gentle cleanser'),
('2', hasLines ? 'Retinol 0.3%' : 'Niacinamide serum', hasLines ? 'Start 2x/week, build up' : '10% — pores, texture, tone'),
('3', 'Moisturiser', hasAcne ? 'Non-comedogenic, lightweight' : 'Rich barrier-repair cream'),
('4', 'Eye cream', 'Optional — target fine lines'),
];
}
Widget _stepRow(String num, String name, String sub, int i) {
return Container(
margin: const EdgeInsets.only(bottom: 10),
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: SColors.bgSurface,
borderRadius: BorderRadius.circular(14),
border: Border.all(color: SColors.borderLight, width: 1.5),
),
child: Row(
children: [
Container(
width: 32, height: 32,
decoration: const BoxDecoration(color: Color(0xFFEEE8FF), shape: BoxShape.circle),
child: Center(child: Text(num, style: SText.cardLabel(SColors.primary).copyWith(fontSize: 14))),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(name, style: SText.cardLabel(SColors.textHead)),
Text(sub, style: SText.cardSub(SColors.textSub)),
],
),
),
const Icon(Icons.chevron_right_rounded, color: SColors.textSub, size: 20),
],
),
)
.animate().fadeIn(duration: 280.ms, delay: (300 + i * 80).ms)
.slideX(begin: 0.03, end: 0, duration: 280.ms);
}
Widget _badge(String label) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: const Color(0xFFEEE8FF),
borderRadius: BorderRadius.circular(20),
),
child: Text(label, style: SText.caption(SColors.primary).copyWith(fontWeight: FontWeight.w700)),
);
}
}
class _IllustrationZone extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Container(
color: const Color(0xFFF0EDFF),
width: double.infinity,
child: Center(
child: const Text('🌙', style: TextStyle(fontSize: 80))
.animate(onPlay: (c) => c.repeat(reverse: true))
.moveY(begin: 0, end: -8, duration: 2200.ms, curve: Curves.easeInOut),
),
);
}
}

View File

@@ -0,0 +1,128 @@
import 'package:flutter/material.dart';
import 'package:flutter_animate/flutter_animate.dart';
import '../../theme.dart';
import '../../widgets/mascot.dart';
import '../../widgets/s_button.dart';
import '../../model/quiz_state.dart';
import '../../router.dart';
import 's19_signup.dart';
class IngredientScienceScreen extends StatelessWidget {
final QuizState state;
const IngredientScienceScreen({super.key, required this.state});
@override
Widget build(BuildContext context) {
final (hero, title, body, ingredients) = _content(state);
return Scaffold(
backgroundColor: SColors.bgSurface,
body: SafeArea(
child: Column(
children: [
Expanded(
flex: 40,
child: Stack(
alignment: Alignment.center,
children: [
Mascot(mood: MascotMood.analyze, size: 160)
.animate().fadeIn(duration: 500.ms).slideY(begin: 0.06, end: 0),
Positioned(
top: 20, right: 40,
child: Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: SColors.bgSurface,
borderRadius: BorderRadius.circular(14),
border: Border.all(color: SColors.borderLight),
),
child: Text(hero, style: const TextStyle(fontSize: 28)),
).animate().fadeIn(duration: 400.ms, delay: 600.ms)
.scaleXY(begin: 0.6, end: 1.0, duration: 400.ms, curve: Curves.elasticOut),
),
],
),
),
Expanded(
flex: 60,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(height: 8),
_badge('🔬 Ingredient science')
.animate().fadeIn(duration: 350.ms, delay: 100.ms),
const SizedBox(height: 14),
Text(title, style: SText.headline2(SColors.textHead))
.animate().fadeIn(duration: 400.ms, delay: 180.ms)
.slideY(begin: 0.05, end: 0),
const SizedBox(height: 8),
Text(body, style: SText.subtitle(SColors.textSub))
.animate().fadeIn(duration: 400.ms, delay: 260.ms),
const SizedBox(height: 16),
Wrap(
spacing: 8, runSpacing: 8,
children: ingredients.asMap().entries.map((e) =>
_chip(e.value, e.key)
).toList(),
).animate().fadeIn(duration: 400.ms, delay: 360.ms),
const Spacer(),
SButton(
label: 'SAVE MY RESULTS',
onTap: () => Navigator.of(context).push(slideRoute(SignupScreen(state: state))),
).animate().fadeIn(duration: 400.ms, delay: 500.ms),
const SizedBox(height: 12),
Center(
child: GestureDetector(
onTap: () => Navigator.of(context).push(slideRoute(SignupScreen(state: state))),
child: Text('Skip for now', style: SText.caption(SColors.textSub)),
),
).animate().fadeIn(duration: 400.ms, delay: 560.ms),
const SizedBox(height: 20),
],
),
),
),
],
),
),
);
}
(String, String, String, List<String>) _content(QuizState state) {
if (state.concerns.contains('Dark spots')) {
return ('☀️', 'Your skin needs\nVitamin C + SPF', 'This combo fades dark spots faster than any other. Vitamin C inhibits melanin production, SPF stops new ones forming.', ['Vitamin C 15%', 'Niacinamide', 'Alpha Arbutin', 'SPF 50+']);
}
if (state.concerns.contains('Fine lines')) {
return ('', 'Retinol is\nyour best ally', 'The most clinically proven anti-aging ingredient. Starts at 0.3%, works up to 1%. Use at night only.', ['Retinol 0.3%', 'Peptides', 'Hyaluronic acid', 'SPF 50+']);
}
if (state.concerns.contains('Breakouts')) {
return ('🫧', 'Your skin needs\nSalicylic acid', 'BHA that gets inside pores to clear them out. 2% is the sweet spot — effective without over-drying.', ['Salicylic acid 2%', 'Niacinamide 10%', 'Benzoyl peroxide', 'Zinc']);
}
return ('💧', 'Hydration is\neverything', 'A healthy moisture barrier is the foundation of all great skin. These ingredients rebuild and lock it in.', ['Hyaluronic acid', 'Ceramides', 'Glycerin', 'Squalane']);
}
Widget _badge(String label) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(color: SColors.primaryLight, borderRadius: BorderRadius.circular(20)),
child: Text(label, style: SText.caption(SColors.primary).copyWith(fontWeight: FontWeight.w700)),
);
}
Widget _chip(String label, int i) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 7),
decoration: BoxDecoration(
color: SColors.bgSurface,
borderRadius: BorderRadius.circular(20),
border: Border.all(color: SColors.primary.withAlpha(80)),
),
child: Text(label, style: SText.caption(SColors.primary).copyWith(fontWeight: FontWeight.w600)),
)
.animate(delay: (i * 80).ms)
.fadeIn(duration: 250.ms)
.scaleXY(begin: 0.8, end: 1.0, duration: 250.ms, curve: Curves.easeOut);
}
}

View File

@@ -0,0 +1,142 @@
import 'package:flutter/material.dart';
import 'package:flutter_animate/flutter_animate.dart';
import '../../theme.dart';
import '../../widgets/mascot.dart';
import '../../widgets/s_button.dart';
import '../../model/quiz_state.dart';
import '../../router.dart';
import 's20_welcome.dart';
class SignupScreen extends StatefulWidget {
final QuizState state;
const SignupScreen({super.key, required this.state});
@override
State<SignupScreen> createState() => _State();
}
class _State extends State<SignupScreen> {
final _email = TextEditingController();
bool _ready = false;
@override
void initState() {
super.initState();
_email.addListener(() => setState(
() => _ready = _email.text.contains('@') && _email.text.contains('.'),
));
}
@override
void dispose() { _email.dispose(); super.dispose(); }
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: SColors.bgSurface,
resizeToAvoidBottomInset: true,
body: SafeArea(
child: Column(
children: [
Expanded(
flex: 38,
child: Container(
color: SColors.primaryLight,
width: double.infinity,
child: Center(
child: Mascot(mood: MascotMood.wave, size: 140)
.animate().fadeIn(duration: 600.ms).slideY(begin: 0.08, end: 0),
),
),
),
Expanded(
flex: 62,
child: SingleChildScrollView(
padding: const EdgeInsets.symmetric(horizontal: 24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(height: 20),
Text('Save your\nresults 🔒', style: SText.headline2(SColors.textHead))
.animate().fadeIn(duration: 400.ms, delay: 200.ms)
.slideY(begin: 0.05, end: 0),
const SizedBox(height: 8),
Text('Create a free account to access your personalised routine anytime.',
style: SText.subtitle(SColors.textSub))
.animate().fadeIn(duration: 400.ms, delay: 280.ms),
const SizedBox(height: 24),
_inputField('Email address', _email, TextInputType.emailAddress)
.animate().fadeIn(duration: 400.ms, delay: 360.ms),
const SizedBox(height: 12),
_benefitsList()
.animate().fadeIn(duration: 400.ms, delay: 440.ms),
const SizedBox(height: 24),
SButton(
label: 'CREATE FREE ACCOUNT',
enabled: _ready,
onTap: _ready ? () => Navigator.of(context).push(
scaleRoute(WelcomeScreen(state: widget.state))
) : null,
).animate().fadeIn(duration: 400.ms, delay: 520.ms),
const SizedBox(height: 12),
Center(
child: GestureDetector(
onTap: () => Navigator.of(context).push(scaleRoute(WelcomeScreen(state: widget.state))),
child: Text('Maybe later', style: SText.caption(SColors.textSub)),
),
).animate().fadeIn(duration: 400.ms, delay: 580.ms),
const SizedBox(height: 20),
],
),
),
),
],
),
),
);
}
Widget _inputField(String hint, TextEditingController ctrl, TextInputType type) {
return Container(
decoration: BoxDecoration(
color: SColors.bgSurface,
borderRadius: BorderRadius.circular(14),
border: Border.all(color: SColors.borderMedium, width: 1.5),
),
child: TextField(
controller: ctrl,
keyboardType: type,
style: SText.cardLabel(SColors.textHead).copyWith(fontSize: 16),
decoration: InputDecoration(
hintText: hint,
hintStyle: SText.cardLabel(SColors.textDisabled).copyWith(fontSize: 16, fontWeight: FontWeight.w500),
border: InputBorder.none,
contentPadding: const EdgeInsets.symmetric(horizontal: 18, vertical: 16),
),
),
);
}
Widget _benefitsList() {
const items = [
(Icons.sync_rounded, 'Routine syncs across devices'),
(Icons.notifications_rounded, 'Daily check-in reminders'),
(Icons.trending_up_rounded, 'Track your skin progress'),
];
return Column(
children: items.map((item) => Padding(
padding: const EdgeInsets.only(bottom: 10),
child: Row(
children: [
Container(
width: 28, height: 28,
decoration: const BoxDecoration(color: SColors.primaryLight, shape: BoxShape.circle),
child: Icon(item.$1, size: 14, color: SColors.primary),
),
const SizedBox(width: 10),
Text(item.$2, style: SText.body(SColors.textBody)),
],
),
)).toList(),
);
}
}

View File

@@ -0,0 +1,70 @@
import 'package:flutter/material.dart';
import 'package:flutter_animate/flutter_animate.dart';
import '../../theme.dart';
import '../../widgets/mascot.dart';
import '../../widgets/s_button.dart';
import '../../widgets/confetti.dart';
import '../../model/quiz_state.dart';
class WelcomeScreen extends StatefulWidget {
final QuizState state;
const WelcomeScreen({super.key, required this.state});
@override
State<WelcomeScreen> createState() => _State();
}
class _State extends State<WelcomeScreen> {
bool _showConfetti = true;
@override
void initState() {
super.initState();
Future.delayed(const Duration(milliseconds: 2800), () {
if (mounted) setState(() => _showConfetti = false);
});
}
@override
Widget build(BuildContext context) {
final name = widget.state.name.split(' ').first;
return Scaffold(
backgroundColor: SColors.bgSurface,
body: Stack(
children: [
SafeArea(
child: Column(
children: [
const Spacer(flex: 2),
Mascot(mood: MascotMood.celebrate, size: 200)
.animate()
.fadeIn(duration: 700.ms, delay: 300.ms)
.slideY(begin: 0.08, end: 0),
const SizedBox(height: 32),
Text('Welcome to\nShaynee, $name! 🎉',
textAlign: TextAlign.center,
style: SText.headline2(SColors.textHead))
.animate().fadeIn(duration: 500.ms, delay: 600.ms)
.slideY(begin: 0.06, end: 0),
const SizedBox(height: 10),
Text("Your routine is ready.\nLet's get glowing ✨",
textAlign: TextAlign.center,
style: SText.subtitle(SColors.textSub))
.animate().fadeIn(duration: 500.ms, delay: 750.ms),
const Spacer(flex: 3),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 24),
child: SButton(
label: "LET'S START",
onTap: () {},
).animate().fadeIn(duration: 400.ms, delay: 1000.ms),
),
const SizedBox(height: 24),
],
),
),
if (_showConfetti) const Positioned.fill(child: IgnorePointer(child: ConfettiOverlay())),
],
),
);
}
}

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