feat: full onboarding + quiz flow (Flutter from scratch)

- splash → intro → 5-step quiz → result screen
- Plus Jakarta Sans typography, indigo brand system
- animated quiz rows, multi-select concerns, progress bar
- Shaynee mascot reveal on result screen only
- flutter_animate for all transitions
This commit is contained in:
mav
2026-05-28 16:20:18 +02:00
parent 74c712dbae
commit 30cee5a486
12 changed files with 1567 additions and 144 deletions

View File

@@ -1,122 +1,27 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'theme.dart';
import 'screens/splash_screen.dart';
void main() {
runApp(const MyApp());
WidgetsFlutterBinding.ensureInitialized();
SystemChrome.setPreferredOrientations([DeviceOrientation.portraitUp]);
SystemChrome.setSystemUIOverlayStyle(
const SystemUiOverlayStyle(statusBarBrightness: Brightness.dark),
);
runApp(const ShayneeApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
class ShayneeApp extends StatelessWidget {
const ShayneeApp({super.key});
// This widget is the root of your application.
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Flutter Demo',
theme: ThemeData(
// This is the theme of your application.
//
// TRY THIS: Try running your application with "flutter run". You'll see
// the application has a purple toolbar. Then, without quitting the app,
// try changing the seedColor in the colorScheme below to Colors.green
// and then invoke "hot reload" (save your changes or press the "hot
// reload" button in a Flutter-supported IDE, or press "r" if you used
// the command line to start the app).
//
// Notice that the counter didn't reset back to zero; the application
// state is not lost during the reload. To reset the state, use hot
// restart instead.
//
// This works for code too, not just values: Most code changes can be
// tested with just a hot reload.
colorScheme: .fromSeed(seedColor: Colors.deepPurple),
),
home: const MyHomePage(title: 'Flutter Demo Home Page'),
);
}
}
class MyHomePage extends StatefulWidget {
const MyHomePage({super.key, required this.title});
// This widget is the home page of your application. It is stateful, meaning
// that it has a State object (defined below) that contains fields that affect
// how it looks.
// This class is the configuration for the state. It holds the values (in this
// case the title) provided by the parent (in this case the App widget) and
// used by the build method of the State. Fields in a Widget subclass are
// always marked "final".
final String title;
@override
State<MyHomePage> createState() => _MyHomePageState();
}
class _MyHomePageState extends State<MyHomePage> {
int _counter = 0;
void _incrementCounter() {
setState(() {
// This call to setState tells the Flutter framework that something has
// changed in this State, which causes it to rerun the build method below
// so that the display can reflect the updated values. If we changed
// _counter without calling setState(), then the build method would not be
// called again, and so nothing would appear to happen.
_counter++;
});
}
@override
Widget build(BuildContext context) {
// This method is rerun every time setState is called, for instance as done
// by the _incrementCounter method above.
//
// The Flutter framework has been optimized to make rerunning build methods
// fast, so that you can just rebuild anything that needs updating rather
// than having to individually change instances of widgets.
return Scaffold(
appBar: AppBar(
// TRY THIS: Try changing the color here to a specific color (to
// Colors.amber, perhaps?) and trigger a hot reload to see the AppBar
// change color while the other colors stay the same.
backgroundColor: Theme.of(context).colorScheme.inversePrimary,
// Here we take the value from the MyHomePage object that was created by
// the App.build method, and use it to set our appbar title.
title: Text(widget.title),
),
body: Center(
// Center is a layout widget. It takes a single child and positions it
// in the middle of the parent.
child: Column(
// Column is also a layout widget. It takes a list of children and
// arranges them vertically. By default, it sizes itself to fit its
// children horizontally, and tries to be as tall as its parent.
//
// Column has various properties to control how it sizes itself and
// how it positions its children. Here we use mainAxisAlignment to
// center the children vertically; the main axis here is the vertical
// axis because Columns are vertical (the cross axis would be
// horizontal).
//
// TRY THIS: Invoke "debug painting" (choose the "Toggle Debug Paint"
// action in the IDE, or press "p" in the console), to see the
// wireframe for each widget.
mainAxisAlignment: .center,
children: [
const Text('You have pushed the button this many times:'),
Text(
'$_counter',
style: Theme.of(context).textTheme.headlineMedium,
),
],
),
),
floatingActionButton: FloatingActionButton(
onPressed: _incrementCounter,
tooltip: 'Increment',
child: const Icon(Icons.add),
),
title: 'Shaynee',
debugShowCheckedModeBanner: false,
theme: buildTheme(),
home: const SplashScreen(),
);
}
}

View File

@@ -0,0 +1,226 @@
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 'quiz/quiz_flow.dart';
class IntroScreen extends StatelessWidget {
const IntroScreen({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: SColors.bgPage,
body: SafeArea(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(height: 24),
_badge()
.animate()
.fadeIn(duration: 400.ms),
const SizedBox(height: 20),
Text(
'Skin that glows,\nroutine that works.',
style: GoogleFonts.plusJakartaSans(
fontSize: 32,
fontWeight: FontWeight.w800,
color: SColors.textPrimary,
height: 1.2,
),
)
.animate()
.fadeIn(duration: 500.ms, delay: 100.ms)
.slideY(begin: 0.1, end: 0, duration: 500.ms),
const SizedBox(height: 12),
Text(
'Answer 5 quick questions and get your\npersonalised skincare plan — in 60 seconds.',
style: GoogleFonts.plusJakartaSans(
fontSize: 15,
color: SColors.textSecondary,
height: 1.5,
),
)
.animate()
.fadeIn(duration: 500.ms, delay: 200.ms),
const SizedBox(height: 32),
_statsRow()
.animate()
.fadeIn(duration: 500.ms, delay: 300.ms),
const SizedBox(height: 32),
_featureList()
.animate()
.fadeIn(duration: 500.ms, delay: 400.ms),
const Spacer(),
PrimaryButton(
label: 'Build my routine',
icon: Icons.arrow_forward_rounded,
onTap: () => Navigator.of(context).push(
PageRouteBuilder(
pageBuilder: (_, a, __) => const QuizFlow(),
transitionsBuilder: (_, a, __, child) =>
SlideTransition(
position: Tween<Offset>(
begin: const Offset(1, 0),
end: Offset.zero,
).animate(CurvedAnimation(parent: a, curve: Curves.easeOut)),
child: child,
),
transitionDuration: const Duration(milliseconds: 350),
),
),
)
.animate()
.fadeIn(duration: 400.ms, delay: 600.ms)
.slideY(begin: 0.2, end: 0, duration: 400.ms),
const SizedBox(height: 12),
Center(
child: Text(
'Free · No credit card · 1 min setup',
style: GoogleFonts.plusJakartaSans(
fontSize: 12,
color: SColors.textSecondary,
),
),
)
.animate()
.fadeIn(duration: 400.ms, delay: 700.ms),
const SizedBox(height: 24),
],
),
),
),
);
}
Widget _badge() {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: SColors.primary.withAlpha(18),
borderRadius: BorderRadius.circular(20),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 8,
height: 8,
decoration: const BoxDecoration(
color: SColors.accent,
shape: BoxShape.circle,
),
),
const SizedBox(width: 6),
Text(
'Trusted by 50,000+ women',
style: GoogleFonts.plusJakartaSans(
fontSize: 12,
fontWeight: FontWeight.w600,
color: SColors.primary,
),
),
],
),
);
}
Widget _statsRow() {
return Row(
children: [
_statCard('50K+', 'Users'),
const SizedBox(width: 12),
_statCard('4.9★', 'Rating'),
const SizedBox(width: 12),
_statCard('94%', 'See results'),
],
);
}
Widget _statCard(String value, String label) {
return Expanded(
child: Container(
padding: const EdgeInsets.symmetric(vertical: 16),
decoration: BoxDecoration(
color: SColors.surface,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: SColors.border),
),
child: Column(
children: [
Text(
value,
style: GoogleFonts.plusJakartaSans(
fontSize: 20,
fontWeight: FontWeight.w800,
color: SColors.primary,
),
),
const SizedBox(height: 2),
Text(
label,
style: GoogleFonts.plusJakartaSans(
fontSize: 12,
color: SColors.textSecondary,
),
),
],
),
),
);
}
Widget _featureList() {
const items = [
(Icons.auto_awesome_rounded, 'AI-powered analysis', 'Personalised to your skin'),
(Icons.science_rounded, 'Ingredient science', 'Evidence-based recommendations'),
(Icons.lock_outline_rounded, 'Private & secure', 'Your data stays with you'),
];
return Column(
children: items.map((item) => _featureRow(item.$1, item.$2, item.$3)).toList(),
);
}
Widget _featureRow(IconData icon, String title, String sub) {
return Padding(
padding: const EdgeInsets.only(bottom: 12),
child: Row(
children: [
Container(
width: 40,
height: 40,
decoration: BoxDecoration(
color: SColors.primary.withAlpha(18),
borderRadius: BorderRadius.circular(12),
),
child: Icon(icon, color: SColors.primary, size: 20),
),
const SizedBox(width: 12),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: GoogleFonts.plusJakartaSans(
fontSize: 14,
fontWeight: FontWeight.w600,
color: SColors.textPrimary,
),
),
Text(
sub,
style: GoogleFonts.plusJakartaSans(
fontSize: 12,
color: SColors.textSecondary,
),
),
],
),
],
),
);
}
}

View File

@@ -0,0 +1,500 @@
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 '../result_screen.dart';
class QuizData {
String name = '';
String skinType = '';
String age = '';
List<String> concerns = [];
String goal = '';
}
class QuizFlow extends StatefulWidget {
const QuizFlow({super.key});
@override
State<QuizFlow> createState() => _QuizFlowState();
}
class _QuizFlowState extends State<QuizFlow> {
final _data = QuizData();
int _step = 0;
final _pageCtrl = PageController();
final _nameCtrl = TextEditingController();
static const _totalSteps = 5;
void _next() {
if (_step < _totalSteps - 1) {
setState(() => _step++);
_pageCtrl.animateToPage(
_step,
duration: const Duration(milliseconds: 350),
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),
),
);
}
}
void _back() {
if (_step > 0) {
setState(() => _step--);
_pageCtrl.animateToPage(
_step,
duration: const Duration(milliseconds: 300),
curve: Curves.easeOut,
);
} else {
Navigator.of(context).pop();
}
}
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,
};
}
@override
void dispose() {
_nameCtrl.dispose();
_pageCtrl.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: SColors.bgPage,
body: SafeArea(
child: Column(
children: [
_topBar(),
_progressBar(),
Expanded(
child: PageView(
controller: _pageCtrl,
physics: const NeverScrollableScrollPhysics(),
children: [
_NameStep(
ctrl: _nameCtrl,
onChanged: (v) => setState(() => _data.name = v),
),
_SkinTypeStep(
selected: _data.skinType,
onSelect: (v) => setState(() => _data.skinType = v),
),
_AgeStep(
selected: _data.age,
onSelect: (v) => setState(() => _data.age = v),
),
_ConcernsStep(
selected: _data.concerns,
onToggle: (v) => setState(() {
if (_data.concerns.contains(v)) {
_data.concerns.remove(v);
} else if (_data.concerns.length < 3) {
_data.concerns.add(v);
}
}),
),
_GoalStep(
selected: _data.goal,
onSelect: (v) => setState(() => _data.goal = v),
),
],
),
),
_bottomBar(),
],
),
),
);
}
Widget _topBar() {
return Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 0),
child: Row(
children: [
GestureDetector(
onTap: _back,
child: Container(
width: 40,
height: 40,
decoration: BoxDecoration(
color: SColors.surface,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: SColors.border),
),
child: const Icon(Icons.arrow_back_ios_new_rounded, size: 16, color: SColors.textPrimary),
),
),
const Spacer(),
Text(
'${_step + 1} of $_totalSteps',
style: GoogleFonts.plusJakartaSans(
fontSize: 13,
fontWeight: FontWeight.w600,
color: SColors.textSecondary,
),
),
],
),
);
}
Widget _progressBar() {
return Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 0),
child: ClipRRect(
borderRadius: BorderRadius.circular(4),
child: LinearProgressIndicator(
value: (_step + 1) / _totalSteps,
backgroundColor: SColors.border,
valueColor: const AlwaysStoppedAnimation<Color>(SColors.primary),
minHeight: 4,
),
),
);
}
Widget _bottomBar() {
return Padding(
padding: const EdgeInsets.fromLTRB(24, 12, 24, 28),
child: PrimaryButton(
label: _step == _totalSteps - 1 ? 'See my results ✨' : 'Continue',
icon: _step == _totalSteps - 1 ? null : Icons.arrow_forward_rounded,
enabled: _canProceed,
onTap: _next,
),
);
}
}
// ── Step 1: Name ──────────────────────────────────────────────────────────────
class _NameStep extends StatelessWidget {
final TextEditingController ctrl;
final ValueChanged<String> onChanged;
const _NameStep({required this.ctrl, required this.onChanged});
@override
Widget build(BuildContext context) {
return SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(24, 32, 24, 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
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),
const SizedBox(height: 8),
Text(
'I\'ll personalise your routine just for you.',
style: GoogleFonts.plusJakartaSans(
fontSize: 15,
color: SColors.textSecondary,
),
).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),
),
),
).animate().fadeIn(duration: 400.ms, delay: 200.ms),
],
),
);
}
}
// ── Step 2: Skin Type ─────────────────────────────────────────────────────────
class _SkinTypeStep extends StatelessWidget {
final String selected;
final ValueChanged<String> onSelect;
const _SkinTypeStep({required this.selected, required this.onSelect});
static const _options = [
(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'),
(Icons.sentiment_satisfied_alt_rounded, 'Normal', 'Balanced, minimal issues'),
(Icons.help_outline_rounded, 'Not sure', 'Help me figure it out'),
];
@override
Widget build(BuildContext context) {
return SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(24, 32, 24, 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,
),
).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];
return Padding(
padding: const EdgeInsets.only(bottom: 10),
child: QuizOptionRow(
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),
);
}),
],
),
);
}
}
// ── Step 3: Age ───────────────────────────────────────────────────────────────
class _AgeStep extends StatelessWidget {
final String selected;
final ValueChanged<String> onSelect;
const _AgeStep({required this.selected, required this.onSelect});
static const _options = [
(Icons.star_rounded, 'Under 25', 'Young & preventive care'),
(Icons.hourglass_bottom_rounded, '2535', 'Early anti-aging focus'),
(Icons.wb_sunny_rounded, '3545', 'Targeted treatments'),
(Icons.nightlight_round, '4560', 'Deep renewal & firming'),
(Icons.diamond_rounded, '60+', 'Intensive care & radiance'),
];
@override
Widget build(BuildContext context) {
return SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(24, 32, 24, 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,
),
).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];
return Padding(
padding: const EdgeInsets.only(bottom: 10),
child: QuizOptionRow(
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),
);
}),
],
),
);
}
}
// ── Step 4: Concerns ──────────────────────────────────────────────────────────
class _ConcernsStep extends StatelessWidget {
final List<String> selected;
final ValueChanged<String> onToggle;
const _ConcernsStep({required this.selected, required this.onToggle});
static const _options = [
(Icons.face_retouching_natural, 'Breakouts', 'Acne, blackheads, congestion'),
(Icons.cloud_rounded, 'Dullness', 'Tired, uneven, grey skin'),
(Icons.air_rounded, 'Dryness', 'Tight, rough, flaking'),
(Icons.format_align_justify_rounded, 'Fine lines', 'Wrinkles, loss of firmness'),
(Icons.adjust_rounded, 'Dark spots', 'Hyperpigmentation, melasma'),
(Icons.local_fire_department_rounded, 'Redness', 'Sensitivity, flushing, rosacea'),
];
@override
Widget build(BuildContext context) {
return SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(24, 32, 24, 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,
),
).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;
return Padding(
padding: const EdgeInsets.only(bottom: 10),
child: Opacity(
opacity: disabled ? 0.4 : 1.0,
child: QuizOptionRow(
icon: icon,
label: label,
sub: sub,
isSelected: isSelected,
onTap: disabled ? () {} : () => onToggle(label),
),
).animate().fadeIn(duration: 300.ms, delay: (100 + i * 60).ms)
.slideX(begin: 0.05, end: 0, duration: 300.ms),
);
}),
],
),
);
}
}
// ── Step 5: Goal ──────────────────────────────────────────────────────────────
class _GoalStep extends StatelessWidget {
final String selected;
final ValueChanged<String> onSelect;
const _GoalStep({required this.selected, required this.onSelect});
static const _options = [
(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'),
(Icons.palette_rounded, 'Even tone', 'Balanced, bright complexion'),
];
@override
Widget build(BuildContext context) {
return SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(24, 32, 24, 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,
),
).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];
return Padding(
padding: const EdgeInsets.only(bottom: 10),
child: QuizOptionRow(
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),
);
}),
],
),
);
}
}

View File

@@ -0,0 +1,263 @@
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 'quiz/quiz_flow.dart';
class ResultScreen extends StatefulWidget {
final QuizData data;
const ResultScreen({super.key, required this.data});
@override
State<ResultScreen> createState() => _ResultScreenState();
}
class _ResultScreenState extends State<ResultScreen> {
bool _revealed = false;
@override
void initState() {
super.initState();
Future.delayed(const Duration(milliseconds: 1800), () {
if (mounted) setState(() => _revealed = true);
});
}
String get _firstName => widget.data.name.split(' ').first;
@override
Widget build(BuildContext context) {
return Scaffold(
body: _revealed ? _resultView() : _analyzingView(),
);
}
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),
),
),
],
),
),
),
);
}
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),
],
),
),
),
);
}
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)),
),
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,
),
),
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();
return Padding(
padding: const EdgeInsets.only(bottom: 10),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: GoogleFonts.plusJakartaSans(
fontSize: 13,
color: Colors.white.withAlpha(160),
),
),
const SizedBox(width: 12),
Expanded(
child: Text(
value,
textAlign: TextAlign.end,
style: GoogleFonts.plusJakartaSans(
fontSize: 13,
fontWeight: FontWeight.w600,
color: Colors.white,
),
),
),
],
),
);
}
Widget _routinePreview() {
const steps = [
('🌅', 'Morning', 'Cleanser → Vitamin C → SPF 50'),
('🌙', 'Evening', 'Cleanser → Retinol → Moisturiser'),
('', 'Weekly', 'Exfoliant + Hydrating mask'),
];
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)),
),
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,
),
),
const SizedBox(height: 14),
...steps.map((s) => Padding(
padding: const EdgeInsets.only(bottom: 12),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(s.$1, style: const TextStyle(fontSize: 20)),
const SizedBox(width: 12),
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),
),
),
],
),
],
),
)),
],
),
);
}
}

View File

@@ -0,0 +1,108 @@
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';
class SplashScreen extends StatefulWidget {
const SplashScreen({super.key});
@override
State<SplashScreen> createState() => _SplashScreenState();
}
class _SplashScreenState extends State<SplashScreen> {
@override
void initState() {
super.initState();
Future.delayed(const Duration(milliseconds: 2600), _goNext);
}
void _goNext() {
if (!mounted) return;
Navigator.of(context).pushReplacement(
PageRouteBuilder(
pageBuilder: (_, a, __) => const IntroScreen(),
transitionsBuilder: (_, a, __, child) =>
FadeTransition(opacity: a, child: child),
transitionDuration: const Duration(milliseconds: 500),
),
);
}
@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),
),
],
),
),
),
);
}
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),
),
);
}
}

43
lib/theme.dart Normal file
View File

@@ -0,0 +1,43 @@
import 'package:flutter/material.dart';
import 'package:google_fonts/google_fonts.dart';
class SColors {
static const primary = Color(0xFF5B4FCF);
static const primaryLight = Color(0xFF7B70E0);
static const primaryDeep = Color(0xFF3D32A8);
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);
}
ThemeData buildTheme() {
return ThemeData(
colorScheme: ColorScheme.fromSeed(
seedColor: SColors.primary,
brightness: Brightness.light,
),
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,
),
),
useMaterial3: true,
);
}

View File

@@ -0,0 +1,66 @@
import 'package:flutter/material.dart';
import 'package:google_fonts/google_fonts.dart';
import '../theme.dart';
class PrimaryButton extends StatelessWidget {
final String label;
final VoidCallback onTap;
final bool enabled;
final IconData? icon;
const PrimaryButton({
super.key,
required this.label,
required this.onTap,
this.enabled = true,
this.icon,
});
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: enabled ? onTap : null,
child: AnimatedContainer(
duration: const Duration(milliseconds: 200),
height: 56,
decoration: BoxDecoration(
gradient: enabled
? const LinearGradient(
colors: [SColors.primary, SColors.gradientBot],
begin: Alignment.centerLeft,
end: Alignment.centerRight,
)
: null,
color: enabled ? null : SColors.border,
borderRadius: BorderRadius.circular(16),
boxShadow: enabled
? [
BoxShadow(
color: SColors.primary.withAlpha(60),
blurRadius: 16,
offset: const Offset(0, 6),
)
]
: [],
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
label,
style: GoogleFonts.plusJakartaSans(
fontSize: 16,
fontWeight: FontWeight.w700,
color: enabled ? Colors.white : SColors.textSecondary,
),
),
if (icon != null) ...[
const SizedBox(width: 8),
Icon(icon, color: enabled ? Colors.white : SColors.textSecondary, size: 18),
],
],
),
),
);
}
}

View File

@@ -0,0 +1,109 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:google_fonts/google_fonts.dart';
import '../theme.dart';
class QuizOptionRow extends StatelessWidget {
final IconData icon;
final String label;
final String sub;
final bool isSelected;
final VoidCallback onTap;
const QuizOptionRow({
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: 200),
curve: Curves.easeOut,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
decoration: BoxDecoration(
color: isSelected ? SColors.primary.withAlpha(13) : SColors.surface,
borderRadius: BorderRadius.circular(18),
border: Border.all(
color: isSelected ? SColors.primary.withAlpha(128) : SColors.border,
width: 1.5,
),
),
child: Row(
children: [
AnimatedContainer(
duration: const Duration(milliseconds: 200),
width: 52,
height: 52,
decoration: BoxDecoration(
color: isSelected
? SColors.primary.withAlpha(26)
: SColors.skyTint.withAlpha(140),
borderRadius: BorderRadius.circular(14),
),
child: Icon(
icon,
size: 26,
color: isSelected ? SColors.primary : SColors.textSecondary,
),
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: GoogleFonts.plusJakartaSans(
fontSize: 16,
fontWeight: FontWeight.w600,
color: isSelected ? SColors.primary : SColors.textPrimary,
),
),
if (sub.isNotEmpty) ...[
const SizedBox(height: 2),
Text(
sub,
style: GoogleFonts.plusJakartaSans(
fontSize: 13,
color: SColors.textSecondary,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
],
),
),
const SizedBox(width: 12),
AnimatedContainer(
duration: const Duration(milliseconds: 200),
width: 22,
height: 22,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: isSelected ? SColors.primary : Colors.transparent,
border: Border.all(
color: isSelected ? SColors.primary : SColors.border,
width: 1.5,
),
),
child: isSelected
? const Icon(Icons.check, size: 13, color: Colors.white)
: null,
),
],
),
),
);
}
}