diff --git a/DESIGN_DOC.md b/DESIGN_DOC.md new file mode 100644 index 0000000..003a189 --- /dev/null +++ b/DESIGN_DOC.md @@ -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" | diff --git a/lib/screens/intro_screen.dart b/lib/screens/intro_screen.dart index bda7714..d27365a 100644 --- a/lib/screens/intro_screen.dart +++ b/lib/screens/intro_screen.dart @@ -1,114 +1,171 @@ import 'package:flutter/material.dart'; import 'package:flutter_animate/flutter_animate.dart'; -import 'package:google_fonts/google_fonts.dart'; import '../theme.dart'; -import '../widgets/primary_button.dart'; +import '../widgets/s_button.dart'; import 'quiz/quiz_flow.dart'; class IntroScreen extends StatelessWidget { const IntroScreen({super.key}); + void _start(BuildContext context) { + Navigator.of(context).push(PageRouteBuilder( + pageBuilder: (_, a, __) => const QuizFlow(), + transitionsBuilder: (_, a, __, child) => SlideTransition( + position: Tween( + begin: const Offset(1, 0), + end: Offset.zero, + ).animate(CurvedAnimation(parent: a, curve: Curves.easeOut)), + child: child, + ), + transitionDuration: const Duration(milliseconds: 300), + )); + } + @override Widget build(BuildContext context) { return Scaffold( - backgroundColor: SColors.bgPage, + backgroundColor: SColors.bgSurface, body: SafeArea( - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // Illustration zone (top 42%) + Expanded( + flex: 42, + child: _illustrationZone() + .animate() + .fadeIn(duration: 500.ms), + ), + + // Content zone (bottom 58%) + Expanded( + flex: 58, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox(height: 8), + + // Trust badge + _badge() + .animate() + .fadeIn(duration: 400.ms, delay: 150.ms), + const SizedBox(height: 16), + + // Headline + Text( + 'Skin that glows,\nroutine that works.', + style: SText.headline2(SColors.textHead), + ) + .animate() + .fadeIn(duration: 400.ms, delay: 200.ms) + .slideY(begin: 0.06, end: 0, duration: 400.ms), + const SizedBox(height: 10), + + // Sub + Text( + 'Answer 5 quick questions and get your\npersonalised skincare plan in 60 seconds.', + style: SText.subtitle(SColors.textSub), + ) + .animate() + .fadeIn(duration: 400.ms, delay: 280.ms), + + const Spacer(), + + // Stats row + _statsRow() + .animate() + .fadeIn(duration: 400.ms, delay: 360.ms), + const SizedBox(height: 24), + + // CTA button + SButton( + label: 'GET STARTED', + onTap: () => _start(context), + ) + .animate() + .fadeIn(duration: 400.ms, delay: 440.ms) + .slideY(begin: 0.1, end: 0, duration: 400.ms), + const SizedBox(height: 16), + + // Sign in link (plain text, no button) + Center( + child: GestureDetector( + onTap: () {}, + child: Text( + 'Already have an account? Sign in', + style: SText.caption(SColors.textSub), + ), + ), + ) + .animate() + .fadeIn(duration: 400.ms, delay: 500.ms), + const SizedBox(height: 20), + ], + ), + ), + ), + ], + ), + ), + ); + } + + Widget _illustrationZone() { + return Stack( + children: [ + // Light tinted background for art area + Container(color: const Color(0xFFF7F5FF)), + // Stats visual — abstract feature cards + Center( child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.center, children: [ - const SizedBox(height: 24), - _badge() - .animate() - .fadeIn(duration: 400.ms), - const SizedBox(height: 20), - Text( - 'Skin that glows,\nroutine that works.', - style: GoogleFonts.plusJakartaSans( - fontSize: 32, - fontWeight: FontWeight.w800, - color: SColors.textPrimary, - height: 1.2, - ), - ) - .animate() - .fadeIn(duration: 500.ms, delay: 100.ms) - .slideY(begin: 0.1, end: 0, duration: 500.ms), - const SizedBox(height: 12), - Text( - 'Answer 5 quick questions and get your\npersonalised skincare plan — in 60 seconds.', - style: GoogleFonts.plusJakartaSans( - fontSize: 15, - color: SColors.textSecondary, - height: 1.5, - ), - ) - .animate() - .fadeIn(duration: 500.ms, delay: 200.ms), - const SizedBox(height: 32), - _statsRow() - .animate() - .fadeIn(duration: 500.ms, delay: 300.ms), - const SizedBox(height: 32), - _featureList() - .animate() - .fadeIn(duration: 500.ms, delay: 400.ms), - const Spacer(), - PrimaryButton( - label: 'Build my routine', - icon: Icons.arrow_forward_rounded, - onTap: () => Navigator.of(context).push( - PageRouteBuilder( - pageBuilder: (_, a, __) => const QuizFlow(), - transitionsBuilder: (_, a, __, child) => - SlideTransition( - position: Tween( - begin: const Offset(1, 0), - end: Offset.zero, - ).animate(CurvedAnimation(parent: a, curve: Curves.easeOut)), - child: child, - ), - transitionDuration: const Duration(milliseconds: 350), - ), - ), - ) - .animate() - .fadeIn(duration: 400.ms, delay: 600.ms) - .slideY(begin: 0.2, end: 0, duration: 400.ms), - const SizedBox(height: 12), - Center( - child: Text( - 'Free · No credit card · 1 min setup', - style: GoogleFonts.plusJakartaSans( - fontSize: 12, - color: SColors.textSecondary, - ), - ), - ) - .animate() - .fadeIn(duration: 400.ms, delay: 700.ms), - const SizedBox(height: 24), + _featureChip(Icons.auto_awesome_rounded, 'AI skin analysis'), + const SizedBox(height: 10), + _featureChip(Icons.science_rounded, 'Ingredient science'), + const SizedBox(height: 10), + _featureChip(Icons.lock_outline_rounded, 'Private & secure'), ], ), ), + ], + ); + } + + Widget _featureChip(IconData icon, String label) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + decoration: BoxDecoration( + color: SColors.bgSurface, + borderRadius: BorderRadius.circular(14), + border: Border.all(color: SColors.borderLight, width: 1.5), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, color: SColors.primary, size: 20), + const SizedBox(width: 10), + Text(label, style: SText.cardLabel(SColors.textHead)), + ], ), ); } Widget _badge() { return Container( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5), decoration: BoxDecoration( - color: SColors.primary.withAlpha(18), + color: SColors.primaryLight, borderRadius: BorderRadius.circular(20), ), child: Row( mainAxisSize: MainAxisSize.min, children: [ Container( - width: 8, - height: 8, + width: 7, + height: 7, decoration: const BoxDecoration( color: SColors.accent, shape: BoxShape.circle, @@ -117,11 +174,7 @@ class IntroScreen extends StatelessWidget { const SizedBox(width: 6), Text( 'Trusted by 50,000+ women', - style: GoogleFonts.plusJakartaSans( - fontSize: 12, - fontWeight: FontWeight.w600, - color: SColors.primary, - ), + style: SText.caption(SColors.primary).copyWith(fontWeight: FontWeight.w700), ), ], ), @@ -131,96 +184,32 @@ class IntroScreen extends StatelessWidget { Widget _statsRow() { return Row( children: [ - _statCard('50K+', 'Users'), - const SizedBox(width: 12), - _statCard('4.9★', 'Rating'), - const SizedBox(width: 12), - _statCard('94%', 'See results'), + _statBox('50K+', 'Users'), + const SizedBox(width: 10), + _statBox('4.9★', 'Rating'), + const SizedBox(width: 10), + _statBox('94%', 'Results'), ], ); } - Widget _statCard(String value, String label) { + Widget _statBox(String value, String label) { return Expanded( child: Container( - padding: const EdgeInsets.symmetric(vertical: 16), + padding: const EdgeInsets.symmetric(vertical: 14), decoration: BoxDecoration( - color: SColors.surface, - borderRadius: BorderRadius.circular(16), - border: Border.all(color: SColors.border), + color: SColors.bgSurface, + borderRadius: BorderRadius.circular(14), + border: Border.all(color: SColors.borderLight, width: 1.5), ), child: Column( children: [ - Text( - value, - style: GoogleFonts.plusJakartaSans( - fontSize: 20, - fontWeight: FontWeight.w800, - color: SColors.primary, - ), - ), + Text(value, style: SText.title(SColors.primary)), const SizedBox(height: 2), - Text( - label, - style: GoogleFonts.plusJakartaSans( - fontSize: 12, - color: SColors.textSecondary, - ), - ), + Text(label, style: SText.caption(SColors.textSub)), ], ), ), ); } - - Widget _featureList() { - const items = [ - (Icons.auto_awesome_rounded, 'AI-powered analysis', 'Personalised to your skin'), - (Icons.science_rounded, 'Ingredient science', 'Evidence-based recommendations'), - (Icons.lock_outline_rounded, 'Private & secure', 'Your data stays with you'), - ]; - return Column( - children: items.map((item) => _featureRow(item.$1, item.$2, item.$3)).toList(), - ); - } - - Widget _featureRow(IconData icon, String title, String sub) { - return Padding( - padding: const EdgeInsets.only(bottom: 12), - child: Row( - children: [ - Container( - width: 40, - height: 40, - decoration: BoxDecoration( - color: SColors.primary.withAlpha(18), - borderRadius: BorderRadius.circular(12), - ), - child: Icon(icon, color: SColors.primary, size: 20), - ), - const SizedBox(width: 12), - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - title, - style: GoogleFonts.plusJakartaSans( - fontSize: 14, - fontWeight: FontWeight.w600, - color: SColors.textPrimary, - ), - ), - Text( - sub, - style: GoogleFonts.plusJakartaSans( - fontSize: 12, - color: SColors.textSecondary, - ), - ), - ], - ), - ], - ), - ); - } } diff --git a/lib/screens/quiz/quiz_flow.dart b/lib/screens/quiz/quiz_flow.dart index 025ca8e..895ee89 100644 --- a/lib/screens/quiz/quiz_flow.dart +++ b/lib/screens/quiz/quiz_flow.dart @@ -1,9 +1,9 @@ import 'package:flutter/material.dart'; import 'package:flutter_animate/flutter_animate.dart'; -import 'package:google_fonts/google_fonts.dart'; import '../../theme.dart'; -import '../../widgets/primary_button.dart'; -import '../../widgets/quiz_option_row.dart'; +import '../../widgets/s_button.dart'; +import '../../widgets/s_card.dart'; +import '../../widgets/s_progress_bar.dart'; import '../result_screen.dart'; class QuizData { @@ -34,18 +34,16 @@ class _QuizFlowState extends State { setState(() => _step++); _pageCtrl.animateToPage( _step, - duration: const Duration(milliseconds: 350), + duration: const Duration(milliseconds: 300), curve: Curves.easeOut, ); } else { - Navigator.of(context).pushReplacement( - PageRouteBuilder( - pageBuilder: (_, a, __) => ResultScreen(data: _data), - transitionsBuilder: (_, a, __, child) => - FadeTransition(opacity: a, child: child), - transitionDuration: const Duration(milliseconds: 500), - ), - ); + Navigator.of(context).pushReplacement(PageRouteBuilder( + pageBuilder: (_, a, __) => ResultScreen(data: _data), + transitionsBuilder: (_, a, __, child) => + FadeTransition(opacity: a, child: child), + transitionDuration: const Duration(milliseconds: 400), + )); } } @@ -54,7 +52,7 @@ class _QuizFlowState extends State { setState(() => _step--); _pageCtrl.animateToPage( _step, - duration: const Duration(milliseconds: 300), + duration: const Duration(milliseconds: 280), curve: Curves.easeOut, ); } else { @@ -62,16 +60,16 @@ class _QuizFlowState extends State { } } - bool get _canProceed { - return switch (_step) { - 0 => _data.name.trim().length >= 2, - 1 => _data.skinType.isNotEmpty, - 2 => _data.age.isNotEmpty, - 3 => _data.concerns.isNotEmpty, - 4 => _data.goal.isNotEmpty, - _ => false, - }; - } + bool get _canProceed => switch (_step) { + 0 => _data.name.trim().length >= 2, + 1 => _data.skinType.isNotEmpty, + 2 => _data.age.isNotEmpty, + 3 => _data.concerns.isNotEmpty, + 4 => _data.goal.isNotEmpty, + _ => false, + }; + + String get _buttonLabel => _step == _totalSteps - 1 ? 'SEE MY RESULTS' : 'CONTINUE'; @override void dispose() { @@ -83,12 +81,20 @@ class _QuizFlowState extends State { @override Widget build(BuildContext context) { return Scaffold( - backgroundColor: SColors.bgPage, + backgroundColor: SColors.bgSurface, body: SafeArea( child: Column( children: [ - _topBar(), - _progressBar(), + // Nav row + _navRow(), + const SizedBox(height: 10), + // Progress bar + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: SProgressBar(value: (_step + 1) / _totalSteps), + ), + const SizedBox(height: 16), + // Pages Expanded( child: PageView( controller: _pageCtrl, @@ -123,74 +129,57 @@ class _QuizFlowState extends State { ], ), ), - _bottomBar(), + // Sticky bottom button + Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 20), + child: SButton( + label: _buttonLabel, + onTap: _canProceed ? _next : null, + enabled: _canProceed, + ), + ), ], ), ), ); } - Widget _topBar() { + Widget _navRow() { return Padding( padding: const EdgeInsets.fromLTRB(16, 12, 16, 0), child: Row( children: [ + // Back button GestureDetector( onTap: _back, child: Container( - width: 40, - height: 40, + width: 36, + height: 36, decoration: BoxDecoration( - color: SColors.surface, - borderRadius: BorderRadius.circular(12), - border: Border.all(color: SColors.border), + color: SColors.bgSurface, + borderRadius: BorderRadius.circular(10), + border: Border.all(color: SColors.borderLight, width: 1.5), + ), + child: const Icon( + Icons.arrow_back_ios_new_rounded, + size: 15, + color: SColors.textBody, ), - child: const Icon(Icons.arrow_back_ios_new_rounded, size: 16, color: SColors.textPrimary), ), ), const Spacer(), + // Step counter Text( '${_step + 1} of $_totalSteps', - style: GoogleFonts.plusJakartaSans( - fontSize: 13, - fontWeight: FontWeight.w600, - color: SColors.textSecondary, - ), + style: SText.caption(SColors.textSub).copyWith(fontWeight: FontWeight.w600), ), ], ), ); } - - Widget _progressBar() { - return Padding( - padding: const EdgeInsets.fromLTRB(16, 12, 16, 0), - child: ClipRRect( - borderRadius: BorderRadius.circular(4), - child: LinearProgressIndicator( - value: (_step + 1) / _totalSteps, - backgroundColor: SColors.border, - valueColor: const AlwaysStoppedAnimation(SColors.primary), - minHeight: 4, - ), - ), - ); - } - - Widget _bottomBar() { - return Padding( - padding: const EdgeInsets.fromLTRB(24, 12, 24, 28), - child: PrimaryButton( - label: _step == _totalSteps - 1 ? 'See my results ✨' : 'Continue', - icon: _step == _totalSteps - 1 ? null : Icons.arrow_forward_rounded, - enabled: _canProceed, - onTap: _next, - ), - ); - } } -// ── Step 1: Name ────────────────────────────────────────────────────────────── +// ── Step: Name ──────────────────────────────────────────────────────────────── class _NameStep extends StatelessWidget { final TextEditingController ctrl; @@ -201,68 +190,68 @@ class _NameStep extends StatelessWidget { @override Widget build(BuildContext context) { return SingleChildScrollView( - padding: const EdgeInsets.fromLTRB(24, 32, 24, 16), + padding: const EdgeInsets.symmetric(horizontal: 24), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ + // Small illustration zone + Center( + child: Image.asset( + 'assets/images/shaynee_hero.png', + height: 100, + fit: BoxFit.contain, + ) + .animate() + .fadeIn(duration: 400.ms) + .slideY(begin: 0.05, end: 0), + ), + const SizedBox(height: 28), + Text( 'Hey! What\'s\nyour name? 👋', - style: GoogleFonts.plusJakartaSans( - fontSize: 30, - fontWeight: FontWeight.w800, - color: SColors.textPrimary, - height: 1.2, - ), - ).animate().fadeIn(duration: 400.ms).slideY(begin: 0.1, end: 0), + style: SText.headline2(SColors.textHead), + ).animate().fadeIn(duration: 350.ms), const SizedBox(height: 8), Text( 'I\'ll personalise your routine just for you.', - style: GoogleFonts.plusJakartaSans( - fontSize: 15, - color: SColors.textSecondary, + style: SText.subtitle(SColors.textSub), + ).animate().fadeIn(duration: 350.ms, delay: 80.ms), + const SizedBox(height: 28), + + // Input — custom, no Material underline + Container( + decoration: BoxDecoration( + color: SColors.bgSurface, + borderRadius: BorderRadius.circular(14), + border: Border.all(color: SColors.borderMedium, width: 1.5), ), - ).animate().fadeIn(duration: 400.ms, delay: 100.ms), - const SizedBox(height: 36), - TextField( - controller: ctrl, - onChanged: onChanged, - autofocus: true, - textCapitalization: TextCapitalization.words, - style: GoogleFonts.plusJakartaSans( - fontSize: 18, - fontWeight: FontWeight.w600, - color: SColors.textPrimary, - ), - decoration: InputDecoration( - hintText: 'Your first name', - hintStyle: GoogleFonts.plusJakartaSans( - color: SColors.textSecondary, - fontWeight: FontWeight.w400, - ), - filled: true, - fillColor: SColors.surface, - contentPadding: const EdgeInsets.symmetric(horizontal: 20, vertical: 18), - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(16), - borderSide: const BorderSide(color: SColors.border), - ), - enabledBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(16), - borderSide: const BorderSide(color: SColors.border, width: 1.5), - ), - focusedBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(16), - borderSide: const BorderSide(color: SColors.primary, width: 2), + child: TextField( + controller: ctrl, + onChanged: onChanged, + autofocus: true, + textCapitalization: TextCapitalization.words, + style: SText.cardLabel(SColors.textHead).copyWith(fontSize: 18), + decoration: InputDecoration( + hintText: 'Your first name', + hintStyle: SText.cardLabel(SColors.textDisabled).copyWith( + fontSize: 18, + fontWeight: FontWeight.w500, + ), + border: InputBorder.none, + contentPadding: const EdgeInsets.symmetric( + horizontal: 18, + vertical: 16, + ), ), ), - ).animate().fadeIn(duration: 400.ms, delay: 200.ms), + ).animate().fadeIn(duration: 350.ms, delay: 160.ms), ], ), ); } } -// ── Step 2: Skin Type ───────────────────────────────────────────────────────── +// ── Step: Skin Type ─────────────────────────────────────────────────────────── class _SkinTypeStep extends StatelessWidget { final String selected; @@ -270,7 +259,7 @@ class _SkinTypeStep extends StatelessWidget { const _SkinTypeStep({required this.selected, required this.onSelect}); - static const _options = [ + static const _opts = [ (Icons.water_drop_rounded, 'Oily', 'Shiny by midday, large pores'), (Icons.grass_rounded, 'Dry', 'Tight, flaky, rough patches'), (Icons.blur_on_rounded, 'Combination', 'Oily T-zone, dry cheeks'), @@ -281,37 +270,37 @@ class _SkinTypeStep extends StatelessWidget { @override Widget build(BuildContext context) { return SingleChildScrollView( - padding: const EdgeInsets.fromLTRB(24, 32, 24, 16), + padding: const EdgeInsets.symmetric(horizontal: 16), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text( - 'What\'s your\nskin type?', - style: GoogleFonts.plusJakartaSans( - fontSize: 30, - fontWeight: FontWeight.w800, - color: SColors.textPrimary, - height: 1.2, + Padding( + padding: const EdgeInsets.symmetric(horizontal: 8), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('What\'s your\nskin type?', style: SText.headline2(SColors.textHead)) + .animate().fadeIn(duration: 350.ms), + const SizedBox(height: 6), + Text('Pick the one that fits best.', style: SText.subtitle(SColors.textSub)) + .animate().fadeIn(duration: 350.ms, delay: 80.ms), + const SizedBox(height: 20), + ], ), - ).animate().fadeIn(duration: 400.ms), - const SizedBox(height: 8), - Text( - 'Choose the one that best describes your skin.', - style: GoogleFonts.plusJakartaSans(fontSize: 15, color: SColors.textSecondary), - ).animate().fadeIn(duration: 400.ms, delay: 100.ms), - const SizedBox(height: 24), - ...List.generate(_options.length, (i) { - final (icon, label, sub) = _options[i]; + ), + ..._opts.asMap().entries.map((e) { + final i = e.key; + final (icon, label, sub) = e.value; return Padding( - padding: const EdgeInsets.only(bottom: 10), - child: QuizOptionRow( + padding: const EdgeInsets.only(bottom: 8), + child: SCard( icon: icon, label: label, sub: sub, isSelected: selected == label, onTap: () => onSelect(label), - ).animate().fadeIn(duration: 300.ms, delay: (100 + i * 60).ms) - .slideX(begin: 0.05, end: 0, duration: 300.ms), + ).animate().fadeIn(duration: 280.ms, delay: (80 + i * 50).ms) + .slideX(begin: 0.03, end: 0, duration: 280.ms), ); }), ], @@ -320,7 +309,7 @@ class _SkinTypeStep extends StatelessWidget { } } -// ── Step 3: Age ─────────────────────────────────────────────────────────────── +// ── Step: Age ───────────────────────────────────────────────────────────────── class _AgeStep extends StatelessWidget { final String selected; @@ -328,7 +317,7 @@ class _AgeStep extends StatelessWidget { const _AgeStep({required this.selected, required this.onSelect}); - static const _options = [ + static const _opts = [ (Icons.star_rounded, 'Under 25', 'Young & preventive care'), (Icons.hourglass_bottom_rounded, '25–35', 'Early anti-aging focus'), (Icons.wb_sunny_rounded, '35–45', 'Targeted treatments'), @@ -339,37 +328,38 @@ class _AgeStep extends StatelessWidget { @override Widget build(BuildContext context) { return SingleChildScrollView( - padding: const EdgeInsets.fromLTRB(24, 32, 24, 16), + padding: const EdgeInsets.symmetric(horizontal: 16), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text( - 'How old\nare you?', - style: GoogleFonts.plusJakartaSans( - fontSize: 30, - fontWeight: FontWeight.w800, - color: SColors.textPrimary, - height: 1.2, + Padding( + padding: const EdgeInsets.symmetric(horizontal: 8), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('How old\nare you?', style: SText.headline2(SColors.textHead)) + .animate().fadeIn(duration: 350.ms), + const SizedBox(height: 6), + Text('Skin changes with age — helps us tailor advice.', + style: SText.subtitle(SColors.textSub)) + .animate().fadeIn(duration: 350.ms, delay: 80.ms), + const SizedBox(height: 20), + ], ), - ).animate().fadeIn(duration: 400.ms), - const SizedBox(height: 8), - Text( - 'Skin changes with age — this helps us tailor advice.', - style: GoogleFonts.plusJakartaSans(fontSize: 15, color: SColors.textSecondary), - ).animate().fadeIn(duration: 400.ms, delay: 100.ms), - const SizedBox(height: 24), - ...List.generate(_options.length, (i) { - final (icon, label, sub) = _options[i]; + ), + ..._opts.asMap().entries.map((e) { + final i = e.key; + final (icon, label, sub) = e.value; return Padding( - padding: const EdgeInsets.only(bottom: 10), - child: QuizOptionRow( + padding: const EdgeInsets.only(bottom: 8), + child: SCard( icon: icon, label: label, sub: sub, isSelected: selected == label, onTap: () => onSelect(label), - ).animate().fadeIn(duration: 300.ms, delay: (100 + i * 60).ms) - .slideX(begin: 0.05, end: 0, duration: 300.ms), + ).animate().fadeIn(duration: 280.ms, delay: (80 + i * 50).ms) + .slideX(begin: 0.03, end: 0, duration: 280.ms), ); }), ], @@ -378,7 +368,7 @@ class _AgeStep extends StatelessWidget { } } -// ── Step 4: Concerns ────────────────────────────────────────────────────────── +// ── Step: Concerns ──────────────────────────────────────────────────────────── class _ConcernsStep extends StatelessWidget { final List selected; @@ -386,7 +376,7 @@ class _ConcernsStep extends StatelessWidget { const _ConcernsStep({required this.selected, required this.onToggle}); - static const _options = [ + static const _opts = [ (Icons.face_retouching_natural, 'Breakouts', 'Acne, blackheads, congestion'), (Icons.cloud_rounded, 'Dullness', 'Tired, uneven, grey skin'), (Icons.air_rounded, 'Dryness', 'Tight, rough, flaking'), @@ -398,42 +388,61 @@ class _ConcernsStep extends StatelessWidget { @override Widget build(BuildContext context) { return SingleChildScrollView( - padding: const EdgeInsets.fromLTRB(24, 32, 24, 16), + padding: const EdgeInsets.symmetric(horizontal: 16), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text( - 'Your skin\nconcerns?', - style: GoogleFonts.plusJakartaSans( - fontSize: 30, - fontWeight: FontWeight.w800, - color: SColors.textPrimary, - height: 1.2, + Padding( + padding: const EdgeInsets.symmetric(horizontal: 8), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Text('Your skin concerns?', style: SText.headline2(SColors.textHead)) + .animate().fadeIn(duration: 350.ms), + const Spacer(), + // counter badge + if (selected.isNotEmpty) + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: SColors.primary, + borderRadius: BorderRadius.circular(20), + ), + child: Text( + '${selected.length}/3', + style: SText.caption(Colors.white) + .copyWith(fontWeight: FontWeight.w700), + ), + ), + ], + ), + const SizedBox(height: 6), + Text('Pick up to 3 that bother you most.', style: SText.subtitle(SColors.textSub)) + .animate().fadeIn(duration: 350.ms, delay: 80.ms), + const SizedBox(height: 20), + ], ), - ).animate().fadeIn(duration: 400.ms), - const SizedBox(height: 8), - Text( - 'Pick up to 3 that bother you most.', - style: GoogleFonts.plusJakartaSans(fontSize: 15, color: SColors.textSecondary), - ).animate().fadeIn(duration: 400.ms, delay: 100.ms), - const SizedBox(height: 24), - ...List.generate(_options.length, (i) { - final (icon, label, sub) = _options[i]; - final isSelected = selected.contains(label); - final disabled = !isSelected && selected.length >= 3; + ), + ..._opts.asMap().entries.map((e) { + final i = e.key; + final (icon, label, sub) = e.value; + final isSel = selected.contains(label); + final disabled = !isSel && selected.length >= 3; return Padding( - padding: const EdgeInsets.only(bottom: 10), + padding: const EdgeInsets.only(bottom: 8), child: Opacity( - opacity: disabled ? 0.4 : 1.0, - child: QuizOptionRow( + opacity: disabled ? 0.35 : 1.0, + child: SCard( icon: icon, label: label, sub: sub, - isSelected: isSelected, + isSelected: isSel, onTap: disabled ? () {} : () => onToggle(label), - ), - ).animate().fadeIn(duration: 300.ms, delay: (100 + i * 60).ms) - .slideX(begin: 0.05, end: 0, duration: 300.ms), + ).animate().fadeIn(duration: 280.ms, delay: (80 + i * 50).ms) + .slideX(begin: 0.03, end: 0, duration: 280.ms), + ), ); }), ], @@ -442,7 +451,7 @@ class _ConcernsStep extends StatelessWidget { } } -// ── Step 5: Goal ────────────────────────────────────────────────────────────── +// ── Step: Goal ──────────────────────────────────────────────────────────────── class _GoalStep extends StatelessWidget { final String selected; @@ -450,7 +459,7 @@ class _GoalStep extends StatelessWidget { const _GoalStep({required this.selected, required this.onSelect}); - static const _options = [ + static const _opts = [ (Icons.auto_awesome_rounded, 'Healthy glow', 'Radiant, dewy, lit-from-within'), (Icons.shield_rounded, 'Clear skin', 'Blemish-free, smooth texture'), (Icons.access_time_rounded, 'Slow aging', 'Firm, youthful, preventive'), @@ -460,37 +469,38 @@ class _GoalStep extends StatelessWidget { @override Widget build(BuildContext context) { return SingleChildScrollView( - padding: const EdgeInsets.fromLTRB(24, 32, 24, 16), + padding: const EdgeInsets.symmetric(horizontal: 16), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text( - 'Your skin\ngoal?', - style: GoogleFonts.plusJakartaSans( - fontSize: 30, - fontWeight: FontWeight.w800, - color: SColors.textPrimary, - height: 1.2, + Padding( + padding: const EdgeInsets.symmetric(horizontal: 8), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Your main\nskin goal?', style: SText.headline2(SColors.textHead)) + .animate().fadeIn(duration: 350.ms), + const SizedBox(height: 6), + Text('We\'ll build your plan around it.', + style: SText.subtitle(SColors.textSub)) + .animate().fadeIn(duration: 350.ms, delay: 80.ms), + const SizedBox(height: 20), + ], ), - ).animate().fadeIn(duration: 400.ms), - const SizedBox(height: 8), - Text( - 'Pick one — we\'ll build your plan around it.', - style: GoogleFonts.plusJakartaSans(fontSize: 15, color: SColors.textSecondary), - ).animate().fadeIn(duration: 400.ms, delay: 100.ms), - const SizedBox(height: 24), - ...List.generate(_options.length, (i) { - final (icon, label, sub) = _options[i]; + ), + ..._opts.asMap().entries.map((e) { + final i = e.key; + final (icon, label, sub) = e.value; return Padding( - padding: const EdgeInsets.only(bottom: 10), - child: QuizOptionRow( + padding: const EdgeInsets.only(bottom: 8), + child: SCard( icon: icon, label: label, sub: sub, isSelected: selected == label, onTap: () => onSelect(label), - ).animate().fadeIn(duration: 300.ms, delay: (100 + i * 60).ms) - .slideX(begin: 0.05, end: 0, duration: 300.ms), + ).animate().fadeIn(duration: 280.ms, delay: (80 + i * 50).ms) + .slideX(begin: 0.03, end: 0, duration: 280.ms), ); }), ], diff --git a/lib/screens/result_screen.dart b/lib/screens/result_screen.dart index 8ee083e..58426ac 100644 --- a/lib/screens/result_screen.dart +++ b/lib/screens/result_screen.dart @@ -1,8 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_animate/flutter_animate.dart'; -import 'package:google_fonts/google_fonts.dart'; import '../theme.dart'; -import '../widgets/primary_button.dart'; +import '../widgets/s_button.dart'; import 'quiz/quiz_flow.dart'; class ResultScreen extends StatefulWidget { @@ -19,180 +18,196 @@ class _ResultScreenState extends State { @override void initState() { super.initState(); - Future.delayed(const Duration(milliseconds: 1800), () { + Future.delayed(const Duration(milliseconds: 2000), () { if (mounted) setState(() => _revealed = true); }); } - String get _firstName => widget.data.name.split(' ').first; + String get _name => widget.data.name.split(' ').first; @override Widget build(BuildContext context) { return Scaffold( - body: _revealed ? _resultView() : _analyzingView(), + backgroundColor: SColors.bgSurface, + body: _revealed ? _resultView(context) : _analyzingView(), ); } + // ── Analysing screen ────────────────────────────────────────────────────── + Widget _analyzingView() { - return Container( - decoration: const BoxDecoration( - gradient: LinearGradient( - colors: [Color(0xFF3D32A8), Color(0xFF5B4FCF), Color(0xFF8B5CF6)], - begin: Alignment.topLeft, - end: Alignment.bottomRight, - ), - ), - child: SafeArea( - child: Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const CircularProgressIndicator( - valueColor: AlwaysStoppedAnimation(Colors.white), - strokeWidth: 3, - ) - .animate(onPlay: (c) => c.repeat()) - .rotate(duration: 1.seconds), - const SizedBox(height: 28), - Text( - 'Analysing your skin...', - style: GoogleFonts.plusJakartaSans( - fontSize: 20, - fontWeight: FontWeight.w600, - color: Colors.white, - ), - ).animate(onPlay: (c) => c.repeat(reverse: true)) - .fadeIn(duration: 800.ms), - const SizedBox(height: 8), - Text( - 'Building your personalised routine', - style: GoogleFonts.plusJakartaSans( - fontSize: 14, - color: Colors.white.withAlpha(180), - ), + return SafeArea( + child: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + // Pulsing Shaynee avatar + Image.asset('assets/images/shaynee_avatar.png', height: 120) + .animate(onPlay: (c) => c.repeat(reverse: true)) + .scaleXY(begin: 0.95, end: 1.05, duration: 900.ms, curve: Curves.easeInOut), + const SizedBox(height: 32), + // Spinner (plain, no color) + const SizedBox( + width: 32, + height: 32, + child: CircularProgressIndicator( + strokeWidth: 2.5, + valueColor: AlwaysStoppedAnimation(SColors.primary), ), - ], - ), + ), + const SizedBox(height: 20), + Text( + 'Analysing your skin...', + style: SText.title(SColors.textHead), + ), + const SizedBox(height: 8), + Text( + 'Building your personalised routine', + style: SText.subtitle(SColors.textSub), + ), + ], ), ), ); } - Widget _resultView() { - return Container( - decoration: const BoxDecoration( - gradient: LinearGradient( - colors: [Color(0xFF3D32A8), Color(0xFF5B4FCF), Color(0xFF8B5CF6)], - begin: Alignment.topLeft, - end: Alignment.bottomRight, - ), - ), - child: SafeArea( - child: SingleChildScrollView( - padding: const EdgeInsets.symmetric(horizontal: 24), - child: Column( - children: [ - const SizedBox(height: 24), - Image.asset( - 'assets/images/shaynee_hero.png', - height: 180, - fit: BoxFit.contain, - ) - .animate() - .fadeIn(duration: 700.ms) - .slideY(begin: 0.15, end: 0, duration: 700.ms, curve: Curves.easeOut), - const SizedBox(height: 20), - Text( - 'Your skin report\nis ready, $_firstName! ✨', - textAlign: TextAlign.center, - style: GoogleFonts.plusJakartaSans( - fontSize: 28, - fontWeight: FontWeight.w800, - color: Colors.white, - height: 1.2, - ), - ) - .animate() - .fadeIn(duration: 600.ms, delay: 200.ms), - const SizedBox(height: 28), - _reportCard() - .animate() - .fadeIn(duration: 600.ms, delay: 400.ms) - .slideY(begin: 0.1, end: 0, duration: 600.ms), - const SizedBox(height: 20), - _routinePreview() - .animate() - .fadeIn(duration: 600.ms, delay: 600.ms), - const SizedBox(height: 28), - PrimaryButton( - label: 'Start my routine', - icon: Icons.arrow_forward_rounded, - onTap: () {}, - ) - .animate() - .fadeIn(duration: 400.ms, delay: 800.ms), - const SizedBox(height: 32), - ], - ), - ), - ), - ); - } + // ── Result reveal screen ────────────────────────────────────────────────── - Widget _reportCard() { - return Container( - padding: const EdgeInsets.all(20), - decoration: BoxDecoration( - color: Colors.white.withAlpha(20), - borderRadius: BorderRadius.circular(20), - border: Border.all(color: Colors.white.withAlpha(40)), - ), + Widget _resultView(BuildContext context) { + return SafeArea( child: Column( - crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text( - 'Your skin profile', - style: GoogleFonts.plusJakartaSans( - fontSize: 12, - fontWeight: FontWeight.w600, - color: Colors.white.withAlpha(160), - letterSpacing: 0.8, + // Mascot zone (top 35%) — KEY MOMENT + Expanded( + flex: 35, + child: Container( + color: SColors.primaryLight, + width: double.infinity, + child: Center( + child: Image.asset( + 'assets/images/shaynee_hero.png', + fit: BoxFit.contain, + ) + .animate() + .fadeIn(duration: 600.ms) + .slideY(begin: 0.08, end: 0, duration: 600.ms, curve: Curves.easeOut), + ), + ), + ), + + // Content zone (bottom 65%) + Expanded( + flex: 65, + child: SingleChildScrollView( + padding: const EdgeInsets.symmetric(horizontal: 24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox(height: 20), + Text( + 'Your skin report is\nready, $_name! ✨', + style: SText.headline2(SColors.textHead), + ) + .animate() + .fadeIn(duration: 500.ms, delay: 200.ms), + const SizedBox(height: 6), + Text( + 'Based on your answers, here\'s what we recommend.', + style: SText.subtitle(SColors.textSub), + ) + .animate() + .fadeIn(duration: 500.ms, delay: 300.ms), + const SizedBox(height: 20), + + // Skin profile card + _profileCard() + .animate() + .fadeIn(duration: 500.ms, delay: 400.ms) + .slideY(begin: 0.05, end: 0), + const SizedBox(height: 12), + + // Routine preview card + _routineCard() + .animate() + .fadeIn(duration: 500.ms, delay: 550.ms) + .slideY(begin: 0.05, end: 0), + const SizedBox(height: 24), + + // CTA + SButton( + label: 'START MY ROUTINE', + onTap: () {}, + ) + .animate() + .fadeIn(duration: 400.ms, delay: 700.ms), + const SizedBox(height: 12), + + // Skip link (plain text, no button) + Center( + child: GestureDetector( + onTap: () {}, + child: Text( + 'Save my results — create account', + style: SText.caption(SColors.textSub), + ), + ), + ) + .animate() + .fadeIn(duration: 400.ms, delay: 800.ms), + const SizedBox(height: 24), + ], + ), ), ), - const SizedBox(height: 14), - _reportRow('Skin type', widget.data.skinType), - _reportRow('Age range', widget.data.age), - _reportRow('Concerns', widget.data.concerns.join(', ')), - _reportRow('Main goal', widget.data.goal), ], ), ); } - Widget _reportRow(String label, String value) { - if (value.isEmpty) return const SizedBox.shrink(); + Widget _profileCard() { + final rows = [ + ('Skin type', widget.data.skinType), + ('Age range', widget.data.age), + ('Concerns', widget.data.concerns.join(', ')), + ('Main goal', widget.data.goal), + ]; + return Container( + padding: const EdgeInsets.all(18), + decoration: BoxDecoration( + color: SColors.bgSurface, + borderRadius: BorderRadius.circular(16), + border: Border.all(color: SColors.borderLight, width: 1.5), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'YOUR SKIN PROFILE', + style: SText.caption(SColors.textSub) + .copyWith(fontWeight: FontWeight.w700, letterSpacing: 0.8), + ), + const SizedBox(height: 14), + ...rows + .where((r) => r.$2.isNotEmpty) + .map((r) => _profileRow(r.$1, r.$2)), + ], + ), + ); + } + + Widget _profileRow(String label, String value) { return Padding( - padding: const EdgeInsets.only(bottom: 10), + padding: const EdgeInsets.only(bottom: 8), child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text( - label, - style: GoogleFonts.plusJakartaSans( - fontSize: 13, - color: Colors.white.withAlpha(160), - ), - ), - const SizedBox(width: 12), - Expanded( + Text(label, style: SText.body(SColors.textSub)), + const Spacer(), + Flexible( child: Text( value, textAlign: TextAlign.end, - style: GoogleFonts.plusJakartaSans( - fontSize: 13, - fontWeight: FontWeight.w600, - color: Colors.white, - ), + style: SText.body(SColors.textHead).copyWith(fontWeight: FontWeight.w700), ), ), ], @@ -200,30 +215,26 @@ class _ResultScreenState extends State { ); } - Widget _routinePreview() { + Widget _routineCard() { const steps = [ - ('🌅', 'Morning', 'Cleanser → Vitamin C → SPF 50'), - ('🌙', 'Evening', 'Cleanser → Retinol → Moisturiser'), + ('🌅', 'Morning', 'Cleanser · Vitamin C · SPF 50'), + ('🌙', 'Evening', 'Cleanser · Retinol · Moisturiser'), ('✨', 'Weekly', 'Exfoliant + Hydrating mask'), ]; return Container( - padding: const EdgeInsets.all(20), + padding: const EdgeInsets.all(18), decoration: BoxDecoration( - color: Colors.white.withAlpha(20), - borderRadius: BorderRadius.circular(20), - border: Border.all(color: Colors.white.withAlpha(40)), + color: SColors.bgSurface, + borderRadius: BorderRadius.circular(16), + border: Border.all(color: SColors.borderLight, width: 1.5), ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - 'Your routine preview', - style: GoogleFonts.plusJakartaSans( - fontSize: 12, - fontWeight: FontWeight.w600, - color: Colors.white.withAlpha(160), - letterSpacing: 0.8, - ), + 'YOUR ROUTINE PREVIEW', + style: SText.caption(SColors.textSub) + .copyWith(fontWeight: FontWeight.w700, letterSpacing: 0.8), ), const SizedBox(height: 14), ...steps.map((s) => Padding( @@ -236,21 +247,10 @@ class _ResultScreenState extends State { Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text( - s.$2, - style: GoogleFonts.plusJakartaSans( - fontSize: 13, - fontWeight: FontWeight.w700, - color: Colors.white, - ), - ), - Text( - s.$3, - style: GoogleFonts.plusJakartaSans( - fontSize: 12, - color: Colors.white.withAlpha(160), - ), - ), + Text(s.$2, + style: SText.cardLabel(SColors.textHead)), + const SizedBox(height: 2), + Text(s.$3, style: SText.cardSub(SColors.textSub)), ], ), ], diff --git a/lib/screens/splash_screen.dart b/lib/screens/splash_screen.dart index 0c21ac3..6783d0a 100644 --- a/lib/screens/splash_screen.dart +++ b/lib/screens/splash_screen.dart @@ -1,6 +1,5 @@ import 'package:flutter/material.dart'; import 'package:flutter_animate/flutter_animate.dart'; -import 'package:google_fonts/google_fonts.dart'; import '../theme.dart'; import 'intro_screen.dart'; @@ -15,7 +14,7 @@ class _SplashScreenState extends State { @override void initState() { super.initState(); - Future.delayed(const Duration(milliseconds: 2600), _goNext); + Future.delayed(const Duration(milliseconds: 2400), _goNext); } void _goNext() { @@ -25,7 +24,7 @@ class _SplashScreenState extends State { pageBuilder: (_, a, __) => const IntroScreen(), transitionsBuilder: (_, a, __, child) => FadeTransition(opacity: a, child: child), - transitionDuration: const Duration(milliseconds: 500), + transitionDuration: const Duration(milliseconds: 400), ), ); } @@ -33,76 +32,42 @@ class _SplashScreenState extends State { @override Widget build(BuildContext context) { return Scaffold( - body: Container( - decoration: const BoxDecoration( - gradient: LinearGradient( - colors: [Color(0xFF3D32A8), Color(0xFF5B4FCF), Color(0xFF8B5CF6)], - begin: Alignment.topLeft, - end: Alignment.bottomRight, - ), - ), - child: SafeArea( - child: Column( - children: [ - const Spacer(flex: 2), - Image.asset( - 'assets/images/shaynee_hero.png', - height: 240, - fit: BoxFit.contain, - ) - .animate() - .fadeIn(duration: 700.ms, delay: 200.ms) - .slideY(begin: 0.15, end: 0, duration: 700.ms, curve: Curves.easeOut), - const SizedBox(height: 28), - Text( - 'shaynee', - style: GoogleFonts.plusJakartaSans( - fontSize: 42, - fontWeight: FontWeight.w800, - color: Colors.white, - letterSpacing: -1.0, - ), - ) - .animate() - .fadeIn(duration: 600.ms, delay: 500.ms), - const SizedBox(height: 8), - Text( - 'your AI skincare companion', - style: GoogleFonts.plusJakartaSans( - fontSize: 16, - fontWeight: FontWeight.w400, - color: Colors.white.withAlpha(180), - letterSpacing: 0.2, - ), - ) - .animate() - .fadeIn(duration: 600.ms, delay: 700.ms), - const Spacer(flex: 3), - Padding( - padding: const EdgeInsets.only(bottom: 40), - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: List.generate(3, (i) => _dot(i)), - ) - .animate() - .fadeIn(duration: 500.ms, delay: 1200.ms), + backgroundColor: SColors.bgSurface, + body: SafeArea( + child: Column( + children: [ + const Spacer(flex: 2), + // Mascot — centered, large + Image.asset( + 'assets/images/shaynee_hero.png', + height: 260, + fit: BoxFit.contain, + ) + .animate() + .fadeIn(duration: 600.ms, delay: 200.ms) + .slideY(begin: 0.08, end: 0, duration: 600.ms, curve: Curves.easeOut), + const SizedBox(height: 32), + // Wordmark + Text( + 'shaynee', + style: SText.headline1(SColors.textHead).copyWith( + fontSize: 42, + letterSpacing: -0.5, ), - ], - ), + ) + .animate() + .fadeIn(duration: 500.ms, delay: 500.ms), + const SizedBox(height: 8), + Text( + 'your ai skincare companion', + style: SText.subtitle(SColors.textSub), + ) + .animate() + .fadeIn(duration: 500.ms, delay: 700.ms), + const Spacer(flex: 3), + ], ), ), ); } - - Widget _dot(int i) { - return Container( - margin: const EdgeInsets.symmetric(horizontal: 4), - width: i == 0 ? 24 : 8, - height: 8, - decoration: BoxDecoration( - color: i == 0 ? Colors.white : Colors.white.withAlpha(80), - borderRadius: BorderRadius.circular(4), - ), - ); - } } diff --git a/lib/theme.dart b/lib/theme.dart index 2affa14..40181c4 100644 --- a/lib/theme.dart +++ b/lib/theme.dart @@ -2,42 +2,65 @@ import 'package:flutter/material.dart'; import 'package:google_fonts/google_fonts.dart'; class SColors { + // Backgrounds + static const bgPage = Color(0xFFFAFAFE); + static const bgSurface = Color(0xFFFFFFFF); + + // Primary (Indigo) static const primary = Color(0xFF5B4FCF); - static const primaryLight = Color(0xFF7B70E0); - static const primaryDeep = Color(0xFF3D32A8); + static const primaryDark = Color(0xFF3D32A8); // button bottom border + 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 accentSoft = Color(0xFFFFF0F6); - static const bgPage = Color(0xFFFAF9FF); - static const surface = Color(0xFFFFFFFF); - static const textPrimary = Color(0xFF1C1A2E); - static const textSecondary = Color(0xFF7B7A8E); - static const border = Color(0xFFE8E6F0); - static const skyTint = Color(0xFFEEECFA); - static const gradientTop = Color(0xFF5B4FCF); - static const gradientBot = Color(0xFF8B5CF6); + static const accentLight = Color(0xFFFFF0F6); + + // States + static const success = Color(0xFF4CAF50); + static const error = Color(0xFFFF4B4B); +} + +TextStyle _nunito(double size, FontWeight weight, Color color, {double? height, double? letterSpacing}) { + 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() { return ThemeData( - colorScheme: ColorScheme.fromSeed( - seedColor: SColors.primary, - brightness: Brightness.light, + colorScheme: const ColorScheme.light( + primary: SColors.primary, + surface: SColors.bgSurface, ), scaffoldBackgroundColor: SColors.bgPage, - textTheme: GoogleFonts.plusJakartaSansTextTheme().copyWith( - 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, - ), - ), + textTheme: GoogleFonts.nunitoTextTheme(), useMaterial3: true, ); } diff --git a/lib/widgets/s_button.dart b/lib/widgets/s_button.dart new file mode 100644 index 0000000..68b1edc --- /dev/null +++ b/lib/widgets/s_button.dart @@ -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 createState() => _SButtonState(); +} + +class _SButtonState extends State { + 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)), + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/widgets/s_card.dart b/lib/widgets/s_card.dart new file mode 100644 index 0000000..3e8711a --- /dev/null +++ b/lib/widgets/s_card.dart @@ -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, + ), + ], + ), + ), + ); + } +} diff --git a/lib/widgets/s_progress_bar.dart b/lib/widgets/s_progress_bar.dart new file mode 100644 index 0000000..a82c971 --- /dev/null +++ b/lib/widgets/s_progress_bar.dart @@ -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), + ), + ), + ], + ); + }, + ); + } +}