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:
BIN
assets/images/shaynee_avatar.png
Normal file
BIN
assets/images/shaynee_avatar.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 240 KiB |
BIN
assets/images/shaynee_hero.png
Normal file
BIN
assets/images/shaynee_hero.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 255 KiB |
125
lib/main.dart
125
lib/main.dart
@@ -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(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
226
lib/screens/intro_screen.dart
Normal file
226
lib/screens/intro_screen.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
500
lib/screens/quiz/quiz_flow.dart
Normal file
500
lib/screens/quiz/quiz_flow.dart
Normal 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, '25–35', 'Early anti-aging focus'),
|
||||
(Icons.wb_sunny_rounded, '35–45', 'Targeted treatments'),
|
||||
(Icons.nightlight_round, '45–60', '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),
|
||||
);
|
||||
}),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
263
lib/screens/result_screen.dart
Normal file
263
lib/screens/result_screen.dart
Normal 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),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
108
lib/screens/splash_screen.dart
Normal file
108
lib/screens/splash_screen.dart
Normal 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
43
lib/theme.dart
Normal 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,
|
||||
);
|
||||
}
|
||||
66
lib/widgets/primary_button.dart
Normal file
66
lib/widgets/primary_button.dart
Normal 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),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
109
lib/widgets/quiz_option_row.dart
Normal file
109
lib/widgets/quiz_option_row.dart
Normal 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,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
234
pubspec.lock
234
pubspec.lock
@@ -1,6 +1,14 @@
|
||||
# Generated by pub
|
||||
# See https://dart.dev/tools/pub/glossary#lockfile
|
||||
packages:
|
||||
args:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: args
|
||||
sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.7.0"
|
||||
async:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -33,6 +41,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.2"
|
||||
code_assets:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: code_assets
|
||||
sha256: "67cf6d84013f9c601e42a6f8a6b74c4c0d9dc1a1619d775f2b28b732d3551b85"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.2.0"
|
||||
collection:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -41,6 +57,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.19.1"
|
||||
crypto:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: crypto
|
||||
sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.0.7"
|
||||
cupertino_icons:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -57,11 +81,27 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.3.3"
|
||||
ffi:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: ffi
|
||||
sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.2.0"
|
||||
flutter:
|
||||
dependency: "direct main"
|
||||
description: flutter
|
||||
source: sdk
|
||||
version: "0.0.0"
|
||||
flutter_animate:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: flutter_animate
|
||||
sha256: "7befe2d3252728afb77aecaaea1dec88a89d35b9b1d2eea6d04479e8af9117b5"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.5.2"
|
||||
flutter_lints:
|
||||
dependency: "direct dev"
|
||||
description:
|
||||
@@ -70,11 +110,67 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.0.0"
|
||||
flutter_shaders:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_shaders
|
||||
sha256: "34794acadd8275d971e02df03afee3dee0f98dbfb8c4837082ad0034f612a3e2"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.1.3"
|
||||
flutter_test:
|
||||
dependency: "direct dev"
|
||||
description: flutter
|
||||
source: sdk
|
||||
version: "0.0.0"
|
||||
google_fonts:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: google_fonts
|
||||
sha256: ba03d03bcaa2f6cb7bd920e3b5027181db75ab524f8891c8bc3aa603885b8055
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.3.3"
|
||||
hooks:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: hooks
|
||||
sha256: a41af4e8fc687cd6d33de9751eb936c8c0204ebe2bcb6c15ecf707504bf47f31
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.0.0"
|
||||
http:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: http
|
||||
sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.6.0"
|
||||
http_parser:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: http_parser
|
||||
sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.1.2"
|
||||
jni:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: jni
|
||||
sha256: c2230682d5bc2362c1c9e8d3c7f406d9cbba23ab3f2e203a025dd47e0fb2e68f
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.0"
|
||||
jni_flutter:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: jni_flutter
|
||||
sha256: "8b59e590786050b1cd866677dddaf76b1ade5e7bc751abe04b86e84d379d3ba6"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.1"
|
||||
leak_tracker:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -107,6 +203,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.1.0"
|
||||
logging:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: logging
|
||||
sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.3.0"
|
||||
matcher:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -131,6 +235,22 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.18.0"
|
||||
objective_c:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: objective_c
|
||||
sha256: "6cb691c686fa2838c6deb34980d426145c2a5d537491cb83d463c33cdbc726ed"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "9.4.1"
|
||||
package_config:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: package_config
|
||||
sha256: f096c55ebb7deb7e384101542bfba8c52696c1b56fca2eb62827989ef2353bbc
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.2.0"
|
||||
path:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -139,6 +259,86 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.9.1"
|
||||
path_provider:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: path_provider
|
||||
sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.5"
|
||||
path_provider_android:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: path_provider_android
|
||||
sha256: "69cbd515a62b94d32a7944f086b2f82b4ac40a1d45bebfc00813a430ab2dabcd"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.3.1"
|
||||
path_provider_foundation:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: path_provider_foundation
|
||||
sha256: "2a376b7d6392d80cd3705782d2caa734ca4727776db0b6ec36ef3f1855197699"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.6.0"
|
||||
path_provider_linux:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: path_provider_linux
|
||||
sha256: f7a1fe3a634fe7734c8d3f2766ad746ae2a2884abe22e241a8b301bf5cac3279
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.2.1"
|
||||
path_provider_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: path_provider_platform_interface
|
||||
sha256: "88f5779f72ba699763fa3a3b06aa4bf6de76c8e5de842cf6f29e2e06476c2334"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.2"
|
||||
path_provider_windows:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: path_provider_windows
|
||||
sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.3.0"
|
||||
platform:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: platform
|
||||
sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.1.6"
|
||||
plugin_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: plugin_platform_interface
|
||||
sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.8"
|
||||
pub_semver:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: pub_semver
|
||||
sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.2.0"
|
||||
record_use:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: record_use
|
||||
sha256: "2551bd8eecfe95d14ae75f6021ad0248be5c27f138c2ec12fcb52b500b3ba1ed"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.6.0"
|
||||
sky_engine:
|
||||
dependency: transitive
|
||||
description: flutter
|
||||
@@ -192,6 +392,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.7.11"
|
||||
typed_data:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: typed_data
|
||||
sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.4.0"
|
||||
vector_math:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -208,6 +416,30 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "15.2.0"
|
||||
web:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: web
|
||||
sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.1"
|
||||
xdg_directories:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: xdg_directories
|
||||
sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.0"
|
||||
yaml:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: yaml
|
||||
sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.1.3"
|
||||
sdks:
|
||||
dart: ">=3.12.0 <4.0.0"
|
||||
flutter: ">=3.18.0-18.0.pre.54"
|
||||
flutter: ">=3.38.4"
|
||||
|
||||
37
pubspec.yaml
37
pubspec.yaml
@@ -30,10 +30,9 @@ environment:
|
||||
dependencies:
|
||||
flutter:
|
||||
sdk: flutter
|
||||
|
||||
# The following adds the Cupertino Icons font to your application.
|
||||
# Use with the CupertinoIcons class for iOS style icons.
|
||||
cupertino_icons: ^1.0.8
|
||||
google_fonts: ^6.2.1
|
||||
flutter_animate: ^4.5.0
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
@@ -57,33 +56,5 @@ flutter:
|
||||
# the material Icons class.
|
||||
uses-material-design: true
|
||||
|
||||
# To add assets to your application, add an assets section, like this:
|
||||
# assets:
|
||||
# - images/a_dot_burr.jpeg
|
||||
# - images/a_dot_ham.jpeg
|
||||
|
||||
# An image asset can refer to one or more resolution-specific "variants", see
|
||||
# https://flutter.dev/to/resolution-aware-images
|
||||
|
||||
# For details regarding adding assets from package dependencies, see
|
||||
# https://flutter.dev/to/asset-from-package
|
||||
|
||||
# To add custom fonts to your application, add a fonts section here,
|
||||
# in this "flutter" section. Each entry in this list should have a
|
||||
# "family" key with the font family name, and a "fonts" key with a
|
||||
# list giving the asset and other descriptors for the font. For
|
||||
# example:
|
||||
# fonts:
|
||||
# - family: Schyler
|
||||
# fonts:
|
||||
# - asset: fonts/Schyler-Regular.ttf
|
||||
# - asset: fonts/Schyler-Italic.ttf
|
||||
# style: italic
|
||||
# - family: Trajan Pro
|
||||
# fonts:
|
||||
# - asset: fonts/TrajanPro.ttf
|
||||
# - asset: fonts/TrajanPro_Bold.ttf
|
||||
# weight: 700
|
||||
#
|
||||
# For details regarding fonts from package dependencies,
|
||||
# see https://flutter.dev/to/font-from-package
|
||||
assets:
|
||||
- assets/images/
|
||||
|
||||
Reference in New Issue
Block a user