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:
306
DESIGN_DOC.md
Normal file
306
DESIGN_DOC.md
Normal 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" |
|
||||||
@@ -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});
|
||||||
|
|
||||||
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
backgroundColor: SColors.bgPage,
|
backgroundColor: SColors.bgSurface,
|
||||||
body: SafeArea(
|
body: SafeArea(
|
||||||
child: Padding(
|
child: Column(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 24),
|
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(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
const SizedBox(height: 24),
|
_featureChip(Icons.auto_awesome_rounded, 'AI skin analysis'),
|
||||||
_badge()
|
const SizedBox(height: 10),
|
||||||
.animate()
|
_featureChip(Icons.science_rounded, 'Ingredient science'),
|
||||||
.fadeIn(duration: 400.ms),
|
const SizedBox(height: 10),
|
||||||
const SizedBox(height: 20),
|
_featureChip(Icons.lock_outline_rounded, 'Private & secure'),
|
||||||
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),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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: 400),
|
||||||
transitionDuration: const Duration(milliseconds: 500),
|
));
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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,16 +60,16 @@ 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,
|
3 => _data.concerns.isNotEmpty,
|
||||||
3 => _data.concerns.isNotEmpty,
|
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),
|
controller: ctrl,
|
||||||
TextField(
|
onChanged: onChanged,
|
||||||
controller: ctrl,
|
autofocus: true,
|
||||||
onChanged: onChanged,
|
textCapitalization: TextCapitalization.words,
|
||||||
autofocus: true,
|
style: SText.cardLabel(SColors.textHead).copyWith(fontSize: 18),
|
||||||
textCapitalization: TextCapitalization.words,
|
decoration: InputDecoration(
|
||||||
style: GoogleFonts.plusJakartaSans(
|
hintText: 'Your first name',
|
||||||
fontSize: 18,
|
hintStyle: SText.cardLabel(SColors.textDisabled).copyWith(
|
||||||
fontWeight: FontWeight.w600,
|
fontSize: 18,
|
||||||
color: SColors.textPrimary,
|
fontWeight: FontWeight.w500,
|
||||||
),
|
),
|
||||||
decoration: InputDecoration(
|
border: InputBorder.none,
|
||||||
hintText: 'Your first name',
|
contentPadding: const EdgeInsets.symmetric(
|
||||||
hintStyle: GoogleFonts.plusJakartaSans(
|
horizontal: 18,
|
||||||
color: SColors.textSecondary,
|
vertical: 16,
|
||||||
fontWeight: FontWeight.w400,
|
),
|
||||||
),
|
|
||||||
filled: true,
|
|
||||||
fillColor: SColors.surface,
|
|
||||||
contentPadding: const EdgeInsets.symmetric(horizontal: 20, vertical: 18),
|
|
||||||
border: OutlineInputBorder(
|
|
||||||
borderRadius: BorderRadius.circular(16),
|
|
||||||
borderSide: const BorderSide(color: SColors.border),
|
|
||||||
),
|
|
||||||
enabledBorder: OutlineInputBorder(
|
|
||||||
borderRadius: BorderRadius.circular(16),
|
|
||||||
borderSide: const BorderSide(color: SColors.border, width: 1.5),
|
|
||||||
),
|
|
||||||
focusedBorder: OutlineInputBorder(
|
|
||||||
borderRadius: BorderRadius.circular(16),
|
|
||||||
borderSide: const BorderSide(color: SColors.primary, width: 2),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
).animate().fadeIn(duration: 400.ms, delay: 200.ms),
|
).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, '25–35', 'Early anti-aging focus'),
|
(Icons.hourglass_bottom_rounded, '25–35', 'Early anti-aging focus'),
|
||||||
(Icons.wb_sunny_rounded, '35–45', 'Targeted treatments'),
|
(Icons.wb_sunny_rounded, '35–45', '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),
|
||||||
|
),
|
||||||
|
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),
|
..._opts.asMap().entries.map((e) {
|
||||||
Text(
|
final i = e.key;
|
||||||
'Pick up to 3 that bother you most.',
|
final (icon, label, sub) = e.value;
|
||||||
style: GoogleFonts.plusJakartaSans(fontSize: 15, color: SColors.textSecondary),
|
final isSel = selected.contains(label);
|
||||||
).animate().fadeIn(duration: 400.ms, delay: 100.ms),
|
final disabled = !isSel && selected.length >= 3;
|
||||||
const SizedBox(height: 24),
|
|
||||||
...List.generate(_options.length, (i) {
|
|
||||||
final (icon, label, sub) = _options[i];
|
|
||||||
final isSelected = selected.contains(label);
|
|
||||||
final disabled = !isSelected && selected.length >= 3;
|
|
||||||
return Padding(
|
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)
|
||||||
).animate().fadeIn(duration: 300.ms, delay: (100 + i * 60).ms)
|
.slideX(begin: 0.03, end: 0, duration: 280.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),
|
||||||
);
|
);
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -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(
|
child: Center(
|
||||||
gradient: LinearGradient(
|
child: Column(
|
||||||
colors: [Color(0xFF3D32A8), Color(0xFF5B4FCF), Color(0xFF8B5CF6)],
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
begin: Alignment.topLeft,
|
children: [
|
||||||
end: Alignment.bottomRight,
|
// Pulsing Shaynee avatar
|
||||||
),
|
Image.asset('assets/images/shaynee_avatar.png', height: 120)
|
||||||
),
|
.animate(onPlay: (c) => c.repeat(reverse: true))
|
||||||
child: SafeArea(
|
.scaleXY(begin: 0.95, end: 1.05, duration: 900.ms, curve: Curves.easeInOut),
|
||||||
child: Center(
|
const SizedBox(height: 32),
|
||||||
child: Column(
|
// Spinner (plain, no color)
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
const SizedBox(
|
||||||
children: [
|
width: 32,
|
||||||
const CircularProgressIndicator(
|
height: 32,
|
||||||
valueColor: AlwaysStoppedAnimation<Color>(Colors.white),
|
child: CircularProgressIndicator(
|
||||||
strokeWidth: 3,
|
strokeWidth: 2.5,
|
||||||
)
|
valueColor: AlwaysStoppedAnimation<Color>(SColors.primary),
|
||||||
.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),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
],
|
),
|
||||||
),
|
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() {
|
// ── Result reveal screen ──────────────────────────────────────────────────
|
||||||
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),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _reportCard() {
|
Widget _resultView(BuildContext context) {
|
||||||
return Container(
|
return SafeArea(
|
||||||
padding: const EdgeInsets.all(20),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: Colors.white.withAlpha(20),
|
|
||||||
borderRadius: BorderRadius.circular(20),
|
|
||||||
border: Border.all(color: Colors.white.withAlpha(40)),
|
|
||||||
),
|
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
children: [
|
||||||
Text(
|
// Mascot zone (top 35%) — KEY MOMENT
|
||||||
'Your skin profile',
|
Expanded(
|
||||||
style: GoogleFonts.plusJakartaSans(
|
flex: 35,
|
||||||
fontSize: 12,
|
child: Container(
|
||||||
fontWeight: FontWeight.w600,
|
color: SColors.primaryLight,
|
||||||
color: Colors.white.withAlpha(160),
|
width: double.infinity,
|
||||||
letterSpacing: 0.8,
|
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) {
|
Widget _profileCard() {
|
||||||
if (value.isEmpty) return const SizedBox.shrink();
|
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(
|
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),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -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(
|
child: Column(
|
||||||
colors: [Color(0xFF3D32A8), Color(0xFF5B4FCF), Color(0xFF8B5CF6)],
|
children: [
|
||||||
begin: Alignment.topLeft,
|
const Spacer(flex: 2),
|
||||||
end: Alignment.bottomRight,
|
// Mascot — centered, large
|
||||||
),
|
Image.asset(
|
||||||
),
|
'assets/images/shaynee_hero.png',
|
||||||
child: SafeArea(
|
height: 260,
|
||||||
child: Column(
|
fit: BoxFit.contain,
|
||||||
children: [
|
)
|
||||||
const Spacer(flex: 2),
|
.animate()
|
||||||
Image.asset(
|
.fadeIn(duration: 600.ms, delay: 200.ms)
|
||||||
'assets/images/shaynee_hero.png',
|
.slideY(begin: 0.08, end: 0, duration: 600.ms, curve: Curves.easeOut),
|
||||||
height: 240,
|
const SizedBox(height: 32),
|
||||||
fit: BoxFit.contain,
|
// Wordmark
|
||||||
)
|
Text(
|
||||||
.animate()
|
'shaynee',
|
||||||
.fadeIn(duration: 700.ms, delay: 200.ms)
|
style: SText.headline1(SColors.textHead).copyWith(
|
||||||
.slideY(begin: 0.15, end: 0, duration: 700.ms, curve: Curves.easeOut),
|
fontSize: 42,
|
||||||
const SizedBox(height: 28),
|
letterSpacing: -0.5,
|
||||||
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),
|
|
||||||
),
|
),
|
||||||
],
|
)
|
||||||
),
|
.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),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
88
lib/widgets/s_button.dart
Normal 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
111
lib/widgets/s_card.dart
Normal 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,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
38
lib/widgets/s_progress_bar.dart
Normal file
38
lib/widgets/s_progress_bar.dart
Normal 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),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user