From 93042cbb4c0436bc9cd50606b2ddffaaa3b71da3 Mon Sep 17 00:00:00 2001 From: mav Date: Thu, 28 May 2026 17:11:27 +0200 Subject: [PATCH] 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. --- lib/main.dart | 4 +- lib/model/quiz_state.dart | 8 + lib/router.dart | 27 +++ lib/screens/onboarding/_quiz_nav.dart | 51 ++++++ lib/screens/onboarding/s01_splash.dart | 47 +++++ lib/screens/onboarding/s02_hi_im_shaynee.dart | 99 ++++++++++ lib/screens/onboarding/s03_value_prop.dart | 135 ++++++++++++++ lib/screens/onboarding/s04_social_proof.dart | 129 +++++++++++++ lib/screens/onboarding/s05_name.dart | 134 ++++++++++++++ lib/screens/onboarding/s06_skin_type.dart | 96 ++++++++++ lib/screens/onboarding/s07_skin_insight.dart | 106 +++++++++++ lib/screens/onboarding/s08_age.dart | 92 ++++++++++ lib/screens/onboarding/s09_concerns.dart | 128 +++++++++++++ .../onboarding/s10_concern_insight.dart | 131 ++++++++++++++ lib/screens/onboarding/s11_goal.dart | 91 ++++++++++ lib/screens/onboarding/s12_almost_there.dart | 107 +++++++++++ lib/screens/onboarding/s13_referral.dart | 101 +++++++++++ lib/screens/onboarding/s14_analysing.dart | 104 +++++++++++ lib/screens/onboarding/s15_result.dart | 171 ++++++++++++++++++ lib/screens/onboarding/s16_routine_am.dart | 129 +++++++++++++ lib/screens/onboarding/s17_routine_pm.dart | 128 +++++++++++++ .../onboarding/s18_ingredient_science.dart | 128 +++++++++++++ lib/screens/onboarding/s19_signup.dart | 142 +++++++++++++++ lib/screens/onboarding/s20_welcome.dart | 70 +++++++ lib/widgets/confetti.dart | 100 ++++++++++ lib/widgets/mascot.dart | 126 +++++++++++++ 26 files changed, 2582 insertions(+), 2 deletions(-) create mode 100644 lib/model/quiz_state.dart create mode 100644 lib/router.dart create mode 100644 lib/screens/onboarding/_quiz_nav.dart create mode 100644 lib/screens/onboarding/s01_splash.dart create mode 100644 lib/screens/onboarding/s02_hi_im_shaynee.dart create mode 100644 lib/screens/onboarding/s03_value_prop.dart create mode 100644 lib/screens/onboarding/s04_social_proof.dart create mode 100644 lib/screens/onboarding/s05_name.dart create mode 100644 lib/screens/onboarding/s06_skin_type.dart create mode 100644 lib/screens/onboarding/s07_skin_insight.dart create mode 100644 lib/screens/onboarding/s08_age.dart create mode 100644 lib/screens/onboarding/s09_concerns.dart create mode 100644 lib/screens/onboarding/s10_concern_insight.dart create mode 100644 lib/screens/onboarding/s11_goal.dart create mode 100644 lib/screens/onboarding/s12_almost_there.dart create mode 100644 lib/screens/onboarding/s13_referral.dart create mode 100644 lib/screens/onboarding/s14_analysing.dart create mode 100644 lib/screens/onboarding/s15_result.dart create mode 100644 lib/screens/onboarding/s16_routine_am.dart create mode 100644 lib/screens/onboarding/s17_routine_pm.dart create mode 100644 lib/screens/onboarding/s18_ingredient_science.dart create mode 100644 lib/screens/onboarding/s19_signup.dart create mode 100644 lib/screens/onboarding/s20_welcome.dart create mode 100644 lib/widgets/confetti.dart create mode 100644 lib/widgets/mascot.dart diff --git a/lib/main.dart b/lib/main.dart index b924dd2..f2adc20 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'theme.dart'; -import 'screens/splash_screen.dart'; +import 'screens/onboarding/s01_splash.dart'; void main() { WidgetsFlutterBinding.ensureInitialized(); @@ -21,7 +21,7 @@ class ShayneeApp extends StatelessWidget { title: 'Shaynee', debugShowCheckedModeBanner: false, theme: buildTheme(), - home: const SplashScreen(), + home: const SplashScreen(), // s01 ); } } diff --git a/lib/model/quiz_state.dart b/lib/model/quiz_state.dart new file mode 100644 index 0000000..5891302 --- /dev/null +++ b/lib/model/quiz_state.dart @@ -0,0 +1,8 @@ +class QuizState { + String name = ''; + String skinType = ''; + String age = ''; + List concerns = []; + String goal = ''; + String referral = ''; +} diff --git a/lib/router.dart b/lib/router.dart new file mode 100644 index 0000000..fa907d6 --- /dev/null +++ b/lib/router.dart @@ -0,0 +1,27 @@ +import 'package:flutter/material.dart'; + +Route slideRoute(Widget page) => PageRouteBuilder( + pageBuilder: (_, a, __) => page, + transitionsBuilder: (_, a, __, child) => SlideTransition( + position: Tween(begin: const Offset(1, 0), end: Offset.zero) + .animate(CurvedAnimation(parent: a, curve: Curves.easeOut)), + child: child, + ), + transitionDuration: const Duration(milliseconds: 320), +); + +Route fadeRoute(Widget page) => PageRouteBuilder( + pageBuilder: (_, a, __) => page, + transitionsBuilder: (_, a, __, child) => FadeTransition(opacity: a, child: child), + transitionDuration: const Duration(milliseconds: 400), +); + +Route scaleRoute(Widget page) => PageRouteBuilder( + pageBuilder: (_, a, __) => page, + transitionsBuilder: (_, a, __, child) => ScaleTransition( + scale: Tween(begin: 0.88, end: 1.0) + .animate(CurvedAnimation(parent: a, curve: Curves.easeOut)), + child: FadeTransition(opacity: a, child: child), + ), + transitionDuration: const Duration(milliseconds: 350), +); diff --git a/lib/screens/onboarding/_quiz_nav.dart b/lib/screens/onboarding/_quiz_nav.dart new file mode 100644 index 0000000..cc5983b --- /dev/null +++ b/lib/screens/onboarding/_quiz_nav.dart @@ -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)), + ), + ], + ], + ), + ); + } +} diff --git a/lib/screens/onboarding/s01_splash.dart b/lib/screens/onboarding/s01_splash.dart new file mode 100644 index 0000000..54f244c --- /dev/null +++ b/lib/screens/onboarding/s01_splash.dart @@ -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 createState() => _State(); +} + +class _State extends State { + @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), + ], + ), + ), + ); + } +} diff --git a/lib/screens/onboarding/s02_hi_im_shaynee.dart b/lib/screens/onboarding/s02_hi_im_shaynee.dart new file mode 100644 index 0000000..6176a72 --- /dev/null +++ b/lib/screens/onboarding/s02_hi_im_shaynee.dart @@ -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)), + ); + } +} diff --git a/lib/screens/onboarding/s03_value_prop.dart b/lib/screens/onboarding/s03_value_prop.dart new file mode 100644 index 0000000..d27326b --- /dev/null +++ b/lib/screens/onboarding/s03_value_prop.dart @@ -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 createState() => _State(); +} + +class _State extends State { + 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), + ); + } +} diff --git a/lib/screens/onboarding/s04_social_proof.dart b/lib/screens/onboarding/s04_social_proof.dart new file mode 100644 index 0000000..48e6bda --- /dev/null +++ b/lib/screens/onboarding/s04_social_proof.dart @@ -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), + ], + ); + } +} diff --git a/lib/screens/onboarding/s05_name.dart b/lib/screens/onboarding/s05_name.dart new file mode 100644 index 0000000..948dfcb --- /dev/null +++ b/lib/screens/onboarding/s05_name.dart @@ -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 createState() => _State(); +} + +class _State extends State { + 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), + ), + ), + ); + } +} diff --git a/lib/screens/onboarding/s06_skin_type.dart b/lib/screens/onboarding/s06_skin_type.dart new file mode 100644 index 0000000..8b51053 --- /dev/null +++ b/lib/screens/onboarding/s06_skin_type.dart @@ -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 createState() => _State(); +} + +class _State extends State { + 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, + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/screens/onboarding/s07_skin_insight.dart b/lib/screens/onboarding/s07_skin_insight.dart new file mode 100644 index 0000000..3dc34af --- /dev/null +++ b/lib/screens/onboarding/s07_skin_insight.dart @@ -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)), + ); + } +} diff --git a/lib/screens/onboarding/s08_age.dart b/lib/screens/onboarding/s08_age.dart new file mode 100644 index 0000000..198d35c --- /dev/null +++ b/lib/screens/onboarding/s08_age.dart @@ -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 createState() => _State(); +} + +class _State extends State { + String _selected = ''; + + static const _opts = [ + (Icons.star_rounded, 'Under 25', 'Young & preventive care'), + (Icons.hourglass_bottom_rounded, '25โ€“35', 'Early anti-aging focus'), + (Icons.wb_sunny_rounded, '35โ€“45', 'Targeted treatments'), + (Icons.nightlight_round, '45โ€“60', '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, + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/screens/onboarding/s09_concerns.dart b/lib/screens/onboarding/s09_concerns.dart new file mode 100644 index 0000000..72c653a --- /dev/null +++ b/lib/screens/onboarding/s09_concerns.dart @@ -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 createState() => _State(); +} + +class _State extends State { + final _selected = []; + + 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, + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/screens/onboarding/s10_concern_insight.dart b/lib/screens/onboarding/s10_concern_insight.dart new file mode 100644 index 0000000..d6421a6 --- /dev/null +++ b/lib/screens/onboarding/s10_concern_insight.dart @@ -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(), + ); + } +} diff --git a/lib/screens/onboarding/s11_goal.dart b/lib/screens/onboarding/s11_goal.dart new file mode 100644 index 0000000..68ba255 --- /dev/null +++ b/lib/screens/onboarding/s11_goal.dart @@ -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 createState() => _State(); +} + +class _State extends State { + 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, + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/screens/onboarding/s12_almost_there.dart b/lib/screens/onboarding/s12_almost_there.dart new file mode 100644 index 0000000..626b4b8 --- /dev/null +++ b/lib/screens/onboarding/s12_almost_there.dart @@ -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(), + ); + } +} diff --git a/lib/screens/onboarding/s13_referral.dart b/lib/screens/onboarding/s13_referral.dart new file mode 100644 index 0000000..4398467 --- /dev/null +++ b/lib/screens/onboarding/s13_referral.dart @@ -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 createState() => _State(); +} + +class _State extends State { + 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)), + ), + ], + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/screens/onboarding/s14_analysing.dart b/lib/screens/onboarding/s14_analysing.dart new file mode 100644 index 0000000..21d9e4b --- /dev/null +++ b/lib/screens/onboarding/s14_analysing.dart @@ -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 createState() => _State(); +} + +class _State extends State { + 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 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(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), + ], + ), + ), + ); + } +} diff --git a/lib/screens/onboarding/s15_result.dart b/lib/screens/onboarding/s15_result.dart new file mode 100644 index 0000000..0806974 --- /dev/null +++ b/lib/screens/onboarding/s15_result.dart @@ -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 createState() => _State(); +} + +class _State extends State { + 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)), + ], + ), + ), + ], + ), + ); + } +} diff --git a/lib/screens/onboarding/s16_routine_am.dart b/lib/screens/onboarding/s16_routine_am.dart new file mode 100644 index 0000000..7dabd54 --- /dev/null +++ b/lib/screens/onboarding/s16_routine_am.dart @@ -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), + ), + ); + } +} diff --git a/lib/screens/onboarding/s17_routine_pm.dart b/lib/screens/onboarding/s17_routine_pm.dart new file mode 100644 index 0000000..86999b2 --- /dev/null +++ b/lib/screens/onboarding/s17_routine_pm.dart @@ -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), + ), + ); + } +} diff --git a/lib/screens/onboarding/s18_ingredient_science.dart b/lib/screens/onboarding/s18_ingredient_science.dart new file mode 100644 index 0000000..ce30a3d --- /dev/null +++ b/lib/screens/onboarding/s18_ingredient_science.dart @@ -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) _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); + } +} diff --git a/lib/screens/onboarding/s19_signup.dart b/lib/screens/onboarding/s19_signup.dart new file mode 100644 index 0000000..b912395 --- /dev/null +++ b/lib/screens/onboarding/s19_signup.dart @@ -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 createState() => _State(); +} + +class _State extends State { + 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(), + ); + } +} diff --git a/lib/screens/onboarding/s20_welcome.dart b/lib/screens/onboarding/s20_welcome.dart new file mode 100644 index 0000000..865cc02 --- /dev/null +++ b/lib/screens/onboarding/s20_welcome.dart @@ -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 createState() => _State(); +} + +class _State extends State { + 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())), + ], + ), + ); + } +} diff --git a/lib/widgets/confetti.dart b/lib/widgets/confetti.dart new file mode 100644 index 0000000..527e403 --- /dev/null +++ b/lib/widgets/confetti.dart @@ -0,0 +1,100 @@ +import 'dart:math'; +import 'package:flutter/material.dart'; + +class ConfettiOverlay extends StatefulWidget { + const ConfettiOverlay({super.key}); + + @override + State createState() => _ConfettiOverlayState(); +} + +class _ConfettiOverlayState extends State + 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; +} diff --git a/lib/widgets/mascot.dart b/lib/widgets/mascot.dart new file mode 100644 index 0000000..d8a96f5 --- /dev/null +++ b/lib/widgets/mascot.dart @@ -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 _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); + } +}