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:
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())),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user