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