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
This commit is contained in:
mav
2026-05-28 16:34:04 +02:00
parent 30cee5a486
commit 6311088256
9 changed files with 1168 additions and 638 deletions

View File

@@ -1,114 +1,171 @@
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/s_button.dart';
import 'quiz/quiz_flow.dart';
class IntroScreen extends StatelessWidget {
const IntroScreen({super.key});
void _start(BuildContext context) {
Navigator.of(context).push(PageRouteBuilder(
pageBuilder: (_, a, __) => const QuizFlow(),
transitionsBuilder: (_, a, __, child) => SlideTransition(
position: Tween<Offset>(
begin: const Offset(1, 0),
end: Offset.zero,
).animate(CurvedAnimation(parent: a, curve: Curves.easeOut)),
child: child,
),
transitionDuration: const Duration(milliseconds: 300),
));
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: SColors.bgPage,
backgroundColor: SColors.bgSurface,
body: SafeArea(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Illustration zone (top 42%)
Expanded(
flex: 42,
child: _illustrationZone()
.animate()
.fadeIn(duration: 500.ms),
),
// Content zone (bottom 58%)
Expanded(
flex: 58,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(height: 8),
// Trust badge
_badge()
.animate()
.fadeIn(duration: 400.ms, delay: 150.ms),
const SizedBox(height: 16),
// Headline
Text(
'Skin that glows,\nroutine that works.',
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: 10),
// Sub
Text(
'Answer 5 quick questions and get your\npersonalised skincare plan in 60 seconds.',
style: SText.subtitle(SColors.textSub),
)
.animate()
.fadeIn(duration: 400.ms, delay: 280.ms),
const Spacer(),
// Stats row
_statsRow()
.animate()
.fadeIn(duration: 400.ms, delay: 360.ms),
const SizedBox(height: 24),
// CTA button
SButton(
label: 'GET STARTED',
onTap: () => _start(context),
)
.animate()
.fadeIn(duration: 400.ms, delay: 440.ms)
.slideY(begin: 0.1, end: 0, duration: 400.ms),
const SizedBox(height: 16),
// Sign in link (plain text, no button)
Center(
child: GestureDetector(
onTap: () {},
child: Text(
'Already have an account? Sign in',
style: SText.caption(SColors.textSub),
),
),
)
.animate()
.fadeIn(duration: 400.ms, delay: 500.ms),
const SizedBox(height: 20),
],
),
),
),
],
),
),
);
}
Widget _illustrationZone() {
return Stack(
children: [
// Light tinted background for art area
Container(color: const Color(0xFFF7F5FF)),
// Stats visual — abstract feature cards
Center(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
children: [
const SizedBox(height: 24),
_badge()
.animate()
.fadeIn(duration: 400.ms),
const SizedBox(height: 20),
Text(
'Skin that glows,\nroutine that works.',
style: GoogleFonts.plusJakartaSans(
fontSize: 32,
fontWeight: FontWeight.w800,
color: SColors.textPrimary,
height: 1.2,
),
)
.animate()
.fadeIn(duration: 500.ms, delay: 100.ms)
.slideY(begin: 0.1, end: 0, duration: 500.ms),
const SizedBox(height: 12),
Text(
'Answer 5 quick questions and get your\npersonalised skincare plan — in 60 seconds.',
style: GoogleFonts.plusJakartaSans(
fontSize: 15,
color: SColors.textSecondary,
height: 1.5,
),
)
.animate()
.fadeIn(duration: 500.ms, delay: 200.ms),
const SizedBox(height: 32),
_statsRow()
.animate()
.fadeIn(duration: 500.ms, delay: 300.ms),
const SizedBox(height: 32),
_featureList()
.animate()
.fadeIn(duration: 500.ms, delay: 400.ms),
const Spacer(),
PrimaryButton(
label: 'Build my routine',
icon: Icons.arrow_forward_rounded,
onTap: () => Navigator.of(context).push(
PageRouteBuilder(
pageBuilder: (_, a, __) => const QuizFlow(),
transitionsBuilder: (_, a, __, child) =>
SlideTransition(
position: Tween<Offset>(
begin: const Offset(1, 0),
end: Offset.zero,
).animate(CurvedAnimation(parent: a, curve: Curves.easeOut)),
child: child,
),
transitionDuration: const Duration(milliseconds: 350),
),
),
)
.animate()
.fadeIn(duration: 400.ms, delay: 600.ms)
.slideY(begin: 0.2, end: 0, duration: 400.ms),
const SizedBox(height: 12),
Center(
child: Text(
'Free · No credit card · 1 min setup',
style: GoogleFonts.plusJakartaSans(
fontSize: 12,
color: SColors.textSecondary,
),
),
)
.animate()
.fadeIn(duration: 400.ms, delay: 700.ms),
const SizedBox(height: 24),
_featureChip(Icons.auto_awesome_rounded, 'AI skin analysis'),
const SizedBox(height: 10),
_featureChip(Icons.science_rounded, 'Ingredient science'),
const SizedBox(height: 10),
_featureChip(Icons.lock_outline_rounded, 'Private & secure'),
],
),
),
],
);
}
Widget _featureChip(IconData icon, String label) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
decoration: BoxDecoration(
color: SColors.bgSurface,
borderRadius: BorderRadius.circular(14),
border: Border.all(color: SColors.borderLight, width: 1.5),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, color: SColors.primary, size: 20),
const SizedBox(width: 10),
Text(label, style: SText.cardLabel(SColors.textHead)),
],
),
);
}
Widget _badge() {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5),
decoration: BoxDecoration(
color: SColors.primary.withAlpha(18),
color: SColors.primaryLight,
borderRadius: BorderRadius.circular(20),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 8,
height: 8,
width: 7,
height: 7,
decoration: const BoxDecoration(
color: SColors.accent,
shape: BoxShape.circle,
@@ -117,11 +174,7 @@ class IntroScreen extends StatelessWidget {
const SizedBox(width: 6),
Text(
'Trusted by 50,000+ women',
style: GoogleFonts.plusJakartaSans(
fontSize: 12,
fontWeight: FontWeight.w600,
color: SColors.primary,
),
style: SText.caption(SColors.primary).copyWith(fontWeight: FontWeight.w700),
),
],
),
@@ -131,96 +184,32 @@ class IntroScreen extends StatelessWidget {
Widget _statsRow() {
return Row(
children: [
_statCard('50K+', 'Users'),
const SizedBox(width: 12),
_statCard('4.9★', 'Rating'),
const SizedBox(width: 12),
_statCard('94%', 'See results'),
_statBox('50K+', 'Users'),
const SizedBox(width: 10),
_statBox('4.9★', 'Rating'),
const SizedBox(width: 10),
_statBox('94%', 'Results'),
],
);
}
Widget _statCard(String value, String label) {
Widget _statBox(String value, String label) {
return Expanded(
child: Container(
padding: const EdgeInsets.symmetric(vertical: 16),
padding: const EdgeInsets.symmetric(vertical: 14),
decoration: BoxDecoration(
color: SColors.surface,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: SColors.border),
color: SColors.bgSurface,
borderRadius: BorderRadius.circular(14),
border: Border.all(color: SColors.borderLight, width: 1.5),
),
child: Column(
children: [
Text(
value,
style: GoogleFonts.plusJakartaSans(
fontSize: 20,
fontWeight: FontWeight.w800,
color: SColors.primary,
),
),
Text(value, style: SText.title(SColors.primary)),
const SizedBox(height: 2),
Text(
label,
style: GoogleFonts.plusJakartaSans(
fontSize: 12,
color: SColors.textSecondary,
),
),
Text(label, style: SText.caption(SColors.textSub)),
],
),
),
);
}
Widget _featureList() {
const items = [
(Icons.auto_awesome_rounded, 'AI-powered analysis', 'Personalised to your skin'),
(Icons.science_rounded, 'Ingredient science', 'Evidence-based recommendations'),
(Icons.lock_outline_rounded, 'Private & secure', 'Your data stays with you'),
];
return Column(
children: items.map((item) => _featureRow(item.$1, item.$2, item.$3)).toList(),
);
}
Widget _featureRow(IconData icon, String title, String sub) {
return Padding(
padding: const EdgeInsets.only(bottom: 12),
child: Row(
children: [
Container(
width: 40,
height: 40,
decoration: BoxDecoration(
color: SColors.primary.withAlpha(18),
borderRadius: BorderRadius.circular(12),
),
child: Icon(icon, color: SColors.primary, size: 20),
),
const SizedBox(width: 12),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: GoogleFonts.plusJakartaSans(
fontSize: 14,
fontWeight: FontWeight.w600,
color: SColors.textPrimary,
),
),
Text(
sub,
style: GoogleFonts.plusJakartaSans(
fontSize: 12,
color: SColors.textSecondary,
),
),
],
),
],
),
);
}
}

View File

@@ -1,9 +1,9 @@
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 '../../widgets/s_button.dart';
import '../../widgets/s_card.dart';
import '../../widgets/s_progress_bar.dart';
import '../result_screen.dart';
class QuizData {
@@ -34,18 +34,16 @@ class _QuizFlowState extends State<QuizFlow> {
setState(() => _step++);
_pageCtrl.animateToPage(
_step,
duration: const Duration(milliseconds: 350),
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: 500),
),
);
Navigator.of(context).pushReplacement(PageRouteBuilder(
pageBuilder: (_, a, __) => ResultScreen(data: _data),
transitionsBuilder: (_, a, __, child) =>
FadeTransition(opacity: a, child: child),
transitionDuration: const Duration(milliseconds: 400),
));
}
}
@@ -54,7 +52,7 @@ class _QuizFlowState extends State<QuizFlow> {
setState(() => _step--);
_pageCtrl.animateToPage(
_step,
duration: const Duration(milliseconds: 300),
duration: const Duration(milliseconds: 280),
curve: Curves.easeOut,
);
} else {
@@ -62,16 +60,16 @@ class _QuizFlowState extends State<QuizFlow> {
}
}
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,
};
}
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() {
@@ -83,12 +81,20 @@ class _QuizFlowState extends State<QuizFlow> {
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: SColors.bgPage,
backgroundColor: SColors.bgSurface,
body: SafeArea(
child: Column(
children: [
_topBar(),
_progressBar(),
// 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,
@@ -123,74 +129,57 @@ class _QuizFlowState extends State<QuizFlow> {
],
),
),
_bottomBar(),
// Sticky bottom button
Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 20),
child: SButton(
label: _buttonLabel,
onTap: _canProceed ? _next : null,
enabled: _canProceed,
),
),
],
),
),
);
}
Widget _topBar() {
Widget _navRow() {
return Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 0),
child: Row(
children: [
// Back button
GestureDetector(
onTap: _back,
child: Container(
width: 40,
height: 40,
width: 36,
height: 36,
decoration: BoxDecoration(
color: SColors.surface,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: SColors.border),
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,
),
child: const Icon(Icons.arrow_back_ios_new_rounded, size: 16, color: SColors.textPrimary),
),
),
const Spacer(),
// Step counter
Text(
'${_step + 1} of $_totalSteps',
style: GoogleFonts.plusJakartaSans(
fontSize: 13,
fontWeight: FontWeight.w600,
color: SColors.textSecondary,
),
style: SText.caption(SColors.textSub).copyWith(fontWeight: FontWeight.w600),
),
],
),
);
}
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 ──────────────────────────────────────────────────────────────
// ── Step: Name ────────────────────────────────────────────────────────────────
class _NameStep extends StatelessWidget {
final TextEditingController ctrl;
@@ -201,68 +190,68 @@ class _NameStep extends StatelessWidget {
@override
Widget build(BuildContext context) {
return SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(24, 32, 24, 16),
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: GoogleFonts.plusJakartaSans(
fontSize: 30,
fontWeight: FontWeight.w800,
color: SColors.textPrimary,
height: 1.2,
),
).animate().fadeIn(duration: 400.ms).slideY(begin: 0.1, end: 0),
style: SText.headline2(SColors.textHead),
).animate().fadeIn(duration: 350.ms),
const SizedBox(height: 8),
Text(
'I\'ll personalise your routine just for you.',
style: GoogleFonts.plusJakartaSans(
fontSize: 15,
color: SColors.textSecondary,
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),
),
).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),
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: 400.ms, delay: 200.ms),
).animate().fadeIn(duration: 350.ms, delay: 160.ms),
],
),
);
}
}
// ── Step 2: Skin Type ─────────────────────────────────────────────────────────
// ── Step: Skin Type ───────────────────────────────────────────────────────────
class _SkinTypeStep extends StatelessWidget {
final String selected;
@@ -270,7 +259,7 @@ class _SkinTypeStep extends StatelessWidget {
const _SkinTypeStep({required this.selected, required this.onSelect});
static const _options = [
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'),
@@ -281,37 +270,37 @@ class _SkinTypeStep extends StatelessWidget {
@override
Widget build(BuildContext context) {
return SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(24, 32, 24, 16),
padding: const EdgeInsets.symmetric(horizontal: 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,
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),
],
),
).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];
),
..._opts.asMap().entries.map((e) {
final i = e.key;
final (icon, label, sub) = e.value;
return Padding(
padding: const EdgeInsets.only(bottom: 10),
child: QuizOptionRow(
padding: const EdgeInsets.only(bottom: 8),
child: SCard(
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),
).animate().fadeIn(duration: 280.ms, delay: (80 + i * 50).ms)
.slideX(begin: 0.03, end: 0, duration: 280.ms),
);
}),
],
@@ -320,7 +309,7 @@ class _SkinTypeStep extends StatelessWidget {
}
}
// ── Step 3: Age ───────────────────────────────────────────────────────────────
// ── Step: Age ─────────────────────────────────────────────────────────────────
class _AgeStep extends StatelessWidget {
final String selected;
@@ -328,7 +317,7 @@ class _AgeStep extends StatelessWidget {
const _AgeStep({required this.selected, required this.onSelect});
static const _options = [
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'),
@@ -339,37 +328,38 @@ class _AgeStep extends StatelessWidget {
@override
Widget build(BuildContext context) {
return SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(24, 32, 24, 16),
padding: const EdgeInsets.symmetric(horizontal: 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,
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),
],
),
).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];
),
..._opts.asMap().entries.map((e) {
final i = e.key;
final (icon, label, sub) = e.value;
return Padding(
padding: const EdgeInsets.only(bottom: 10),
child: QuizOptionRow(
padding: const EdgeInsets.only(bottom: 8),
child: SCard(
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),
).animate().fadeIn(duration: 280.ms, delay: (80 + i * 50).ms)
.slideX(begin: 0.03, end: 0, duration: 280.ms),
);
}),
],
@@ -378,7 +368,7 @@ class _AgeStep extends StatelessWidget {
}
}
// ── Step 4: Concerns ──────────────────────────────────────────────────────────
// ── Step: Concerns ────────────────────────────────────────────────────────────
class _ConcernsStep extends StatelessWidget {
final List<String> selected;
@@ -386,7 +376,7 @@ class _ConcernsStep extends StatelessWidget {
const _ConcernsStep({required this.selected, required this.onToggle});
static const _options = [
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'),
@@ -398,42 +388,61 @@ class _ConcernsStep extends StatelessWidget {
@override
Widget build(BuildContext context) {
return SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(24, 32, 24, 16),
padding: const EdgeInsets.symmetric(horizontal: 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,
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),
],
),
).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;
),
..._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: 10),
padding: const EdgeInsets.only(bottom: 8),
child: Opacity(
opacity: disabled ? 0.4 : 1.0,
child: QuizOptionRow(
opacity: disabled ? 0.35 : 1.0,
child: SCard(
icon: icon,
label: label,
sub: sub,
isSelected: isSelected,
isSelected: isSel,
onTap: disabled ? () {} : () => onToggle(label),
),
).animate().fadeIn(duration: 300.ms, delay: (100 + i * 60).ms)
.slideX(begin: 0.05, end: 0, duration: 300.ms),
).animate().fadeIn(duration: 280.ms, delay: (80 + i * 50).ms)
.slideX(begin: 0.03, end: 0, duration: 280.ms),
),
);
}),
],
@@ -442,7 +451,7 @@ class _ConcernsStep extends StatelessWidget {
}
}
// ── Step 5: Goal ──────────────────────────────────────────────────────────────
// ── Step: Goal ────────────────────────────────────────────────────────────────
class _GoalStep extends StatelessWidget {
final String selected;
@@ -450,7 +459,7 @@ class _GoalStep extends StatelessWidget {
const _GoalStep({required this.selected, required this.onSelect});
static const _options = [
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'),
@@ -460,37 +469,38 @@ class _GoalStep extends StatelessWidget {
@override
Widget build(BuildContext context) {
return SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(24, 32, 24, 16),
padding: const EdgeInsets.symmetric(horizontal: 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,
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),
],
),
).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];
),
..._opts.asMap().entries.map((e) {
final i = e.key;
final (icon, label, sub) = e.value;
return Padding(
padding: const EdgeInsets.only(bottom: 10),
child: QuizOptionRow(
padding: const EdgeInsets.only(bottom: 8),
child: SCard(
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),
).animate().fadeIn(duration: 280.ms, delay: (80 + i * 50).ms)
.slideX(begin: 0.03, end: 0, duration: 280.ms),
);
}),
],

View File

@@ -1,8 +1,7 @@
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/s_button.dart';
import 'quiz/quiz_flow.dart';
class ResultScreen extends StatefulWidget {
@@ -19,180 +18,196 @@ class _ResultScreenState extends State<ResultScreen> {
@override
void initState() {
super.initState();
Future.delayed(const Duration(milliseconds: 1800), () {
Future.delayed(const Duration(milliseconds: 2000), () {
if (mounted) setState(() => _revealed = true);
});
}
String get _firstName => widget.data.name.split(' ').first;
String get _name => widget.data.name.split(' ').first;
@override
Widget build(BuildContext context) {
return Scaffold(
body: _revealed ? _resultView() : _analyzingView(),
backgroundColor: SColors.bgSurface,
body: _revealed ? _resultView(context) : _analyzingView(),
);
}
// ── Analysing screen ──────────────────────────────────────────────────────
Widget _analyzingView() {
return Container(
decoration: const BoxDecoration(
gradient: LinearGradient(
colors: [Color(0xFF3D32A8), Color(0xFF5B4FCF), Color(0xFF8B5CF6)],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
),
child: SafeArea(
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const CircularProgressIndicator(
valueColor: AlwaysStoppedAnimation<Color>(Colors.white),
strokeWidth: 3,
)
.animate(onPlay: (c) => c.repeat())
.rotate(duration: 1.seconds),
const SizedBox(height: 28),
Text(
'Analysing your skin...',
style: GoogleFonts.plusJakartaSans(
fontSize: 20,
fontWeight: FontWeight.w600,
color: Colors.white,
),
).animate(onPlay: (c) => c.repeat(reverse: true))
.fadeIn(duration: 800.ms),
const SizedBox(height: 8),
Text(
'Building your personalised routine',
style: GoogleFonts.plusJakartaSans(
fontSize: 14,
color: Colors.white.withAlpha(180),
),
return SafeArea(
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
// Pulsing Shaynee avatar
Image.asset('assets/images/shaynee_avatar.png', height: 120)
.animate(onPlay: (c) => c.repeat(reverse: true))
.scaleXY(begin: 0.95, end: 1.05, duration: 900.ms, curve: Curves.easeInOut),
const SizedBox(height: 32),
// Spinner (plain, no color)
const SizedBox(
width: 32,
height: 32,
child: CircularProgressIndicator(
strokeWidth: 2.5,
valueColor: AlwaysStoppedAnimation<Color>(SColors.primary),
),
],
),
),
const SizedBox(height: 20),
Text(
'Analysing your skin...',
style: SText.title(SColors.textHead),
),
const SizedBox(height: 8),
Text(
'Building your personalised routine',
style: SText.subtitle(SColors.textSub),
),
],
),
),
);
}
Widget _resultView() {
return Container(
decoration: const BoxDecoration(
gradient: LinearGradient(
colors: [Color(0xFF3D32A8), Color(0xFF5B4FCF), Color(0xFF8B5CF6)],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
),
child: SafeArea(
child: SingleChildScrollView(
padding: const EdgeInsets.symmetric(horizontal: 24),
child: Column(
children: [
const SizedBox(height: 24),
Image.asset(
'assets/images/shaynee_hero.png',
height: 180,
fit: BoxFit.contain,
)
.animate()
.fadeIn(duration: 700.ms)
.slideY(begin: 0.15, end: 0, duration: 700.ms, curve: Curves.easeOut),
const SizedBox(height: 20),
Text(
'Your skin report\nis ready, $_firstName! ✨',
textAlign: TextAlign.center,
style: GoogleFonts.plusJakartaSans(
fontSize: 28,
fontWeight: FontWeight.w800,
color: Colors.white,
height: 1.2,
),
)
.animate()
.fadeIn(duration: 600.ms, delay: 200.ms),
const SizedBox(height: 28),
_reportCard()
.animate()
.fadeIn(duration: 600.ms, delay: 400.ms)
.slideY(begin: 0.1, end: 0, duration: 600.ms),
const SizedBox(height: 20),
_routinePreview()
.animate()
.fadeIn(duration: 600.ms, delay: 600.ms),
const SizedBox(height: 28),
PrimaryButton(
label: 'Start my routine',
icon: Icons.arrow_forward_rounded,
onTap: () {},
)
.animate()
.fadeIn(duration: 400.ms, delay: 800.ms),
const SizedBox(height: 32),
],
),
),
),
);
}
// ── Result reveal screen ──────────────────────────────────────────────────
Widget _reportCard() {
return Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: Colors.white.withAlpha(20),
borderRadius: BorderRadius.circular(20),
border: Border.all(color: Colors.white.withAlpha(40)),
),
Widget _resultView(BuildContext context) {
return SafeArea(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Your skin profile',
style: GoogleFonts.plusJakartaSans(
fontSize: 12,
fontWeight: FontWeight.w600,
color: Colors.white.withAlpha(160),
letterSpacing: 0.8,
// Mascot zone (top 35%) — KEY MOMENT
Expanded(
flex: 35,
child: Container(
color: SColors.primaryLight,
width: double.infinity,
child: Center(
child: Image.asset(
'assets/images/shaynee_hero.png',
fit: BoxFit.contain,
)
.animate()
.fadeIn(duration: 600.ms)
.slideY(begin: 0.08, end: 0, duration: 600.ms, curve: Curves.easeOut),
),
),
),
// Content zone (bottom 65%)
Expanded(
flex: 65,
child: SingleChildScrollView(
padding: const EdgeInsets.symmetric(horizontal: 24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(height: 20),
Text(
'Your skin report is\nready, $_name! ✨',
style: SText.headline2(SColors.textHead),
)
.animate()
.fadeIn(duration: 500.ms, delay: 200.ms),
const SizedBox(height: 6),
Text(
'Based on your answers, here\'s what we recommend.',
style: SText.subtitle(SColors.textSub),
)
.animate()
.fadeIn(duration: 500.ms, delay: 300.ms),
const SizedBox(height: 20),
// Skin profile card
_profileCard()
.animate()
.fadeIn(duration: 500.ms, delay: 400.ms)
.slideY(begin: 0.05, end: 0),
const SizedBox(height: 12),
// Routine preview card
_routineCard()
.animate()
.fadeIn(duration: 500.ms, delay: 550.ms)
.slideY(begin: 0.05, end: 0),
const SizedBox(height: 24),
// CTA
SButton(
label: 'START MY ROUTINE',
onTap: () {},
)
.animate()
.fadeIn(duration: 400.ms, delay: 700.ms),
const SizedBox(height: 12),
// Skip link (plain text, no button)
Center(
child: GestureDetector(
onTap: () {},
child: Text(
'Save my results — create account',
style: SText.caption(SColors.textSub),
),
),
)
.animate()
.fadeIn(duration: 400.ms, delay: 800.ms),
const SizedBox(height: 24),
],
),
),
),
const SizedBox(height: 14),
_reportRow('Skin type', widget.data.skinType),
_reportRow('Age range', widget.data.age),
_reportRow('Concerns', widget.data.concerns.join(', ')),
_reportRow('Main goal', widget.data.goal),
],
),
);
}
Widget _reportRow(String label, String value) {
if (value.isEmpty) return const SizedBox.shrink();
Widget _profileCard() {
final rows = [
('Skin type', widget.data.skinType),
('Age range', widget.data.age),
('Concerns', widget.data.concerns.join(', ')),
('Main goal', widget.data.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: 14),
...rows
.where((r) => r.$2.isNotEmpty)
.map((r) => _profileRow(r.$1, r.$2)),
],
),
);
}
Widget _profileRow(String label, String value) {
return Padding(
padding: const EdgeInsets.only(bottom: 10),
padding: const EdgeInsets.only(bottom: 8),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: GoogleFonts.plusJakartaSans(
fontSize: 13,
color: Colors.white.withAlpha(160),
),
),
const SizedBox(width: 12),
Expanded(
Text(label, style: SText.body(SColors.textSub)),
const Spacer(),
Flexible(
child: Text(
value,
textAlign: TextAlign.end,
style: GoogleFonts.plusJakartaSans(
fontSize: 13,
fontWeight: FontWeight.w600,
color: Colors.white,
),
style: SText.body(SColors.textHead).copyWith(fontWeight: FontWeight.w700),
),
),
],
@@ -200,30 +215,26 @@ class _ResultScreenState extends State<ResultScreen> {
);
}
Widget _routinePreview() {
Widget _routineCard() {
const steps = [
('🌅', 'Morning', 'Cleanser Vitamin C SPF 50'),
('🌙', 'Evening', 'Cleanser Retinol Moisturiser'),
('🌅', 'Morning', 'Cleanser · Vitamin C · SPF 50'),
('🌙', 'Evening', 'Cleanser · Retinol · Moisturiser'),
('', 'Weekly', 'Exfoliant + Hydrating mask'),
];
return Container(
padding: const EdgeInsets.all(20),
padding: const EdgeInsets.all(18),
decoration: BoxDecoration(
color: Colors.white.withAlpha(20),
borderRadius: BorderRadius.circular(20),
border: Border.all(color: Colors.white.withAlpha(40)),
color: SColors.bgSurface,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: SColors.borderLight, width: 1.5),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Your routine preview',
style: GoogleFonts.plusJakartaSans(
fontSize: 12,
fontWeight: FontWeight.w600,
color: Colors.white.withAlpha(160),
letterSpacing: 0.8,
),
'YOUR ROUTINE PREVIEW',
style: SText.caption(SColors.textSub)
.copyWith(fontWeight: FontWeight.w700, letterSpacing: 0.8),
),
const SizedBox(height: 14),
...steps.map((s) => Padding(
@@ -236,21 +247,10 @@ class _ResultScreenState extends State<ResultScreen> {
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
s.$2,
style: GoogleFonts.plusJakartaSans(
fontSize: 13,
fontWeight: FontWeight.w700,
color: Colors.white,
),
),
Text(
s.$3,
style: GoogleFonts.plusJakartaSans(
fontSize: 12,
color: Colors.white.withAlpha(160),
),
),
Text(s.$2,
style: SText.cardLabel(SColors.textHead)),
const SizedBox(height: 2),
Text(s.$3, style: SText.cardSub(SColors.textSub)),
],
),
],

View File

@@ -1,6 +1,5 @@
import 'package:flutter/material.dart';
import 'package:flutter_animate/flutter_animate.dart';
import 'package:google_fonts/google_fonts.dart';
import '../theme.dart';
import 'intro_screen.dart';
@@ -15,7 +14,7 @@ class _SplashScreenState extends State<SplashScreen> {
@override
void initState() {
super.initState();
Future.delayed(const Duration(milliseconds: 2600), _goNext);
Future.delayed(const Duration(milliseconds: 2400), _goNext);
}
void _goNext() {
@@ -25,7 +24,7 @@ class _SplashScreenState extends State<SplashScreen> {
pageBuilder: (_, a, __) => const IntroScreen(),
transitionsBuilder: (_, a, __, child) =>
FadeTransition(opacity: a, child: child),
transitionDuration: const Duration(milliseconds: 500),
transitionDuration: const Duration(milliseconds: 400),
),
);
}
@@ -33,76 +32,42 @@ class _SplashScreenState extends State<SplashScreen> {
@override
Widget build(BuildContext context) {
return Scaffold(
body: Container(
decoration: const BoxDecoration(
gradient: LinearGradient(
colors: [Color(0xFF3D32A8), Color(0xFF5B4FCF), Color(0xFF8B5CF6)],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
),
child: SafeArea(
child: Column(
children: [
const Spacer(flex: 2),
Image.asset(
'assets/images/shaynee_hero.png',
height: 240,
fit: BoxFit.contain,
)
.animate()
.fadeIn(duration: 700.ms, delay: 200.ms)
.slideY(begin: 0.15, end: 0, duration: 700.ms, curve: Curves.easeOut),
const SizedBox(height: 28),
Text(
'shaynee',
style: GoogleFonts.plusJakartaSans(
fontSize: 42,
fontWeight: FontWeight.w800,
color: Colors.white,
letterSpacing: -1.0,
),
)
.animate()
.fadeIn(duration: 600.ms, delay: 500.ms),
const SizedBox(height: 8),
Text(
'your AI skincare companion',
style: GoogleFonts.plusJakartaSans(
fontSize: 16,
fontWeight: FontWeight.w400,
color: Colors.white.withAlpha(180),
letterSpacing: 0.2,
),
)
.animate()
.fadeIn(duration: 600.ms, delay: 700.ms),
const Spacer(flex: 3),
Padding(
padding: const EdgeInsets.only(bottom: 40),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: List.generate(3, (i) => _dot(i)),
)
.animate()
.fadeIn(duration: 500.ms, delay: 1200.ms),
backgroundColor: SColors.bgSurface,
body: SafeArea(
child: Column(
children: [
const Spacer(flex: 2),
// Mascot — centered, large
Image.asset(
'assets/images/shaynee_hero.png',
height: 260,
fit: BoxFit.contain,
)
.animate()
.fadeIn(duration: 600.ms, delay: 200.ms)
.slideY(begin: 0.08, end: 0, duration: 600.ms, curve: Curves.easeOut),
const SizedBox(height: 32),
// Wordmark
Text(
'shaynee',
style: SText.headline1(SColors.textHead).copyWith(
fontSize: 42,
letterSpacing: -0.5,
),
],
),
)
.animate()
.fadeIn(duration: 500.ms, delay: 500.ms),
const SizedBox(height: 8),
Text(
'your ai skincare companion',
style: SText.subtitle(SColors.textSub),
)
.animate()
.fadeIn(duration: 500.ms, delay: 700.ms),
const Spacer(flex: 3),
],
),
),
);
}
Widget _dot(int i) {
return Container(
margin: const EdgeInsets.symmetric(horizontal: 4),
width: i == 0 ? 24 : 8,
height: 8,
decoration: BoxDecoration(
color: i == 0 ? Colors.white : Colors.white.withAlpha(80),
borderRadius: BorderRadius.circular(4),
),
);
}
}