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:
@@ -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
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
8
lib/model/quiz_state.dart
Normal file
8
lib/model/quiz_state.dart
Normal 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
27
lib/router.dart
Normal 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),
|
||||||
|
);
|
||||||
51
lib/screens/onboarding/_quiz_nav.dart
Normal file
51
lib/screens/onboarding/_quiz_nav.dart
Normal 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)),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
47
lib/screens/onboarding/s01_splash.dart
Normal file
47
lib/screens/onboarding/s01_splash.dart
Normal 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),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
99
lib/screens/onboarding/s02_hi_im_shaynee.dart
Normal file
99
lib/screens/onboarding/s02_hi_im_shaynee.dart
Normal 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)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
135
lib/screens/onboarding/s03_value_prop.dart
Normal file
135
lib/screens/onboarding/s03_value_prop.dart
Normal 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),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
129
lib/screens/onboarding/s04_social_proof.dart
Normal file
129
lib/screens/onboarding/s04_social_proof.dart
Normal 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),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
134
lib/screens/onboarding/s05_name.dart
Normal file
134
lib/screens/onboarding/s05_name.dart
Normal 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),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
96
lib/screens/onboarding/s06_skin_type.dart
Normal file
96
lib/screens/onboarding/s06_skin_type.dart
Normal 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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
106
lib/screens/onboarding/s07_skin_insight.dart
Normal file
106
lib/screens/onboarding/s07_skin_insight.dart
Normal 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)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
92
lib/screens/onboarding/s08_age.dart
Normal file
92
lib/screens/onboarding/s08_age.dart
Normal 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, '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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
128
lib/screens/onboarding/s09_concerns.dart
Normal file
128
lib/screens/onboarding/s09_concerns.dart
Normal 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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
131
lib/screens/onboarding/s10_concern_insight.dart
Normal file
131
lib/screens/onboarding/s10_concern_insight.dart
Normal 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(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
91
lib/screens/onboarding/s11_goal.dart
Normal file
91
lib/screens/onboarding/s11_goal.dart
Normal 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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
107
lib/screens/onboarding/s12_almost_there.dart
Normal file
107
lib/screens/onboarding/s12_almost_there.dart
Normal 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(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
101
lib/screens/onboarding/s13_referral.dart
Normal file
101
lib/screens/onboarding/s13_referral.dart
Normal 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)),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
104
lib/screens/onboarding/s14_analysing.dart
Normal file
104
lib/screens/onboarding/s14_analysing.dart
Normal 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),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
171
lib/screens/onboarding/s15_result.dart
Normal file
171
lib/screens/onboarding/s15_result.dart
Normal 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)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
129
lib/screens/onboarding/s16_routine_am.dart
Normal file
129
lib/screens/onboarding/s16_routine_am.dart
Normal 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),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
128
lib/screens/onboarding/s17_routine_pm.dart
Normal file
128
lib/screens/onboarding/s17_routine_pm.dart
Normal 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),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
128
lib/screens/onboarding/s18_ingredient_science.dart
Normal file
128
lib/screens/onboarding/s18_ingredient_science.dart
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
142
lib/screens/onboarding/s19_signup.dart
Normal file
142
lib/screens/onboarding/s19_signup.dart
Normal 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(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
70
lib/screens/onboarding/s20_welcome.dart
Normal file
70
lib/screens/onboarding/s20_welcome.dart
Normal 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
100
lib/widgets/confetti.dart
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
import 'dart:math';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
class ConfettiOverlay extends StatefulWidget {
|
||||||
|
const ConfettiOverlay({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<ConfettiOverlay> createState() => _ConfettiOverlayState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ConfettiOverlayState extends State<ConfettiOverlay>
|
||||||
|
with SingleTickerProviderStateMixin {
|
||||||
|
late AnimationController _ctrl;
|
||||||
|
final _rand = Random();
|
||||||
|
late List<_Particle> _particles;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_particles = List.generate(40, (_) => _Particle(_rand));
|
||||||
|
_ctrl = AnimationController(
|
||||||
|
vsync: this,
|
||||||
|
duration: const Duration(milliseconds: 2400),
|
||||||
|
)..forward();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_ctrl.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return AnimatedBuilder(
|
||||||
|
animation: _ctrl,
|
||||||
|
builder: (_, __) => CustomPaint(
|
||||||
|
painter: _ConfettiPainter(_particles, _ctrl.value),
|
||||||
|
size: Size.infinite,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _Particle {
|
||||||
|
final double x;
|
||||||
|
double y;
|
||||||
|
final double speed;
|
||||||
|
final double size;
|
||||||
|
final Color color;
|
||||||
|
final double rotation;
|
||||||
|
final double rotSpeed;
|
||||||
|
|
||||||
|
_Particle(Random r)
|
||||||
|
: x = r.nextDouble(),
|
||||||
|
y = -0.1 - r.nextDouble() * 0.3,
|
||||||
|
speed = 0.3 + r.nextDouble() * 0.5,
|
||||||
|
size = 6 + r.nextDouble() * 8,
|
||||||
|
color = [
|
||||||
|
const Color(0xFF5B4FCF),
|
||||||
|
const Color(0xFFFF7EB3),
|
||||||
|
const Color(0xFFFFD700),
|
||||||
|
const Color(0xFF4CAF50),
|
||||||
|
const Color(0xFF1CB0F6),
|
||||||
|
const Color(0xFFFF7043),
|
||||||
|
][r.nextInt(6)],
|
||||||
|
rotation = r.nextDouble() * 2 * pi,
|
||||||
|
rotSpeed = (r.nextDouble() - 0.5) * 6;
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ConfettiPainter extends CustomPainter {
|
||||||
|
final List<_Particle> particles;
|
||||||
|
final double t;
|
||||||
|
|
||||||
|
_ConfettiPainter(this.particles, this.t);
|
||||||
|
|
||||||
|
@override
|
||||||
|
void paint(Canvas canvas, Size size) {
|
||||||
|
for (final p in particles) {
|
||||||
|
final y = (p.y + t * p.speed) * size.height;
|
||||||
|
if (y > size.height) continue;
|
||||||
|
final x = p.x * size.width;
|
||||||
|
final rot = p.rotation + t * p.rotSpeed;
|
||||||
|
|
||||||
|
canvas.save();
|
||||||
|
canvas.translate(x, y);
|
||||||
|
canvas.rotate(rot);
|
||||||
|
|
||||||
|
final paint = Paint()..color = p.color;
|
||||||
|
canvas.drawRect(
|
||||||
|
Rect.fromCenter(center: Offset.zero, width: p.size, height: p.size * 0.5),
|
||||||
|
paint,
|
||||||
|
);
|
||||||
|
canvas.restore();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool shouldRepaint(_ConfettiPainter old) => old.t != t;
|
||||||
|
}
|
||||||
126
lib/widgets/mascot.dart
Normal file
126
lib/widgets/mascot.dart
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_animate/flutter_animate.dart';
|
||||||
|
|
||||||
|
enum MascotMood { neutral, wave, celebrate, think, analyze }
|
||||||
|
|
||||||
|
class Mascot extends StatelessWidget {
|
||||||
|
final MascotMood mood;
|
||||||
|
final double size;
|
||||||
|
|
||||||
|
const Mascot({super.key, this.mood = MascotMood.neutral, this.size = 160});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return SizedBox(
|
||||||
|
width: size,
|
||||||
|
height: size,
|
||||||
|
child: Stack(
|
||||||
|
alignment: Alignment.center,
|
||||||
|
children: [
|
||||||
|
_mascotImage(),
|
||||||
|
if (mood == MascotMood.celebrate) ..._sparkles(),
|
||||||
|
if (mood == MascotMood.analyze) _magnifier(),
|
||||||
|
if (mood == MascotMood.think) _thoughtBubble(),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _mascotImage() {
|
||||||
|
final img = Image.asset(
|
||||||
|
'assets/images/shaynee_hero.png',
|
||||||
|
width: size,
|
||||||
|
height: size,
|
||||||
|
fit: BoxFit.contain,
|
||||||
|
);
|
||||||
|
|
||||||
|
return switch (mood) {
|
||||||
|
MascotMood.neutral => img
|
||||||
|
.animate(onPlay: (c) => c.repeat(reverse: true))
|
||||||
|
.moveY(begin: 0, end: -8, duration: 1800.ms, curve: Curves.easeInOut),
|
||||||
|
MascotMood.wave => img
|
||||||
|
.animate(onPlay: (c) => c.repeat(reverse: true))
|
||||||
|
.moveY(begin: 0, end: -6, duration: 1600.ms, curve: Curves.easeInOut),
|
||||||
|
MascotMood.celebrate => img
|
||||||
|
.animate(onPlay: (c) => c.repeat(reverse: true))
|
||||||
|
.scaleXY(begin: 0.95, end: 1.05, duration: 700.ms, curve: Curves.easeInOut)
|
||||||
|
.moveY(begin: 0, end: -10, duration: 700.ms, curve: Curves.easeInOut),
|
||||||
|
MascotMood.think => img
|
||||||
|
.animate(onPlay: (c) => c.repeat(reverse: true))
|
||||||
|
.moveX(begin: 0, end: 4, duration: 2000.ms, curve: Curves.easeInOut),
|
||||||
|
MascotMood.analyze => img
|
||||||
|
.animate(onPlay: (c) => c.repeat(reverse: true))
|
||||||
|
.moveY(begin: 0, end: -5, duration: 2000.ms, curve: Curves.easeInOut),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
List<Widget> _sparkles() {
|
||||||
|
const positions = [
|
||||||
|
Offset(-50, -30), Offset(50, -40), Offset(-40, 30),
|
||||||
|
Offset(55, 20), Offset(0, -55),
|
||||||
|
];
|
||||||
|
return positions.asMap().entries.map((e) {
|
||||||
|
final i = e.key;
|
||||||
|
final pos = e.value;
|
||||||
|
return Positioned(
|
||||||
|
left: size / 2 + pos.dx,
|
||||||
|
top: size / 2 + pos.dy,
|
||||||
|
child: _Sparkle(delay: i * 200),
|
||||||
|
);
|
||||||
|
}).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _magnifier() {
|
||||||
|
return Positioned(
|
||||||
|
right: 0,
|
||||||
|
bottom: size * 0.15,
|
||||||
|
child: Container(
|
||||||
|
width: size * 0.35,
|
||||||
|
height: size * 0.35,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
border: Border.all(color: const Color(0xFF5B4FCF), width: 3),
|
||||||
|
color: Colors.white.withAlpha(180),
|
||||||
|
),
|
||||||
|
child: const Icon(Icons.search, color: Color(0xFF5B4FCF), size: 20),
|
||||||
|
)
|
||||||
|
.animate(onPlay: (c) => c.repeat(reverse: true))
|
||||||
|
.rotate(begin: -0.05, end: 0.05, duration: 1500.ms),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _thoughtBubble() {
|
||||||
|
return Positioned(
|
||||||
|
top: 0,
|
||||||
|
right: 0,
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.all(8),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white,
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
border: Border.all(color: const Color(0xFFE8E6F0), width: 1.5),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(color: Colors.black.withAlpha(12), blurRadius: 8),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: const Text('🤔', style: TextStyle(fontSize: 18)),
|
||||||
|
)
|
||||||
|
.animate()
|
||||||
|
.fadeIn(duration: 500.ms)
|
||||||
|
.scaleXY(begin: 0.5, end: 1.0, duration: 400.ms, curve: Curves.elasticOut),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _Sparkle extends StatelessWidget {
|
||||||
|
final int delay;
|
||||||
|
const _Sparkle({required this.delay});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return const Text('✨', style: TextStyle(fontSize: 14))
|
||||||
|
.animate(onPlay: (c) => c.repeat(reverse: true), delay: Duration(milliseconds: delay))
|
||||||
|
.scaleXY(begin: 0.5, end: 1.2, duration: 800.ms, curve: Curves.easeInOut)
|
||||||
|
.fadeIn(duration: 400.ms);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user