- Nunito font replacing Plus Jakarta Sans - SButton: Stack-based 3D pressable (dark shadow layer + top layer translates on press) - SCard: flat border-based selection, no shadows - SProgressBar: 4px thin bar, animated fill - All screens: white bg, no gradients - Duolingo layout: illustration top zone, content below, sticky button
511 lines
18 KiB
Dart
511 lines
18 KiB
Dart
import 'package:flutter/material.dart';
|
||
import 'package:flutter_animate/flutter_animate.dart';
|
||
import '../../theme.dart';
|
||
import '../../widgets/s_button.dart';
|
||
import '../../widgets/s_card.dart';
|
||
import '../../widgets/s_progress_bar.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: 300),
|
||
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: 400),
|
||
));
|
||
}
|
||
}
|
||
|
||
void _back() {
|
||
if (_step > 0) {
|
||
setState(() => _step--);
|
||
_pageCtrl.animateToPage(
|
||
_step,
|
||
duration: const Duration(milliseconds: 280),
|
||
curve: Curves.easeOut,
|
||
);
|
||
} else {
|
||
Navigator.of(context).pop();
|
||
}
|
||
}
|
||
|
||
bool get _canProceed => 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,
|
||
};
|
||
|
||
String get _buttonLabel => _step == _totalSteps - 1 ? 'SEE MY RESULTS' : 'CONTINUE';
|
||
|
||
@override
|
||
void dispose() {
|
||
_nameCtrl.dispose();
|
||
_pageCtrl.dispose();
|
||
super.dispose();
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return Scaffold(
|
||
backgroundColor: SColors.bgSurface,
|
||
body: SafeArea(
|
||
child: Column(
|
||
children: [
|
||
// Nav row
|
||
_navRow(),
|
||
const SizedBox(height: 10),
|
||
// Progress bar
|
||
Padding(
|
||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||
child: SProgressBar(value: (_step + 1) / _totalSteps),
|
||
),
|
||
const SizedBox(height: 16),
|
||
// Pages
|
||
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),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
// Sticky bottom button
|
||
Padding(
|
||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 20),
|
||
child: SButton(
|
||
label: _buttonLabel,
|
||
onTap: _canProceed ? _next : null,
|
||
enabled: _canProceed,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _navRow() {
|
||
return Padding(
|
||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 0),
|
||
child: Row(
|
||
children: [
|
||
// Back button
|
||
GestureDetector(
|
||
onTap: _back,
|
||
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(),
|
||
// Step counter
|
||
Text(
|
||
'${_step + 1} of $_totalSteps',
|
||
style: SText.caption(SColors.textSub).copyWith(fontWeight: FontWeight.w600),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
// ── Step: 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.symmetric(horizontal: 24),
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
// Small illustration zone
|
||
Center(
|
||
child: Image.asset(
|
||
'assets/images/shaynee_hero.png',
|
||
height: 100,
|
||
fit: BoxFit.contain,
|
||
)
|
||
.animate()
|
||
.fadeIn(duration: 400.ms)
|
||
.slideY(begin: 0.05, end: 0),
|
||
),
|
||
const SizedBox(height: 28),
|
||
|
||
Text(
|
||
'Hey! What\'s\nyour name? 👋',
|
||
style: SText.headline2(SColors.textHead),
|
||
).animate().fadeIn(duration: 350.ms),
|
||
const SizedBox(height: 8),
|
||
Text(
|
||
'I\'ll personalise your routine just for you.',
|
||
style: SText.subtitle(SColors.textSub),
|
||
).animate().fadeIn(duration: 350.ms, delay: 80.ms),
|
||
const SizedBox(height: 28),
|
||
|
||
// Input — custom, no Material underline
|
||
Container(
|
||
decoration: BoxDecoration(
|
||
color: SColors.bgSurface,
|
||
borderRadius: BorderRadius.circular(14),
|
||
border: Border.all(color: SColors.borderMedium, width: 1.5),
|
||
),
|
||
child: TextField(
|
||
controller: ctrl,
|
||
onChanged: onChanged,
|
||
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,
|
||
),
|
||
),
|
||
),
|
||
).animate().fadeIn(duration: 350.ms, delay: 160.ms),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
// ── Step: Skin Type ───────────────────────────────────────────────────────────
|
||
|
||
class _SkinTypeStep extends StatelessWidget {
|
||
final String selected;
|
||
final ValueChanged<String> onSelect;
|
||
|
||
const _SkinTypeStep({required this.selected, required this.onSelect});
|
||
|
||
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 SingleChildScrollView(
|
||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||
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: () => onSelect(label),
|
||
).animate().fadeIn(duration: 280.ms, delay: (80 + i * 50).ms)
|
||
.slideX(begin: 0.03, end: 0, duration: 280.ms),
|
||
);
|
||
}),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
// ── Step: Age ─────────────────────────────────────────────────────────────────
|
||
|
||
class _AgeStep extends StatelessWidget {
|
||
final String selected;
|
||
final ValueChanged<String> onSelect;
|
||
|
||
const _AgeStep({required this.selected, required this.onSelect});
|
||
|
||
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 SingleChildScrollView(
|
||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||
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: () => onSelect(label),
|
||
).animate().fadeIn(duration: 280.ms, delay: (80 + i * 50).ms)
|
||
.slideX(begin: 0.03, end: 0, duration: 280.ms),
|
||
);
|
||
}),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
// ── Step: Concerns ────────────────────────────────────────────────────────────
|
||
|
||
class _ConcernsStep extends StatelessWidget {
|
||
final List<String> selected;
|
||
final ValueChanged<String> onToggle;
|
||
|
||
const _ConcernsStep({required this.selected, required this.onToggle});
|
||
|
||
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'),
|
||
];
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return SingleChildScrollView(
|
||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Padding(
|
||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Row(
|
||
children: [
|
||
Text('Your skin concerns?', style: SText.headline2(SColors.textHead))
|
||
.animate().fadeIn(duration: 350.ms),
|
||
const Spacer(),
|
||
// counter badge
|
||
if (selected.isNotEmpty)
|
||
Container(
|
||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||
decoration: BoxDecoration(
|
||
color: SColors.primary,
|
||
borderRadius: BorderRadius.circular(20),
|
||
),
|
||
child: Text(
|
||
'${selected.length}/3',
|
||
style: SText.caption(Colors.white)
|
||
.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 ? () {} : () => onToggle(label),
|
||
).animate().fadeIn(duration: 280.ms, delay: (80 + i * 50).ms)
|
||
.slideX(begin: 0.03, end: 0, duration: 280.ms),
|
||
),
|
||
);
|
||
}),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
// ── Step: Goal ────────────────────────────────────────────────────────────────
|
||
|
||
class _GoalStep extends StatelessWidget {
|
||
final String selected;
|
||
final ValueChanged<String> onSelect;
|
||
|
||
const _GoalStep({required this.selected, required this.onSelect});
|
||
|
||
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 SingleChildScrollView(
|
||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||
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 plan around it.',
|
||
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: () => onSelect(label),
|
||
).animate().fadeIn(duration: 280.ms, delay: (80 + i * 50).ms)
|
||
.slideX(begin: 0.03, end: 0, duration: 280.ms),
|
||
);
|
||
}),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|