Files
shaynee-flutter/lib/screens/quiz/quiz_flow.dart
mav 6311088256 redesign: duolingo-pattern onboarding, no gradient, pressable button
- 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
2026-05-28 16:34:04 +02:00

511 lines
18 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 '../../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, '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.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),
);
}),
],
),
);
}
}