Files
shaynee-flutter/lib/screens/quiz/quiz_flow.dart
mav 30cee5a486 feat: full onboarding + quiz flow (Flutter from scratch)
- 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
2026-05-28 16:20:18 +02:00

501 lines
17 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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, '2535', 'Early anti-aging focus'),
(Icons.wb_sunny_rounded, '3545', 'Targeted treatments'),
(Icons.nightlight_round, '4560', 'Deep renewal & firming'),
(Icons.diamond_rounded, '60+', 'Intensive care & radiance'),
];
@override
Widget build(BuildContext context) {
return 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),
);
}),
],
),
);
}
}