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

306
DESIGN_DOC.md Normal file
View File

@@ -0,0 +1,306 @@
# Shaynee Flutter — Design System (Duolingo-study edition)
## 1. How Duolingo Screens Actually Work (first 20 screens)
### Master Layout Template (used on ~80% of screens)
```
┌─────────────────────────────────┐ safe area top
│ [←/×] ████████░░░░░░ [skip] │ nav row: 48px tall, 16px padding
├─────────────────────────────────┤
│ │
│ [ILLUSTRATION / MASCOT] │ top zone: ~40% of remaining height
│ centered, ~220px │ image sits here, NO text
│ │
├─────────────────────────────────┤
│ │
│ Headline │ 24px h-padding
│ (bold, 22-26px, dark) │
│ │
│ Sub text (15px, gray) │
│ │
│ ┌─────────────────────────┐ │ option cards: 16px h-padding
│ │ [icon] Label [radio○] │ │ card height: 62px
│ └─────────────────────────┘ │ gap between cards: 8px
│ ┌─────────────────────────┐ │
│ │ [icon] Label [radio○] │ │
│ └─────────────────────────┘ │
│ ┌─────────────────────────┐ │
│ │ [icon] Label [radio○] │ │
│ └─────────────────────────┘ │
│ │
├─────────────────────────────────┤
│ ┌─────────────────────────┐ │ button zone: 16px h-padding
│ │ CONTINUE │ │ button: 52px tall
│ └─────────────────────────┘ │
└─────────────────────────────────┘ safe area bottom
```
### Nav Row breakdown
- Left: back arrow `←` in 36×36 box with gray border OR `×` to exit
- Center: thin progress bar (4px height, full remaining width)
- Right: "Skip" as plain gray text link (no box, no border)
- Background: white, no shadow
### Illustration Zone rules
- NO text inside illustration zone
- Image centered horizontally
- ~40% of screen height (not fixed pixel — scales)
- Mascot/character always faces slightly right or center
- White background behind image (no colored backdrop)
### Card style (EXACT)
```
height: 62px
borderRadius: 12px
background: white (default) / #f7f7f7 tint (hover)
border: 1.5px solid #e5e5e5 (default)
2px solid #58CC02 (Duolingo green, selected)
→ for Shaynee: 2px solid #5B4FCF
icon box: 40×40, radius 10, bg #f7f7f7, icon 22px
label: left-aligned, bold 16px, dark #3c3c3c
radio circle: right side, 22px, empty or filled+check
gap between: 8px
h-padding: 16px from screen edge
```
### Pressable Button (THE KEY THING — not box-shadow, uses border-bottom trick)
```
height: 52px
borderRadius: 16px
background: #58CC02 (Duo green) → Shaynee: #5B4FCF
border-bottom: 4px solid #58A700 → Shaynee: 4px solid #3D32A8
(this IS the shadow — not box-shadow, not gradient)
text: WHITE, ExtraBold, 15px, uppercase or title-case
disabled state: bg #e5e5e5, border-bottom #b0b0b0, text gray
pressed state: translateY(+4px), border-bottom: 0 (collapses — button "sinks")
width: full (16px h-padding each side)
```
### Specific screen patterns
**Screen 1 — Splash/Welcome**
```
BG: white (NOT gradient)
Center: Duo mascot (large, ~280px, animated float)
Below mascot:
"Learn a language\nfor free." (bold 28px, centered)
Bottom stack:
[GET STARTED] green pressable button
[I already have an account] — plain text link, no button style
```
**Screen 2 — Reason/Goal (first question)**
```
Nav: [×] ░░░░░░░░░░ [no skip]
Illustration: topic-specific art (school bag, brain, etc), centered
Headline: "What is your reason for learning?" (22px bold)
Cards: list of 5-7 options (career, family, travel, school, brain, other)
Bottom: [CONTINUE] — disabled until selection
```
**Screen 3 — Language selection**
```
Nav row with progress
Grid of flag+name cards (2 columns or scrollable list)
Searchable (search bar at top of list area)
Bottom: [CONTINUE]
```
**Screen 4-5 — Proficiency / experience**
```
Illustration centered
Big question text
3-4 cards: "I'm new" / "I know a little" / "I'm comfortable" / "It's complicated"
[CONTINUE]
```
**Screen 6-7 — Daily goal**
```
Illustration: Duo with calendar or clock
Question: "Choose your daily goal"
Cards: Casual (5min) / Regular (10min) / Serious (15min) / Intense (20min)
Each card has icon + name + description (3 lines in card)
Selected card: green border + light green bg
[CONTINUE]
```
**Screen 8-10 — First mini lesson (before signup)**
```
Quiz layout:
Top: progress bar (green fill) + × + hearts ♥♥♥
Middle: question illustration
Question text below illustration
Answer options (A/B/C/D) as square/rounded cards in 2×2 grid or vertical
Bottom: [CHECK] button (green, disabled until pick)
After correct: [CHECK] becomes green checkmark animation, then [CONTINUE]
After wrong: button turns red, explanation text appears below
```
**Screen 11 — Lesson complete / celebration**
```
Confetti animation full screen
Mascot celebrating (arms up)
"You did it!" bold centered
XP earned badge
[CONTINUE]
```
**Screen 12 — Streak intro**
```
Flame icon (large, centered)
"You're on a streak!"
Explanation of how streaks work
[CONTINUE]
```
**Screen 13-16 — Signup gate**
```
Illustration: Duo with "Save your progress"
"Create a profile to save your progress"
[CREATE PROFILE] green button
[Maybe later] plain text link (NOT a button)
```
**Screen 17-20 — Account creation**
```
Individual input screens, one field per screen:
Enter your age → Enter your name → Enter email → Set password
Each screen: input field centered, big, full-width below headline
[CONTINUE] stays disabled until field valid
```
---
## 2. Shaynee Design Rules (derived from Duolingo study)
### RULE 1: NO gradients anywhere (kills premium feel on cheap gradient)
### RULE 2: White backgrounds on all content screens
### RULE 3: Pressable button = border-bottom trick, NOT box-shadow
### RULE 4: One illustration zone (top 40%) + one content zone (bottom 60%)
### RULE 5: Button always stuck to bottom, full width, 16px h-padding
### RULE 6: Cards: 62px, border-based selection, no shadows
### RULE 7: Mascot only on: splash, analysing complete, result reveal
### RULE 8: "Skip" / "Maybe later" = plain text, never in a button/box
---
## 3. Shaynee Color System (no gradients)
```dart
// Backgrounds
bgPage: #FAFAFE // very light lavender-white
bgSurface: #FFFFFF // pure white (cards, inputs)
// Primary (Indigo — replaces Duo's green)
primary: #5B4FCF
primaryDark: #3D32A8 // button bottom border
primaryLight: #EAE7FB // selected card bg tint
primaryBorder: #5B4FCF // selected card border
// Text
textHead: #1C1A2E // nearly black
textBody: #4B4B6B // medium dark
textSub: #9898AA // gray subtext
textDisabled: #C4C4D4
// Borders
borderLight: #E8E6F0 // card default border
borderMedium: #D0CDEA // input default
// Accent
accent: #FF7EB3 // pink — celebrations, badges only
accentLight: #FFF0F6
// States
success: #4CAF50
error: #FF4B4B
```
---
## 4. Typography — Nunito (Google Fonts)
```dart
// Weights to use:
// 900 Black → main headlines (36px+)
// 800 ExtraBold → section heads (22-28px), button text
// 700 Bold → card labels (16px), input text
// 600 SemiBold → body (15px), subtitles
// 500 Medium → captions (12-13px), subtext
// 400 Regular → (avoid — too thin)
// Scale:
headline1: Nunito 900, 36px, lh 1.15
headline2: Nunito 800, 26px, lh 1.2
title: Nunito 800, 22px, lh 1.25
subtitle: Nunito 600, 15px, lh 1.5
body: Nunito 600, 15px, lh 1.5
caption: Nunito 500, 12px, lh 1.4
button: Nunito 800, 15px, lh 1.0, letterSpacing 0.3
```
---
## 5. Component Specs (Flutter implementation)
### SButton (Pressable)
```dart
// Use GestureDetector with onTapDown/onTapUp for press animation
// AnimatedContainer: on press, translate Y +4, remove bottom border
decoration: BoxDecoration(
color: SColors.primary, // flat, NO gradient
borderRadius: BorderRadius.circular(16),
border: Border(
bottom: BorderSide(
color: SColors.primaryDark, // #3D32A8
width: 4, // THIS is the "shadow"
),
),
),
// On press: transform translate(0, 4), border bottom 0
// Duration: 80ms
```
### SCard (Option row)
```dart
height: 62,
decoration: BoxDecoration(
color: selected ? SColors.primaryLight : SColors.bgSurface,
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: selected ? SColors.primary : SColors.borderLight,
width: selected ? 2.0 : 1.5,
),
),
// NO shadow
// Icon box: 40×40, radius 10, bg #f4f2ff (selected) or #f7f7f7
// Radio: right side, 22×22 circle
```
### SProgressBar
```dart
height: 4,
borderRadius: BorderRadius.circular(2),
// Track: SColors.borderLight
// Fill: SColors.primary
// Animate width change: 300ms ease-out
```
---
## 6. Screen Flow & Rules per Screen
| Screen | BG | Illustration zone | Nav | Button text |
|--------|----|--------------------|-----|-------------|
| Splash | White | Shaynee hero (center, large) | none | auto-advance |
| Intro/Welcome | White | Feature art or stats visual | none | "Get started" |
| Name | White | Small Shaynee avatar (top, 100px) | ← + 1/5 + skip | "Continue" |
| Skin type | White | Skin illustration (120px) | ← + 2/5 | "Continue" |
| Age | White | Age illustration (120px) | ← + 3/5 | "Continue" |
| Concerns | White | Skincare illustration (120px) | ← + 4/5 | "Continue" |
| Goal | White | Goal illustration (120px) | ← + 5/5 | "See my results" |
| Analysing | White + spinner | Shaynee pulsing (center) | none | auto-advance |
| Result | White | Shaynee hero mascot (top 35%) | none | "Start my routine" |
| Sign up | White | none | × | "Save my results" |

View File

@@ -1,114 +1,171 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_animate/flutter_animate.dart'; import 'package:flutter_animate/flutter_animate.dart';
import 'package:google_fonts/google_fonts.dart';
import '../theme.dart'; import '../theme.dart';
import '../widgets/primary_button.dart'; import '../widgets/s_button.dart';
import 'quiz/quiz_flow.dart'; import 'quiz/quiz_flow.dart';
class IntroScreen extends StatelessWidget { class IntroScreen extends StatelessWidget {
const IntroScreen({super.key}); const IntroScreen({super.key});
@override void _start(BuildContext context) {
Widget build(BuildContext context) { Navigator.of(context).push(PageRouteBuilder(
return Scaffold(
backgroundColor: SColors.bgPage,
body: SafeArea(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
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(), pageBuilder: (_, a, __) => const QuizFlow(),
transitionsBuilder: (_, a, __, child) => transitionsBuilder: (_, a, __, child) => SlideTransition(
SlideTransition(
position: Tween<Offset>( position: Tween<Offset>(
begin: const Offset(1, 0), begin: const Offset(1, 0),
end: Offset.zero, end: Offset.zero,
).animate(CurvedAnimation(parent: a, curve: Curves.easeOut)), ).animate(CurvedAnimation(parent: a, curve: Curves.easeOut)),
child: child, child: child,
), ),
transitionDuration: const Duration(milliseconds: 350), transitionDuration: const Duration(milliseconds: 300),
), ));
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: SColors.bgSurface,
body: SafeArea(
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() .animate()
.fadeIn(duration: 400.ms, delay: 600.ms) .fadeIn(duration: 400.ms, delay: 200.ms)
.slideY(begin: 0.2, end: 0, duration: 400.ms), .slideY(begin: 0.06, end: 0, duration: 400.ms),
const SizedBox(height: 12), const SizedBox(height: 10),
Center(
child: Text( // Sub
'Free · No credit card · 1 min setup', Text(
style: GoogleFonts.plusJakartaSans( 'Answer 5 quick questions and get your\npersonalised skincare plan in 60 seconds.',
fontSize: 12, style: SText.subtitle(SColors.textSub),
color: SColors.textSecondary,
),
),
) )
.animate() .animate()
.fadeIn(duration: 400.ms, delay: 700.ms), .fadeIn(duration: 400.ms, delay: 280.ms),
const Spacer(),
// Stats row
_statsRow()
.animate()
.fadeIn(duration: 400.ms, delay: 360.ms),
const SizedBox(height: 24), 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(
mainAxisAlignment: MainAxisAlignment.center,
children: [
_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() { Widget _badge() {
return Container( return Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5),
decoration: BoxDecoration( decoration: BoxDecoration(
color: SColors.primary.withAlpha(18), color: SColors.primaryLight,
borderRadius: BorderRadius.circular(20), borderRadius: BorderRadius.circular(20),
), ),
child: Row( child: Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
Container( Container(
width: 8, width: 7,
height: 8, height: 7,
decoration: const BoxDecoration( decoration: const BoxDecoration(
color: SColors.accent, color: SColors.accent,
shape: BoxShape.circle, shape: BoxShape.circle,
@@ -117,11 +174,7 @@ class IntroScreen extends StatelessWidget {
const SizedBox(width: 6), const SizedBox(width: 6),
Text( Text(
'Trusted by 50,000+ women', 'Trusted by 50,000+ women',
style: GoogleFonts.plusJakartaSans( style: SText.caption(SColors.primary).copyWith(fontWeight: FontWeight.w700),
fontSize: 12,
fontWeight: FontWeight.w600,
color: SColors.primary,
),
), ),
], ],
), ),
@@ -131,96 +184,32 @@ class IntroScreen extends StatelessWidget {
Widget _statsRow() { Widget _statsRow() {
return Row( return Row(
children: [ children: [
_statCard('50K+', 'Users'), _statBox('50K+', 'Users'),
const SizedBox(width: 12), const SizedBox(width: 10),
_statCard('4.9★', 'Rating'), _statBox('4.9★', 'Rating'),
const SizedBox(width: 12), const SizedBox(width: 10),
_statCard('94%', 'See results'), _statBox('94%', 'Results'),
], ],
); );
} }
Widget _statCard(String value, String label) { Widget _statBox(String value, String label) {
return Expanded( return Expanded(
child: Container( child: Container(
padding: const EdgeInsets.symmetric(vertical: 16), padding: const EdgeInsets.symmetric(vertical: 14),
decoration: BoxDecoration( decoration: BoxDecoration(
color: SColors.surface, color: SColors.bgSurface,
borderRadius: BorderRadius.circular(16), borderRadius: BorderRadius.circular(14),
border: Border.all(color: SColors.border), border: Border.all(color: SColors.borderLight, width: 1.5),
), ),
child: Column( child: Column(
children: [ children: [
Text( Text(value, style: SText.title(SColors.primary)),
value,
style: GoogleFonts.plusJakartaSans(
fontSize: 20,
fontWeight: FontWeight.w800,
color: SColors.primary,
),
),
const SizedBox(height: 2), const SizedBox(height: 2),
Text( Text(label, style: SText.caption(SColors.textSub)),
label,
style: GoogleFonts.plusJakartaSans(
fontSize: 12,
color: SColors.textSecondary,
),
),
], ],
), ),
), ),
); );
} }
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/material.dart';
import 'package:flutter_animate/flutter_animate.dart'; import 'package:flutter_animate/flutter_animate.dart';
import 'package:google_fonts/google_fonts.dart';
import '../../theme.dart'; import '../../theme.dart';
import '../../widgets/primary_button.dart'; import '../../widgets/s_button.dart';
import '../../widgets/quiz_option_row.dart'; import '../../widgets/s_card.dart';
import '../../widgets/s_progress_bar.dart';
import '../result_screen.dart'; import '../result_screen.dart';
class QuizData { class QuizData {
@@ -34,18 +34,16 @@ class _QuizFlowState extends State<QuizFlow> {
setState(() => _step++); setState(() => _step++);
_pageCtrl.animateToPage( _pageCtrl.animateToPage(
_step, _step,
duration: const Duration(milliseconds: 350), duration: const Duration(milliseconds: 300),
curve: Curves.easeOut, curve: Curves.easeOut,
); );
} else { } else {
Navigator.of(context).pushReplacement( Navigator.of(context).pushReplacement(PageRouteBuilder(
PageRouteBuilder(
pageBuilder: (_, a, __) => ResultScreen(data: _data), pageBuilder: (_, a, __) => ResultScreen(data: _data),
transitionsBuilder: (_, a, __, child) => transitionsBuilder: (_, a, __, child) =>
FadeTransition(opacity: a, child: child), FadeTransition(opacity: a, child: child),
transitionDuration: const Duration(milliseconds: 500), transitionDuration: const Duration(milliseconds: 400),
), ));
);
} }
} }
@@ -54,7 +52,7 @@ class _QuizFlowState extends State<QuizFlow> {
setState(() => _step--); setState(() => _step--);
_pageCtrl.animateToPage( _pageCtrl.animateToPage(
_step, _step,
duration: const Duration(milliseconds: 300), duration: const Duration(milliseconds: 280),
curve: Curves.easeOut, curve: Curves.easeOut,
); );
} else { } else {
@@ -62,8 +60,7 @@ class _QuizFlowState extends State<QuizFlow> {
} }
} }
bool get _canProceed { bool get _canProceed => switch (_step) {
return switch (_step) {
0 => _data.name.trim().length >= 2, 0 => _data.name.trim().length >= 2,
1 => _data.skinType.isNotEmpty, 1 => _data.skinType.isNotEmpty,
2 => _data.age.isNotEmpty, 2 => _data.age.isNotEmpty,
@@ -71,7 +68,8 @@ class _QuizFlowState extends State<QuizFlow> {
4 => _data.goal.isNotEmpty, 4 => _data.goal.isNotEmpty,
_ => false, _ => false,
}; };
}
String get _buttonLabel => _step == _totalSteps - 1 ? 'SEE MY RESULTS' : 'CONTINUE';
@override @override
void dispose() { void dispose() {
@@ -83,12 +81,20 @@ class _QuizFlowState extends State<QuizFlow> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
backgroundColor: SColors.bgPage, backgroundColor: SColors.bgSurface,
body: SafeArea( body: SafeArea(
child: Column( child: Column(
children: [ children: [
_topBar(), // Nav row
_progressBar(), _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( Expanded(
child: PageView( child: PageView(
controller: _pageCtrl, 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( return Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 0), padding: const EdgeInsets.fromLTRB(16, 12, 16, 0),
child: Row( child: Row(
children: [ children: [
// Back button
GestureDetector( GestureDetector(
onTap: _back, onTap: _back,
child: Container( child: Container(
width: 40, width: 36,
height: 40, height: 36,
decoration: BoxDecoration( decoration: BoxDecoration(
color: SColors.surface, color: SColors.bgSurface,
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(10),
border: Border.all(color: SColors.border), 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(), const Spacer(),
// Step counter
Text( Text(
'${_step + 1} of $_totalSteps', '${_step + 1} of $_totalSteps',
style: GoogleFonts.plusJakartaSans( style: SText.caption(SColors.textSub).copyWith(fontWeight: FontWeight.w600),
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 ────────────────────────────────────────────────────────────── // ── Step: Name ────────────────────────────────────────────────────────────────
class _NameStep extends StatelessWidget { class _NameStep extends StatelessWidget {
final TextEditingController ctrl; final TextEditingController ctrl;
@@ -201,68 +190,68 @@ class _NameStep extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return SingleChildScrollView( return SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(24, 32, 24, 16), padding: const EdgeInsets.symmetric(horizontal: 24),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ 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( Text(
'Hey! What\'s\nyour name? 👋', 'Hey! What\'s\nyour name? 👋',
style: GoogleFonts.plusJakartaSans( style: SText.headline2(SColors.textHead),
fontSize: 30, ).animate().fadeIn(duration: 350.ms),
fontWeight: FontWeight.w800,
color: SColors.textPrimary,
height: 1.2,
),
).animate().fadeIn(duration: 400.ms).slideY(begin: 0.1, end: 0),
const SizedBox(height: 8), const SizedBox(height: 8),
Text( Text(
'I\'ll personalise your routine just for you.', 'I\'ll personalise your routine just for you.',
style: GoogleFonts.plusJakartaSans( style: SText.subtitle(SColors.textSub),
fontSize: 15, ).animate().fadeIn(duration: 350.ms, delay: 80.ms),
color: SColors.textSecondary, 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), child: TextField(
const SizedBox(height: 36),
TextField(
controller: ctrl, controller: ctrl,
onChanged: onChanged, onChanged: onChanged,
autofocus: true, autofocus: true,
textCapitalization: TextCapitalization.words, textCapitalization: TextCapitalization.words,
style: GoogleFonts.plusJakartaSans( style: SText.cardLabel(SColors.textHead).copyWith(fontSize: 18),
fontSize: 18,
fontWeight: FontWeight.w600,
color: SColors.textPrimary,
),
decoration: InputDecoration( decoration: InputDecoration(
hintText: 'Your first name', hintText: 'Your first name',
hintStyle: GoogleFonts.plusJakartaSans( hintStyle: SText.cardLabel(SColors.textDisabled).copyWith(
color: SColors.textSecondary, fontSize: 18,
fontWeight: FontWeight.w400, fontWeight: FontWeight.w500,
), ),
filled: true, border: InputBorder.none,
fillColor: SColors.surface, contentPadding: const EdgeInsets.symmetric(
contentPadding: const EdgeInsets.symmetric(horizontal: 20, vertical: 18), horizontal: 18,
border: OutlineInputBorder( vertical: 16,
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), ),
).animate().fadeIn(duration: 350.ms, delay: 160.ms),
], ],
), ),
); );
} }
} }
// ── Step 2: Skin Type ───────────────────────────────────────────────────────── // ── Step: Skin Type ───────────────────────────────────────────────────────────
class _SkinTypeStep extends StatelessWidget { class _SkinTypeStep extends StatelessWidget {
final String selected; final String selected;
@@ -270,7 +259,7 @@ class _SkinTypeStep extends StatelessWidget {
const _SkinTypeStep({required this.selected, required this.onSelect}); 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.water_drop_rounded, 'Oily', 'Shiny by midday, large pores'),
(Icons.grass_rounded, 'Dry', 'Tight, flaky, rough patches'), (Icons.grass_rounded, 'Dry', 'Tight, flaky, rough patches'),
(Icons.blur_on_rounded, 'Combination', 'Oily T-zone, dry cheeks'), (Icons.blur_on_rounded, 'Combination', 'Oily T-zone, dry cheeks'),
@@ -281,37 +270,37 @@ class _SkinTypeStep extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return SingleChildScrollView( return SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(24, 32, 24, 16), padding: const EdgeInsets.symmetric(horizontal: 16),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text( Padding(
'What\'s your\nskin type?', padding: const EdgeInsets.symmetric(horizontal: 8),
style: GoogleFonts.plusJakartaSans( child: Column(
fontSize: 30, crossAxisAlignment: CrossAxisAlignment.start,
fontWeight: FontWeight.w800, children: [
color: SColors.textPrimary, Text('What\'s your\nskin type?', style: SText.headline2(SColors.textHead))
height: 1.2, .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), ..._opts.asMap().entries.map((e) {
Text( final i = e.key;
'Choose the one that best describes your skin.', final (icon, label, sub) = e.value;
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( return Padding(
padding: const EdgeInsets.only(bottom: 10), padding: const EdgeInsets.only(bottom: 8),
child: QuizOptionRow( child: SCard(
icon: icon, icon: icon,
label: label, label: label,
sub: sub, sub: sub,
isSelected: selected == label, isSelected: selected == label,
onTap: () => onSelect(label), onTap: () => onSelect(label),
).animate().fadeIn(duration: 300.ms, delay: (100 + i * 60).ms) ).animate().fadeIn(duration: 280.ms, delay: (80 + i * 50).ms)
.slideX(begin: 0.05, end: 0, duration: 300.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 { class _AgeStep extends StatelessWidget {
final String selected; final String selected;
@@ -328,7 +317,7 @@ class _AgeStep extends StatelessWidget {
const _AgeStep({required this.selected, required this.onSelect}); const _AgeStep({required this.selected, required this.onSelect});
static const _options = [ static const _opts = [
(Icons.star_rounded, 'Under 25', 'Young & preventive care'), (Icons.star_rounded, 'Under 25', 'Young & preventive care'),
(Icons.hourglass_bottom_rounded, '2535', 'Early anti-aging focus'), (Icons.hourglass_bottom_rounded, '2535', 'Early anti-aging focus'),
(Icons.wb_sunny_rounded, '3545', 'Targeted treatments'), (Icons.wb_sunny_rounded, '3545', 'Targeted treatments'),
@@ -339,37 +328,38 @@ class _AgeStep extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return SingleChildScrollView( return SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(24, 32, 24, 16), padding: const EdgeInsets.symmetric(horizontal: 16),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text( Padding(
'How old\nare you?', padding: const EdgeInsets.symmetric(horizontal: 8),
style: GoogleFonts.plusJakartaSans( child: Column(
fontSize: 30, crossAxisAlignment: CrossAxisAlignment.start,
fontWeight: FontWeight.w800, children: [
color: SColors.textPrimary, Text('How old\nare you?', style: SText.headline2(SColors.textHead))
height: 1.2, .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), ..._opts.asMap().entries.map((e) {
Text( final i = e.key;
'Skin changes with age — this helps us tailor advice.', final (icon, label, sub) = e.value;
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( return Padding(
padding: const EdgeInsets.only(bottom: 10), padding: const EdgeInsets.only(bottom: 8),
child: QuizOptionRow( child: SCard(
icon: icon, icon: icon,
label: label, label: label,
sub: sub, sub: sub,
isSelected: selected == label, isSelected: selected == label,
onTap: () => onSelect(label), onTap: () => onSelect(label),
).animate().fadeIn(duration: 300.ms, delay: (100 + i * 60).ms) ).animate().fadeIn(duration: 280.ms, delay: (80 + i * 50).ms)
.slideX(begin: 0.05, end: 0, duration: 300.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 { class _ConcernsStep extends StatelessWidget {
final List<String> selected; final List<String> selected;
@@ -386,7 +376,7 @@ class _ConcernsStep extends StatelessWidget {
const _ConcernsStep({required this.selected, required this.onToggle}); const _ConcernsStep({required this.selected, required this.onToggle});
static const _options = [ static const _opts = [
(Icons.face_retouching_natural, 'Breakouts', 'Acne, blackheads, congestion'), (Icons.face_retouching_natural, 'Breakouts', 'Acne, blackheads, congestion'),
(Icons.cloud_rounded, 'Dullness', 'Tired, uneven, grey skin'), (Icons.cloud_rounded, 'Dullness', 'Tired, uneven, grey skin'),
(Icons.air_rounded, 'Dryness', 'Tight, rough, flaking'), (Icons.air_rounded, 'Dryness', 'Tight, rough, flaking'),
@@ -398,42 +388,61 @@ class _ConcernsStep extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return SingleChildScrollView( return SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(24, 32, 24, 16), padding: const EdgeInsets.symmetric(horizontal: 16),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text( Padding(
'Your skin\nconcerns?', padding: const EdgeInsets.symmetric(horizontal: 8),
style: GoogleFonts.plusJakartaSans( child: Column(
fontSize: 30, crossAxisAlignment: CrossAxisAlignment.start,
fontWeight: FontWeight.w800, children: [
color: SColors.textPrimary, Row(
height: 1.2, 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),
), ),
).animate().fadeIn(duration: 400.ms), child: Text(
const SizedBox(height: 8), '${selected.length}/3',
Text( style: SText.caption(Colors.white)
'Pick up to 3 that bother you most.', .copyWith(fontWeight: FontWeight.w700),
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]; const SizedBox(height: 6),
final isSelected = selected.contains(label); Text('Pick up to 3 that bother you most.', style: SText.subtitle(SColors.textSub))
final disabled = !isSelected && selected.length >= 3; .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( return Padding(
padding: const EdgeInsets.only(bottom: 10), padding: const EdgeInsets.only(bottom: 8),
child: Opacity( child: Opacity(
opacity: disabled ? 0.4 : 1.0, opacity: disabled ? 0.35 : 1.0,
child: QuizOptionRow( child: SCard(
icon: icon, icon: icon,
label: label, label: label,
sub: sub, sub: sub,
isSelected: isSelected, isSelected: isSel,
onTap: disabled ? () {} : () => onToggle(label), onTap: disabled ? () {} : () => onToggle(label),
).animate().fadeIn(duration: 280.ms, delay: (80 + i * 50).ms)
.slideX(begin: 0.03, end: 0, duration: 280.ms),
), ),
).animate().fadeIn(duration: 300.ms, delay: (100 + i * 60).ms)
.slideX(begin: 0.05, end: 0, duration: 300.ms),
); );
}), }),
], ],
@@ -442,7 +451,7 @@ class _ConcernsStep extends StatelessWidget {
} }
} }
// ── Step 5: Goal ────────────────────────────────────────────────────────────── // ── Step: Goal ────────────────────────────────────────────────────────────────
class _GoalStep extends StatelessWidget { class _GoalStep extends StatelessWidget {
final String selected; final String selected;
@@ -450,7 +459,7 @@ class _GoalStep extends StatelessWidget {
const _GoalStep({required this.selected, required this.onSelect}); 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.auto_awesome_rounded, 'Healthy glow', 'Radiant, dewy, lit-from-within'),
(Icons.shield_rounded, 'Clear skin', 'Blemish-free, smooth texture'), (Icons.shield_rounded, 'Clear skin', 'Blemish-free, smooth texture'),
(Icons.access_time_rounded, 'Slow aging', 'Firm, youthful, preventive'), (Icons.access_time_rounded, 'Slow aging', 'Firm, youthful, preventive'),
@@ -460,37 +469,38 @@ class _GoalStep extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return SingleChildScrollView( return SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(24, 32, 24, 16), padding: const EdgeInsets.symmetric(horizontal: 16),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text( Padding(
'Your skin\ngoal?', padding: const EdgeInsets.symmetric(horizontal: 8),
style: GoogleFonts.plusJakartaSans( child: Column(
fontSize: 30, crossAxisAlignment: CrossAxisAlignment.start,
fontWeight: FontWeight.w800, children: [
color: SColors.textPrimary, Text('Your main\nskin goal?', style: SText.headline2(SColors.textHead))
height: 1.2, .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), ..._opts.asMap().entries.map((e) {
Text( final i = e.key;
'Pick one — we\'ll build your plan around it.', final (icon, label, sub) = e.value;
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( return Padding(
padding: const EdgeInsets.only(bottom: 10), padding: const EdgeInsets.only(bottom: 8),
child: QuizOptionRow( child: SCard(
icon: icon, icon: icon,
label: label, label: label,
sub: sub, sub: sub,
isSelected: selected == label, isSelected: selected == label,
onTap: () => onSelect(label), onTap: () => onSelect(label),
).animate().fadeIn(duration: 300.ms, delay: (100 + i * 60).ms) ).animate().fadeIn(duration: 280.ms, delay: (80 + i * 50).ms)
.slideX(begin: 0.05, end: 0, duration: 300.ms), .slideX(begin: 0.03, end: 0, duration: 280.ms),
); );
}), }),
], ],

View File

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

View File

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

View File

@@ -2,42 +2,65 @@ import 'package:flutter/material.dart';
import 'package:google_fonts/google_fonts.dart'; import 'package:google_fonts/google_fonts.dart';
class SColors { class SColors {
// Backgrounds
static const bgPage = Color(0xFFFAFAFE);
static const bgSurface = Color(0xFFFFFFFF);
// Primary (Indigo)
static const primary = Color(0xFF5B4FCF); static const primary = Color(0xFF5B4FCF);
static const primaryLight = Color(0xFF7B70E0); static const primaryDark = Color(0xFF3D32A8); // button bottom border
static const primaryDeep = Color(0xFF3D32A8); static const primaryLight = Color(0xFFEAE7FB); // selected card tint
static const primaryBorder = Color(0xFF5B4FCF); // selected card border
// Text
static const textHead = Color(0xFF1C1A2E);
static const textBody = Color(0xFF4B4B6B);
static const textSub = Color(0xFF9898AA);
static const textDisabled = Color(0xFFC4C4D4);
// Borders
static const borderLight = Color(0xFFE8E6F0);
static const borderMedium = Color(0xFFD0CDEA);
// Accent
static const accent = Color(0xFFFF7EB3); static const accent = Color(0xFFFF7EB3);
static const accentSoft = Color(0xFFFFF0F6); static const accentLight = Color(0xFFFFF0F6);
static const bgPage = Color(0xFFFAF9FF);
static const surface = Color(0xFFFFFFFF); // States
static const textPrimary = Color(0xFF1C1A2E); static const success = Color(0xFF4CAF50);
static const textSecondary = Color(0xFF7B7A8E); static const error = Color(0xFFFF4B4B);
static const border = Color(0xFFE8E6F0); }
static const skyTint = Color(0xFFEEECFA);
static const gradientTop = Color(0xFF5B4FCF); TextStyle _nunito(double size, FontWeight weight, Color color, {double? height, double? letterSpacing}) {
static const gradientBot = Color(0xFF8B5CF6); return GoogleFonts.nunito(
fontSize: size,
fontWeight: weight,
color: color,
height: height,
letterSpacing: letterSpacing,
);
}
class SText {
static TextStyle headline1(Color c) => _nunito(36, FontWeight.w900, c, height: 1.15);
static TextStyle headline2(Color c) => _nunito(26, FontWeight.w800, c, height: 1.2);
static TextStyle title(Color c) => _nunito(22, FontWeight.w800, c, height: 1.25);
static TextStyle subtitle(Color c) => _nunito(15, FontWeight.w600, c, height: 1.5);
static TextStyle body(Color c) => _nunito(15, FontWeight.w600, c, height: 1.5);
static TextStyle caption(Color c) => _nunito(12, FontWeight.w500, c, height: 1.4);
static TextStyle button(Color c) => _nunito(15, FontWeight.w800, c, letterSpacing: 0.3);
static TextStyle cardLabel(Color c) => _nunito(16, FontWeight.w700, c);
static TextStyle cardSub(Color c) => _nunito(13, FontWeight.w500, c);
} }
ThemeData buildTheme() { ThemeData buildTheme() {
return ThemeData( return ThemeData(
colorScheme: ColorScheme.fromSeed( colorScheme: const ColorScheme.light(
seedColor: SColors.primary, primary: SColors.primary,
brightness: Brightness.light, surface: SColors.bgSurface,
), ),
scaffoldBackgroundColor: SColors.bgPage, scaffoldBackgroundColor: SColors.bgPage,
textTheme: GoogleFonts.plusJakartaSansTextTheme().copyWith( textTheme: GoogleFonts.nunitoTextTheme(),
displayLarge: GoogleFonts.plusJakartaSans(
fontSize: 32, fontWeight: FontWeight.w800, color: SColors.textPrimary,
),
headlineMedium: GoogleFonts.plusJakartaSans(
fontSize: 24, fontWeight: FontWeight.w700, color: SColors.textPrimary,
),
bodyLarge: GoogleFonts.plusJakartaSans(
fontSize: 16, fontWeight: FontWeight.w400, color: SColors.textPrimary,
),
bodyMedium: GoogleFonts.plusJakartaSans(
fontSize: 14, fontWeight: FontWeight.w400, color: SColors.textSecondary,
),
),
useMaterial3: true, useMaterial3: true,
); );
} }

88
lib/widgets/s_button.dart Normal file
View File

@@ -0,0 +1,88 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../theme.dart';
// Duolingo-style pressable button.
// Stack of two containers: dark "shadow" bottom + colored top that translates down on press.
class SButton extends StatefulWidget {
final String label;
final VoidCallback? onTap;
final bool enabled;
final Color? color;
final Color? shadowColor;
final Color? textColor;
const SButton({
super.key,
required this.label,
required this.onTap,
this.enabled = true,
this.color,
this.shadowColor,
this.textColor,
});
@override
State<SButton> createState() => _SButtonState();
}
class _SButtonState extends State<SButton> {
bool _pressed = false;
bool get _active => widget.enabled && widget.onTap != null;
static const _shadowH = 4.0;
static const _btnH = 52.0;
static const _radius = 16.0;
@override
Widget build(BuildContext context) {
final bg = _active ? (widget.color ?? SColors.primary) : const Color(0xFFE5E5E5);
final shadow = _active ? (widget.shadowColor ?? SColors.primaryDark) : const Color(0xFFB8B8B8);
final textCol = _active ? (widget.textColor ?? Colors.white) : SColors.textDisabled;
final offset = (_pressed && _active) ? _shadowH : 0.0;
return GestureDetector(
onTapDown: (_) { if (_active) setState(() => _pressed = true); },
onTapUp: (_) {
if (!_active) return;
setState(() => _pressed = false);
HapticFeedback.mediumImpact();
widget.onTap!();
},
onTapCancel: () { if (_active) setState(() => _pressed = false); },
child: SizedBox(
height: _btnH + _shadowH,
child: Stack(
children: [
// Shadow layer (always at bottom)
Positioned.fill(
child: Container(
decoration: BoxDecoration(
color: shadow,
borderRadius: BorderRadius.circular(_radius),
),
),
),
// Button layer (translates down on press, "sinking" into shadow)
AnimatedPositioned(
duration: const Duration(milliseconds: 80),
top: offset,
left: 0,
right: 0,
height: _btnH,
child: Container(
decoration: BoxDecoration(
color: bg,
borderRadius: BorderRadius.circular(_radius),
),
alignment: Alignment.center,
child: Text(widget.label, style: SText.button(textCol)),
),
),
],
),
),
);
}
}

111
lib/widgets/s_card.dart Normal file
View File

@@ -0,0 +1,111 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../theme.dart';
// Duolingo-style option card. Flat — no shadow, border-based selection.
class SCard extends StatelessWidget {
final IconData icon;
final String label;
final String sub;
final bool isSelected;
final VoidCallback onTap;
const SCard({
super.key,
required this.icon,
required this.label,
required this.isSelected,
required this.onTap,
this.sub = '',
});
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: () {
HapticFeedback.selectionClick();
onTap();
},
child: AnimatedContainer(
duration: const Duration(milliseconds: 150),
curve: Curves.easeOut,
height: sub.isEmpty ? 62 : null,
padding: sub.isEmpty
? const EdgeInsets.symmetric(horizontal: 14)
: const EdgeInsets.symmetric(horizontal: 14, vertical: 14),
decoration: BoxDecoration(
color: isSelected ? SColors.primaryLight : SColors.bgSurface,
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: isSelected ? SColors.primaryBorder : SColors.borderLight,
width: isSelected ? 2.0 : 1.5,
),
),
child: Row(
children: [
// Icon box
AnimatedContainer(
duration: const Duration(milliseconds: 150),
width: 40,
height: 40,
decoration: BoxDecoration(
color: isSelected
? SColors.primary.withAlpha(20)
: const Color(0xFFF7F7F7),
borderRadius: BorderRadius.circular(10),
),
child: Icon(
icon,
size: 22,
color: isSelected ? SColors.primary : SColors.textBody,
),
),
const SizedBox(width: 12),
// Labels
Expanded(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: SText.cardLabel(
isSelected ? SColors.primary : SColors.textHead,
),
),
if (sub.isNotEmpty) ...[
const SizedBox(height: 2),
Text(
sub,
style: SText.cardSub(SColors.textSub),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
],
),
),
const SizedBox(width: 10),
// Radio
AnimatedContainer(
duration: const Duration(milliseconds: 150),
width: 22,
height: 22,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: isSelected ? SColors.primary : Colors.transparent,
border: Border.all(
color: isSelected ? SColors.primary : SColors.borderMedium,
width: 1.5,
),
),
child: isSelected
? const Icon(Icons.check, size: 13, color: Colors.white)
: null,
),
],
),
),
);
}
}

View File

@@ -0,0 +1,38 @@
import 'package:flutter/material.dart';
import '../theme.dart';
class SProgressBar extends StatelessWidget {
final double value; // 0.0 to 1.0
const SProgressBar({super.key, required this.value});
@override
Widget build(BuildContext context) {
return LayoutBuilder(
builder: (context, constraints) {
return Stack(
children: [
Container(
height: 4,
width: constraints.maxWidth,
decoration: BoxDecoration(
color: SColors.borderLight,
borderRadius: BorderRadius.circular(2),
),
),
AnimatedContainer(
duration: const Duration(milliseconds: 300),
curve: Curves.easeOut,
height: 4,
width: constraints.maxWidth * value.clamp(0.0, 1.0),
decoration: BoxDecoration(
color: SColors.primary,
borderRadius: BorderRadius.circular(2),
),
),
],
);
},
);
}
}