- splash → intro → 5-step quiz → result screen - Plus Jakarta Sans typography, indigo brand system - animated quiz rows, multi-select concerns, progress bar - Shaynee mascot reveal on result screen only - flutter_animate for all transitions
501 lines
17 KiB
Dart
501 lines
17 KiB
Dart
import 'package:flutter/material.dart';
|
||
import 'package:flutter_animate/flutter_animate.dart';
|
||
import 'package:google_fonts/google_fonts.dart';
|
||
import '../../theme.dart';
|
||
import '../../widgets/primary_button.dart';
|
||
import '../../widgets/quiz_option_row.dart';
|
||
import '../result_screen.dart';
|
||
|
||
class QuizData {
|
||
String name = '';
|
||
String skinType = '';
|
||
String age = '';
|
||
List<String> concerns = [];
|
||
String goal = '';
|
||
}
|
||
|
||
class QuizFlow extends StatefulWidget {
|
||
const QuizFlow({super.key});
|
||
|
||
@override
|
||
State<QuizFlow> createState() => _QuizFlowState();
|
||
}
|
||
|
||
class _QuizFlowState extends State<QuizFlow> {
|
||
final _data = QuizData();
|
||
int _step = 0;
|
||
final _pageCtrl = PageController();
|
||
final _nameCtrl = TextEditingController();
|
||
|
||
static const _totalSteps = 5;
|
||
|
||
void _next() {
|
||
if (_step < _totalSteps - 1) {
|
||
setState(() => _step++);
|
||
_pageCtrl.animateToPage(
|
||
_step,
|
||
duration: const Duration(milliseconds: 350),
|
||
curve: Curves.easeOut,
|
||
);
|
||
} else {
|
||
Navigator.of(context).pushReplacement(
|
||
PageRouteBuilder(
|
||
pageBuilder: (_, a, __) => ResultScreen(data: _data),
|
||
transitionsBuilder: (_, a, __, child) =>
|
||
FadeTransition(opacity: a, child: child),
|
||
transitionDuration: const Duration(milliseconds: 500),
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
void _back() {
|
||
if (_step > 0) {
|
||
setState(() => _step--);
|
||
_pageCtrl.animateToPage(
|
||
_step,
|
||
duration: const Duration(milliseconds: 300),
|
||
curve: Curves.easeOut,
|
||
);
|
||
} else {
|
||
Navigator.of(context).pop();
|
||
}
|
||
}
|
||
|
||
bool get _canProceed {
|
||
return switch (_step) {
|
||
0 => _data.name.trim().length >= 2,
|
||
1 => _data.skinType.isNotEmpty,
|
||
2 => _data.age.isNotEmpty,
|
||
3 => _data.concerns.isNotEmpty,
|
||
4 => _data.goal.isNotEmpty,
|
||
_ => false,
|
||
};
|
||
}
|
||
|
||
@override
|
||
void dispose() {
|
||
_nameCtrl.dispose();
|
||
_pageCtrl.dispose();
|
||
super.dispose();
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return Scaffold(
|
||
backgroundColor: SColors.bgPage,
|
||
body: SafeArea(
|
||
child: Column(
|
||
children: [
|
||
_topBar(),
|
||
_progressBar(),
|
||
Expanded(
|
||
child: PageView(
|
||
controller: _pageCtrl,
|
||
physics: const NeverScrollableScrollPhysics(),
|
||
children: [
|
||
_NameStep(
|
||
ctrl: _nameCtrl,
|
||
onChanged: (v) => setState(() => _data.name = v),
|
||
),
|
||
_SkinTypeStep(
|
||
selected: _data.skinType,
|
||
onSelect: (v) => setState(() => _data.skinType = v),
|
||
),
|
||
_AgeStep(
|
||
selected: _data.age,
|
||
onSelect: (v) => setState(() => _data.age = v),
|
||
),
|
||
_ConcernsStep(
|
||
selected: _data.concerns,
|
||
onToggle: (v) => setState(() {
|
||
if (_data.concerns.contains(v)) {
|
||
_data.concerns.remove(v);
|
||
} else if (_data.concerns.length < 3) {
|
||
_data.concerns.add(v);
|
||
}
|
||
}),
|
||
),
|
||
_GoalStep(
|
||
selected: _data.goal,
|
||
onSelect: (v) => setState(() => _data.goal = v),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
_bottomBar(),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _topBar() {
|
||
return Padding(
|
||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 0),
|
||
child: Row(
|
||
children: [
|
||
GestureDetector(
|
||
onTap: _back,
|
||
child: Container(
|
||
width: 40,
|
||
height: 40,
|
||
decoration: BoxDecoration(
|
||
color: SColors.surface,
|
||
borderRadius: BorderRadius.circular(12),
|
||
border: Border.all(color: SColors.border),
|
||
),
|
||
child: const Icon(Icons.arrow_back_ios_new_rounded, size: 16, color: SColors.textPrimary),
|
||
),
|
||
),
|
||
const Spacer(),
|
||
Text(
|
||
'${_step + 1} of $_totalSteps',
|
||
style: GoogleFonts.plusJakartaSans(
|
||
fontSize: 13,
|
||
fontWeight: FontWeight.w600,
|
||
color: SColors.textSecondary,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _progressBar() {
|
||
return Padding(
|
||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 0),
|
||
child: ClipRRect(
|
||
borderRadius: BorderRadius.circular(4),
|
||
child: LinearProgressIndicator(
|
||
value: (_step + 1) / _totalSteps,
|
||
backgroundColor: SColors.border,
|
||
valueColor: const AlwaysStoppedAnimation<Color>(SColors.primary),
|
||
minHeight: 4,
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _bottomBar() {
|
||
return Padding(
|
||
padding: const EdgeInsets.fromLTRB(24, 12, 24, 28),
|
||
child: PrimaryButton(
|
||
label: _step == _totalSteps - 1 ? 'See my results ✨' : 'Continue',
|
||
icon: _step == _totalSteps - 1 ? null : Icons.arrow_forward_rounded,
|
||
enabled: _canProceed,
|
||
onTap: _next,
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
// ── Step 1: Name ──────────────────────────────────────────────────────────────
|
||
|
||
class _NameStep extends StatelessWidget {
|
||
final TextEditingController ctrl;
|
||
final ValueChanged<String> onChanged;
|
||
|
||
const _NameStep({required this.ctrl, required this.onChanged});
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return SingleChildScrollView(
|
||
padding: const EdgeInsets.fromLTRB(24, 32, 24, 16),
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Text(
|
||
'Hey! What\'s\nyour name? 👋',
|
||
style: GoogleFonts.plusJakartaSans(
|
||
fontSize: 30,
|
||
fontWeight: FontWeight.w800,
|
||
color: SColors.textPrimary,
|
||
height: 1.2,
|
||
),
|
||
).animate().fadeIn(duration: 400.ms).slideY(begin: 0.1, end: 0),
|
||
const SizedBox(height: 8),
|
||
Text(
|
||
'I\'ll personalise your routine just for you.',
|
||
style: GoogleFonts.plusJakartaSans(
|
||
fontSize: 15,
|
||
color: SColors.textSecondary,
|
||
),
|
||
).animate().fadeIn(duration: 400.ms, delay: 100.ms),
|
||
const SizedBox(height: 36),
|
||
TextField(
|
||
controller: ctrl,
|
||
onChanged: onChanged,
|
||
autofocus: true,
|
||
textCapitalization: TextCapitalization.words,
|
||
style: GoogleFonts.plusJakartaSans(
|
||
fontSize: 18,
|
||
fontWeight: FontWeight.w600,
|
||
color: SColors.textPrimary,
|
||
),
|
||
decoration: InputDecoration(
|
||
hintText: 'Your first name',
|
||
hintStyle: GoogleFonts.plusJakartaSans(
|
||
color: SColors.textSecondary,
|
||
fontWeight: FontWeight.w400,
|
||
),
|
||
filled: true,
|
||
fillColor: SColors.surface,
|
||
contentPadding: const EdgeInsets.symmetric(horizontal: 20, vertical: 18),
|
||
border: OutlineInputBorder(
|
||
borderRadius: BorderRadius.circular(16),
|
||
borderSide: const BorderSide(color: SColors.border),
|
||
),
|
||
enabledBorder: OutlineInputBorder(
|
||
borderRadius: BorderRadius.circular(16),
|
||
borderSide: const BorderSide(color: SColors.border, width: 1.5),
|
||
),
|
||
focusedBorder: OutlineInputBorder(
|
||
borderRadius: BorderRadius.circular(16),
|
||
borderSide: const BorderSide(color: SColors.primary, width: 2),
|
||
),
|
||
),
|
||
).animate().fadeIn(duration: 400.ms, delay: 200.ms),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
// ── Step 2: Skin Type ─────────────────────────────────────────────────────────
|
||
|
||
class _SkinTypeStep extends StatelessWidget {
|
||
final String selected;
|
||
final ValueChanged<String> onSelect;
|
||
|
||
const _SkinTypeStep({required this.selected, required this.onSelect});
|
||
|
||
static const _options = [
|
||
(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 SingleChildScrollView(
|
||
padding: const EdgeInsets.fromLTRB(24, 32, 24, 16),
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Text(
|
||
'What\'s your\nskin type?',
|
||
style: GoogleFonts.plusJakartaSans(
|
||
fontSize: 30,
|
||
fontWeight: FontWeight.w800,
|
||
color: SColors.textPrimary,
|
||
height: 1.2,
|
||
),
|
||
).animate().fadeIn(duration: 400.ms),
|
||
const SizedBox(height: 8),
|
||
Text(
|
||
'Choose the one that best describes your skin.',
|
||
style: GoogleFonts.plusJakartaSans(fontSize: 15, color: SColors.textSecondary),
|
||
).animate().fadeIn(duration: 400.ms, delay: 100.ms),
|
||
const SizedBox(height: 24),
|
||
...List.generate(_options.length, (i) {
|
||
final (icon, label, sub) = _options[i];
|
||
return Padding(
|
||
padding: const EdgeInsets.only(bottom: 10),
|
||
child: QuizOptionRow(
|
||
icon: icon,
|
||
label: label,
|
||
sub: sub,
|
||
isSelected: selected == label,
|
||
onTap: () => onSelect(label),
|
||
).animate().fadeIn(duration: 300.ms, delay: (100 + i * 60).ms)
|
||
.slideX(begin: 0.05, end: 0, duration: 300.ms),
|
||
);
|
||
}),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
// ── Step 3: Age ───────────────────────────────────────────────────────────────
|
||
|
||
class _AgeStep extends StatelessWidget {
|
||
final String selected;
|
||
final ValueChanged<String> onSelect;
|
||
|
||
const _AgeStep({required this.selected, required this.onSelect});
|
||
|
||
static const _options = [
|
||
(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 SingleChildScrollView(
|
||
padding: const EdgeInsets.fromLTRB(24, 32, 24, 16),
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Text(
|
||
'How old\nare you?',
|
||
style: GoogleFonts.plusJakartaSans(
|
||
fontSize: 30,
|
||
fontWeight: FontWeight.w800,
|
||
color: SColors.textPrimary,
|
||
height: 1.2,
|
||
),
|
||
).animate().fadeIn(duration: 400.ms),
|
||
const SizedBox(height: 8),
|
||
Text(
|
||
'Skin changes with age — this helps us tailor advice.',
|
||
style: GoogleFonts.plusJakartaSans(fontSize: 15, color: SColors.textSecondary),
|
||
).animate().fadeIn(duration: 400.ms, delay: 100.ms),
|
||
const SizedBox(height: 24),
|
||
...List.generate(_options.length, (i) {
|
||
final (icon, label, sub) = _options[i];
|
||
return Padding(
|
||
padding: const EdgeInsets.only(bottom: 10),
|
||
child: QuizOptionRow(
|
||
icon: icon,
|
||
label: label,
|
||
sub: sub,
|
||
isSelected: selected == label,
|
||
onTap: () => onSelect(label),
|
||
).animate().fadeIn(duration: 300.ms, delay: (100 + i * 60).ms)
|
||
.slideX(begin: 0.05, end: 0, duration: 300.ms),
|
||
);
|
||
}),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
// ── Step 4: Concerns ──────────────────────────────────────────────────────────
|
||
|
||
class _ConcernsStep extends StatelessWidget {
|
||
final List<String> selected;
|
||
final ValueChanged<String> onToggle;
|
||
|
||
const _ConcernsStep({required this.selected, required this.onToggle});
|
||
|
||
static const _options = [
|
||
(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'),
|
||
];
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return SingleChildScrollView(
|
||
padding: const EdgeInsets.fromLTRB(24, 32, 24, 16),
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Text(
|
||
'Your skin\nconcerns?',
|
||
style: GoogleFonts.plusJakartaSans(
|
||
fontSize: 30,
|
||
fontWeight: FontWeight.w800,
|
||
color: SColors.textPrimary,
|
||
height: 1.2,
|
||
),
|
||
).animate().fadeIn(duration: 400.ms),
|
||
const SizedBox(height: 8),
|
||
Text(
|
||
'Pick up to 3 that bother you most.',
|
||
style: GoogleFonts.plusJakartaSans(fontSize: 15, color: SColors.textSecondary),
|
||
).animate().fadeIn(duration: 400.ms, delay: 100.ms),
|
||
const SizedBox(height: 24),
|
||
...List.generate(_options.length, (i) {
|
||
final (icon, label, sub) = _options[i];
|
||
final isSelected = selected.contains(label);
|
||
final disabled = !isSelected && selected.length >= 3;
|
||
return Padding(
|
||
padding: const EdgeInsets.only(bottom: 10),
|
||
child: Opacity(
|
||
opacity: disabled ? 0.4 : 1.0,
|
||
child: QuizOptionRow(
|
||
icon: icon,
|
||
label: label,
|
||
sub: sub,
|
||
isSelected: isSelected,
|
||
onTap: disabled ? () {} : () => onToggle(label),
|
||
),
|
||
).animate().fadeIn(duration: 300.ms, delay: (100 + i * 60).ms)
|
||
.slideX(begin: 0.05, end: 0, duration: 300.ms),
|
||
);
|
||
}),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
// ── Step 5: Goal ──────────────────────────────────────────────────────────────
|
||
|
||
class _GoalStep extends StatelessWidget {
|
||
final String selected;
|
||
final ValueChanged<String> onSelect;
|
||
|
||
const _GoalStep({required this.selected, required this.onSelect});
|
||
|
||
static const _options = [
|
||
(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 SingleChildScrollView(
|
||
padding: const EdgeInsets.fromLTRB(24, 32, 24, 16),
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Text(
|
||
'Your skin\ngoal?',
|
||
style: GoogleFonts.plusJakartaSans(
|
||
fontSize: 30,
|
||
fontWeight: FontWeight.w800,
|
||
color: SColors.textPrimary,
|
||
height: 1.2,
|
||
),
|
||
).animate().fadeIn(duration: 400.ms),
|
||
const SizedBox(height: 8),
|
||
Text(
|
||
'Pick one — we\'ll build your plan around it.',
|
||
style: GoogleFonts.plusJakartaSans(fontSize: 15, color: SColors.textSecondary),
|
||
).animate().fadeIn(duration: 400.ms, delay: 100.ms),
|
||
const SizedBox(height: 24),
|
||
...List.generate(_options.length, (i) {
|
||
final (icon, label, sub) = _options[i];
|
||
return Padding(
|
||
padding: const EdgeInsets.only(bottom: 10),
|
||
child: QuizOptionRow(
|
||
icon: icon,
|
||
label: label,
|
||
sub: sub,
|
||
isSelected: selected == label,
|
||
onTap: () => onSelect(label),
|
||
).animate().fadeIn(duration: 300.ms, delay: (100 + i * 60).ms)
|
||
.slideX(begin: 0.05, end: 0, duration: 300.ms),
|
||
);
|
||
}),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|