completed group batch feature
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:qpay/models/responsive_policy.dart';
|
||||
import 'package:qpay/screens/onboarding/widgets/onboarding_layout.dart';
|
||||
|
||||
class CompleteScreen extends StatefulWidget {
|
||||
const CompleteScreen({super.key});
|
||||
@@ -12,65 +12,77 @@ class CompleteScreen extends StatefulWidget {
|
||||
class _CompleteScreenState extends State<CompleteScreen> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
body: SingleChildScrollView(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(25),
|
||||
child: Center(
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final maxWidth = constraints.maxWidth > ResponsivePolicy.md
|
||||
? ResponsivePolicy.md.toDouble()
|
||||
: constraints.maxWidth;
|
||||
return SizedBox(
|
||||
width: maxWidth,
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
SizedBox(height: 75),
|
||||
Text(
|
||||
'Registration Complete',
|
||||
style: TextStyle(
|
||||
fontSize: 30,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
),
|
||||
SizedBox(height: 5),
|
||||
Text(
|
||||
'Thank you for registering with Velocity payments',
|
||||
style: TextStyle(fontSize: 18),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
Image.asset('assets/landing.png', width: 300),
|
||||
Text(
|
||||
'Tap Login to get started',
|
||||
style: TextStyle(fontSize: 18),
|
||||
),
|
||||
SizedBox(height: 30),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 25),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: ElevatedButton(
|
||||
onPressed: () {
|
||||
context.go('/onboarding/login');
|
||||
},
|
||||
child: Text('Go to Login', style: TextStyle(fontSize: 16)),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
SizedBox(height: 5),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
return OnboardingLayout(
|
||||
// No back button - the user shouldn't be able to go back to the
|
||||
// verification step after their account is fully set up.
|
||||
showBackButton: false,
|
||||
child: OnboardingCard(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
// Success badge - green to differentiate from the brand primary.
|
||||
Container(
|
||||
padding: const EdgeInsets.all(20),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.green.withValues(alpha: 0.12),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.check_circle_rounded,
|
||||
color: Colors.green,
|
||||
size: 56,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
Text(
|
||||
"You're all set!",
|
||||
textAlign: TextAlign.center,
|
||||
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
|
||||
fontWeight: FontWeight.w800,
|
||||
color: colorScheme.onSurface,
|
||||
letterSpacing: -0.5,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Welcome to Velocity. Your account is ready — sign in to start sending and receiving payments.',
|
||||
textAlign: TextAlign.center,
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: colorScheme.onSurface.withValues(alpha: 0.65),
|
||||
height: 1.45,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 28),
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: colorScheme.primary.withValues(alpha: 0.2),
|
||||
blurRadius: 24,
|
||||
offset: const Offset(0, 12),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
child: Image.asset(
|
||||
'assets/complete.png',
|
||||
width: 180,
|
||||
fit: BoxFit.contain,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
OnboardingPrimaryButton(
|
||||
label: 'Continue to sign in',
|
||||
icon: Icons.arrow_forward_rounded,
|
||||
onPressed: () {
|
||||
context.go('/onboarding/login');
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:qpay/models/responsive_policy.dart';
|
||||
import 'package:qpay/screens/onboarding/login/login_controller.dart';
|
||||
import 'package:qpay/screens/onboarding/onboarding_controller.dart';
|
||||
|
||||
class LandingScreen extends StatefulWidget {
|
||||
const LandingScreen({super.key});
|
||||
@@ -11,90 +12,645 @@ class LandingScreen extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _LandingScreenState extends State<LandingScreen> {
|
||||
late OnboardingController onboardingController;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
onboardingController = Provider.of<OnboardingController>(
|
||||
context,
|
||||
listen: false,
|
||||
);
|
||||
}
|
||||
|
||||
void _goToRegister() {
|
||||
// Reset the registration state so a fresh sign-up starts cleanly.
|
||||
onboardingController.resetState();
|
||||
context.go('/onboarding/phone');
|
||||
}
|
||||
|
||||
// Max content width on large screens (desktop / web).
|
||||
static const double _maxContentWidth = 1200;
|
||||
// Max content width on tablet — the current mobile cap lifted slightly
|
||||
// so the page breathes a bit on landscape phones / small tablets.
|
||||
static const double _maxTabletWidth = 760;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final colorScheme = theme.colorScheme;
|
||||
final width = MediaQuery.of(context).size.width;
|
||||
final isTablet = width >= ResponsivePolicy.md;
|
||||
final isDesktop = width >= ResponsivePolicy.lg;
|
||||
|
||||
return Scaffold(
|
||||
body: SingleChildScrollView(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(25),
|
||||
child: Center(
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final maxWidth = constraints.maxWidth > ResponsivePolicy.md
|
||||
? ResponsivePolicy.md.toDouble()
|
||||
: constraints.maxWidth;
|
||||
return SizedBox(
|
||||
width: maxWidth,
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
SizedBox(height: 75),
|
||||
Text(
|
||||
'Welcome to Velocity',
|
||||
style: TextStyle(
|
||||
fontSize: 30,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
),
|
||||
SizedBox(height: 5),
|
||||
Text(
|
||||
'The fastest way to pay for local goods and services',
|
||||
style: TextStyle(fontSize: 18),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
Image.asset('assets/landing.png', width: 300),
|
||||
Image.asset('assets/velocity.png', width: 150),
|
||||
SizedBox(height: 5),
|
||||
Text(
|
||||
'Register or login to get started',
|
||||
style: TextStyle(fontSize: 16),
|
||||
),
|
||||
SizedBox(height: 50),
|
||||
Column(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 25),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: ElevatedButton(
|
||||
onPressed: () {
|
||||
context.go('/onboarding/register');
|
||||
},
|
||||
child: Text('Register', style: TextStyle(fontSize: 16)),
|
||||
),
|
||||
),
|
||||
SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: OutlinedButton(
|
||||
onPressed: () {
|
||||
context.go('/onboarding/login');
|
||||
},
|
||||
child: Text('Login', style: TextStyle(fontSize: 16)),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
SizedBox(height: 10),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
context.go('/');
|
||||
},
|
||||
child: Text('Continue as guest?', style: TextStyle(fontSize: 14)),
|
||||
),
|
||||
],
|
||||
)
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
// Use a Stack so the gradient background is guaranteed to cover the
|
||||
// full viewport (including the area under the SafeArea) regardless
|
||||
// of how tall the content ends up being.
|
||||
body: Stack(
|
||||
children: [
|
||||
Positioned.fill(
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
colors: [
|
||||
colorScheme.primary.withValues(alpha: 0.08),
|
||||
colorScheme.surface,
|
||||
colorScheme.secondary.withValues(alpha: 0.05),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
SafeArea(
|
||||
child: SingleChildScrollView(
|
||||
child: Center(
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: _maxContentWidth),
|
||||
child: Padding(
|
||||
padding: EdgeInsets.symmetric(
|
||||
horizontal: isDesktop ? 48 : 20,
|
||||
vertical: isDesktop ? 32 : 16,
|
||||
),
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final contentMaxWidth = isDesktop
|
||||
? _maxContentWidth
|
||||
: (isTablet
|
||||
? _maxTabletWidth
|
||||
: constraints.maxWidth);
|
||||
return SizedBox(
|
||||
width: contentMaxWidth,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
const SizedBox(height: 12),
|
||||
_buildTopBar(theme, colorScheme),
|
||||
SizedBox(height: isDesktop ? 40 : 24),
|
||||
_buildHero(theme, colorScheme, isDesktop),
|
||||
SizedBox(height: isDesktop ? 72 : 36),
|
||||
_buildValuePropsHeadline(theme, colorScheme),
|
||||
const SizedBox(height: 28),
|
||||
_buildValueProps(isTablet),
|
||||
SizedBox(height: isDesktop ? 64 : 32),
|
||||
_buildCallToActions(
|
||||
theme,
|
||||
colorScheme,
|
||||
isDesktop,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
context.go('/');
|
||||
},
|
||||
child: Text(
|
||||
'Continue as guest?',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: colorScheme.onSurface.withValues(
|
||||
alpha: 0.7,
|
||||
),
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
_buildFooter(theme, colorScheme),
|
||||
const SizedBox(height: 12),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTopBar(ThemeData theme, ColorScheme colorScheme) {
|
||||
return Row(
|
||||
children: [
|
||||
Image(image: AssetImage('assets/velocity.png'), width: 150),
|
||||
const Spacer(),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
context.go('/onboarding/login');
|
||||
},
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor: colorScheme.primary,
|
||||
textStyle: const TextStyle(
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
child: const Text('Sign in'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildHero(ThemeData theme, ColorScheme colorScheme, bool isDesktop) {
|
||||
final textBlock = Column(
|
||||
crossAxisAlignment: isDesktop
|
||||
? CrossAxisAlignment.start
|
||||
: CrossAxisAlignment.center,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: isDesktop
|
||||
? Colors.white.withValues(alpha: 0.18)
|
||||
: colorScheme.primary.withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
border: isDesktop
|
||||
? Border.all(color: Colors.white.withValues(alpha: 0.25))
|
||||
: null,
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.flash_on_rounded,
|
||||
size: 14,
|
||||
color: isDesktop ? Colors.white : colorScheme.primary,
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
'Fast. Secure. Local.',
|
||||
style: theme.textTheme.labelMedium?.copyWith(
|
||||
color: isDesktop ? Colors.white : colorScheme.primary,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: 0.4,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
SizedBox(height: isDesktop ? 20 : 16),
|
||||
Text(
|
||||
'Welcome to Velocity',
|
||||
textAlign: isDesktop ? TextAlign.start : TextAlign.center,
|
||||
style:
|
||||
(isDesktop
|
||||
? theme.textTheme.displaySmall
|
||||
: theme.textTheme.headlineMedium)
|
||||
?.copyWith(
|
||||
color: isDesktop ? null : colorScheme.primary,
|
||||
fontWeight: FontWeight.w800,
|
||||
letterSpacing: -0.5,
|
||||
height: 1.1,
|
||||
),
|
||||
),
|
||||
SizedBox(height: isDesktop ? 16 : 8),
|
||||
Text(
|
||||
'The fastest way to pay for local goods and services.',
|
||||
textAlign: isDesktop ? TextAlign.start : TextAlign.center,
|
||||
style: theme.textTheme.titleMedium?.copyWith(
|
||||
color: isDesktop
|
||||
? colorScheme.onSurface.withValues(alpha: 0.75)
|
||||
: colorScheme.onSurface.withValues(alpha: 0.8),
|
||||
height: 1.4,
|
||||
fontWeight: FontWeight.w400,
|
||||
),
|
||||
),
|
||||
if (isDesktop) ...[
|
||||
const SizedBox(height: 28),
|
||||
Wrap(
|
||||
spacing: 12,
|
||||
runSpacing: 12,
|
||||
children: [
|
||||
_buildHeroCta(context, colorScheme),
|
||||
_buildHeroSecondaryCta(context, colorScheme),
|
||||
],
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
|
||||
final imageBlock = ClipRRect(
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: colorScheme.primary.withValues(alpha: 0.25),
|
||||
blurRadius: 32,
|
||||
offset: const Offset(0, 18),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Image.asset(
|
||||
'assets/signup.png',
|
||||
width: isDesktop ? 360 : 260,
|
||||
fit: BoxFit.contain,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
if (isDesktop) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(40),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(32),
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
colors: [
|
||||
colorScheme.primary.withValues(alpha: 0.06),
|
||||
colorScheme.secondary.withValues(alpha: 0.04),
|
||||
],
|
||||
),
|
||||
border: Border.all(
|
||||
color: colorScheme.onSurface.withValues(alpha: 0.06),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Expanded(flex: 5, child: textBlock),
|
||||
const SizedBox(width: 32),
|
||||
Expanded(flex: 4, child: Center(child: imageBlock)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Mobile / tablet — original vertical hero with gradient background.
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(vertical: 32, horizontal: 20),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(28),
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
colors: [colorScheme.primary, colorScheme.secondary],
|
||||
),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: colorScheme.primary.withValues(alpha: 0.25),
|
||||
blurRadius: 24,
|
||||
offset: const Offset(0, 12),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withValues(alpha: 0.15),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.bolt_rounded,
|
||||
color: Colors.white,
|
||||
size: 44,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'Welcome to Velocity',
|
||||
textAlign: TextAlign.center,
|
||||
style: theme.textTheme.headlineMedium?.copyWith(
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.w800,
|
||||
letterSpacing: -0.5,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'The fastest way to pay for local goods\nand services',
|
||||
textAlign: TextAlign.center,
|
||||
style: theme.textTheme.bodyLarge?.copyWith(
|
||||
color: Colors.white.withValues(alpha: 0.92),
|
||||
height: 1.4,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
imageBlock,
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildHeroCta(BuildContext context, ColorScheme colorScheme) {
|
||||
return SizedBox(
|
||||
height: 52,
|
||||
child: ElevatedButton(
|
||||
onPressed: _goToRegister,
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: colorScheme.primary,
|
||||
foregroundColor: Colors.white,
|
||||
elevation: 0,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 28),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
),
|
||||
textStyle: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: 0.3,
|
||||
),
|
||||
),
|
||||
child: const Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text('Create Free Account'),
|
||||
SizedBox(width: 8),
|
||||
Icon(Icons.arrow_forward_rounded, size: 20),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildHeroSecondaryCta(BuildContext context, ColorScheme colorScheme) {
|
||||
return SizedBox(
|
||||
height: 52,
|
||||
child: OutlinedButton(
|
||||
onPressed: () {
|
||||
context.go('/onboarding/login');
|
||||
},
|
||||
style: OutlinedButton.styleFrom(
|
||||
side: BorderSide(color: colorScheme.primary, width: 1.5),
|
||||
foregroundColor: colorScheme.primary,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 28),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
),
|
||||
textStyle: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: 0.3,
|
||||
),
|
||||
),
|
||||
child: const Text('Sign in'),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildValuePropsHeadline(ThemeData theme, ColorScheme colorScheme) {
|
||||
return Column(
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.primary.withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: Text(
|
||||
'Why register?',
|
||||
style: theme.textTheme.labelLarge?.copyWith(
|
||||
color: colorScheme.primary,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: 0.5,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
Text(
|
||||
'Unlock the full Velocity experience',
|
||||
textAlign: TextAlign.center,
|
||||
style: theme.textTheme.headlineSmall?.copyWith(
|
||||
color: colorScheme.onSurface,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 600),
|
||||
child: Text(
|
||||
'Create a free account to access powerful features designed to make your payments effortless.',
|
||||
textAlign: TextAlign.center,
|
||||
style: theme.textTheme.bodyLarge?.copyWith(
|
||||
color: colorScheme.onSurface.withValues(alpha: 0.65),
|
||||
height: 1.45,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildValueProps(bool isWide) {
|
||||
final props = const [
|
||||
_ValueProp(
|
||||
icon: Icons.groups_2_rounded,
|
||||
title: 'Batch Pay Groups',
|
||||
description:
|
||||
'Send payments to many recipients at once. Perfect for splitting bills, paying teams, or settling group purchases in a single tap.',
|
||||
accent: Color(0xFF9D6711),
|
||||
),
|
||||
_ValueProp(
|
||||
icon: Icons.devices_rounded,
|
||||
title: 'Access Anywhere',
|
||||
description:
|
||||
'Your recipients and full transaction history sync seamlessly across all your devices — pick up right where you left off.',
|
||||
accent: Color(0xFF8B0000),
|
||||
),
|
||||
_ValueProp(
|
||||
icon: Icons.account_balance_wallet_rounded,
|
||||
title: 'Prefund Your Wallet',
|
||||
description:
|
||||
'Top up your wallet today and use the balance whenever you need it. No rushing, no last-minute transfers.',
|
||||
accent: Color(0xFFB7791F),
|
||||
),
|
||||
];
|
||||
|
||||
if (isWide) {
|
||||
return IntrinsicHeight(
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: props
|
||||
.map(
|
||||
(p) => Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
child: p,
|
||||
),
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return Column(
|
||||
children: props
|
||||
.map(
|
||||
(p) =>
|
||||
Padding(padding: const EdgeInsets.only(bottom: 12), child: p),
|
||||
)
|
||||
.toList(),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildCallToActions(
|
||||
ThemeData theme,
|
||||
ColorScheme colorScheme,
|
||||
bool isDesktop,
|
||||
) {
|
||||
final registerButton = SizedBox(
|
||||
width: double.infinity,
|
||||
height: 54,
|
||||
child: ElevatedButton(
|
||||
onPressed: _goToRegister,
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: colorScheme.primary,
|
||||
foregroundColor: Colors.white,
|
||||
elevation: 0,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
),
|
||||
textStyle: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: 0.3,
|
||||
),
|
||||
),
|
||||
child: const Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text('Create Free Account'),
|
||||
SizedBox(width: 8),
|
||||
Icon(Icons.arrow_forward_rounded, size: 20),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
final loginButton = SizedBox(
|
||||
width: double.infinity,
|
||||
height: 54,
|
||||
child: OutlinedButton(
|
||||
onPressed: () {
|
||||
context.go('/onboarding/login');
|
||||
},
|
||||
style: OutlinedButton.styleFrom(
|
||||
side: BorderSide(color: colorScheme.primary, width: 1.5),
|
||||
foregroundColor: colorScheme.primary,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
),
|
||||
textStyle: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: 0.3,
|
||||
),
|
||||
),
|
||||
child: const Text('I already have an account'),
|
||||
),
|
||||
);
|
||||
|
||||
if (isDesktop) {
|
||||
return Center(
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 520),
|
||||
child: Column(
|
||||
children: [registerButton, const SizedBox(height: 12), loginButton],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return Column(
|
||||
children: [registerButton, const SizedBox(height: 12), loginButton],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildFooter(ThemeData theme, ColorScheme colorScheme) {
|
||||
return Center(
|
||||
child: Text(
|
||||
'© Velocity · Fast, secure local payments',
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onSurface.withValues(alpha: 0.45),
|
||||
letterSpacing: 0.2,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ValueProp extends StatelessWidget {
|
||||
const _ValueProp({
|
||||
required this.icon,
|
||||
required this.title,
|
||||
required this.description,
|
||||
required this.accent,
|
||||
});
|
||||
|
||||
final IconData icon;
|
||||
final String title;
|
||||
final String description;
|
||||
final Color accent;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final colorScheme = theme.colorScheme;
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(20),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.surface,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
border: Border.all(
|
||||
color: colorScheme.onSurface.withValues(alpha: 0.06),
|
||||
width: 1,
|
||||
),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: 0.04),
|
||||
blurRadius: 16,
|
||||
offset: const Offset(0, 4),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: accent.withValues(alpha: 0.12),
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
),
|
||||
child: Icon(icon, color: accent, size: 26),
|
||||
),
|
||||
const SizedBox(width: 14),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
title,
|
||||
style: theme.textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
color: colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
description,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: colorScheme.onSurface.withValues(alpha: 0.68),
|
||||
height: 1.5,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:qpay/auth_state.dart';
|
||||
import 'package:qpay/http/http.dart';
|
||||
import 'package:qpay/models/api_response.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
@@ -60,15 +61,22 @@ class LoginController extends ChangeNotifier {
|
||||
prefs.setString('username', username);
|
||||
prefs.setString('email', response['email']);
|
||||
prefs.setString('firstName', response['firstName']);
|
||||
prefs.setString('lastName', response['lastName']);
|
||||
prefs.setString('lastName', response['lastName'] ?? '');
|
||||
prefs.setString('phone', response['phone']);
|
||||
prefs.setString('userId', response['uuid']);
|
||||
prefs.setString(
|
||||
'initials',
|
||||
response['firstName'].substring(0, 1) +
|
||||
response['lastName'].substring(0, 1),
|
||||
(response['lastName'] != null && response['lastName'].isNotEmpty
|
||||
? response['lastName'].substring(0, 1)
|
||||
: ''),
|
||||
);
|
||||
|
||||
// Notify the router that the user is now signed in so any
|
||||
// auth-gated redirects (e.g. the groups flow) are re-evaluated
|
||||
// and the user is sent to their originally requested page.
|
||||
await authState.refresh();
|
||||
|
||||
return ApiResponse.success(model);
|
||||
} else {
|
||||
model.errorMessage =
|
||||
|
||||
@@ -1,15 +1,10 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_dotenv/flutter_dotenv.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:google_sign_in/google_sign_in.dart';
|
||||
import 'package:google_sign_in_web/web_only.dart' as web;
|
||||
import 'package:qpay/models/responsive_policy.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:qpay/screens/onboarding/login/login_controller.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:qpay/screens/onboarding/onboarding_controller.dart';
|
||||
import 'package:qpay/screens/onboarding/widgets/onboarding_layout.dart';
|
||||
import 'package:qpay/widgets/app_snack_bar.dart';
|
||||
import 'package:skeletonizer/skeletonizer.dart';
|
||||
|
||||
class LoginScreen extends StatefulWidget {
|
||||
@@ -20,242 +15,151 @@ class LoginScreen extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _LoginScreenState extends State<LoginScreen> {
|
||||
late SharedPreferences prefs;
|
||||
late GoogleSignIn googleSignIn;
|
||||
late OnboardingController onboardingController;
|
||||
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
final TextEditingController _emailController = TextEditingController();
|
||||
final TextEditingController _passwordController = TextEditingController();
|
||||
|
||||
bool _obscurePassword = true;
|
||||
bool _loading = false;
|
||||
|
||||
List<String> scopes = <String>[
|
||||
'https://www.googleapis.com/auth/contacts.readonly',
|
||||
];
|
||||
|
||||
late LoginController _loginController;
|
||||
StreamSubscription<GoogleSignInAuthenticationEvent>? _authSubscription;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loginController = LoginController(context);
|
||||
onboardingController = Provider.of<OnboardingController>(
|
||||
context,
|
||||
listen: false,
|
||||
);
|
||||
|
||||
googleSignIn = GoogleSignIn.instance;
|
||||
if (kIsWeb) {
|
||||
googleSignIn.initialize();
|
||||
_authSubscription = googleSignIn.authenticationEvents
|
||||
.handleError(_handleAuthenticationError)
|
||||
.listen(_handleAuthenticationEvent);
|
||||
} else {
|
||||
unawaited(
|
||||
googleSignIn.initialize(serverClientId: dotenv.env['CLIENTID']),
|
||||
);
|
||||
// Pre-fill email if it was captured during registration.
|
||||
final capturedEmail = onboardingController.model.email;
|
||||
if (capturedEmail != null && capturedEmail.isNotEmpty) {
|
||||
_emailController.text = capturedEmail;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> signInWithGoogle() async {
|
||||
if (GoogleSignIn.instance.supportsAuthenticate()) {
|
||||
_authSubscription?.cancel();
|
||||
unawaited(
|
||||
GoogleSignIn.instance.authenticate().then((value) async {
|
||||
_authSubscription = googleSignIn.authenticationEvents
|
||||
.handleError(_handleAuthenticationError)
|
||||
.listen(_handleAuthenticationEvent);
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void saveUser(GoogleSignInAccount user) async {
|
||||
final Map<String, dynamic> data = <String, dynamic>{
|
||||
'displayName': user.displayName,
|
||||
'email': user.email,
|
||||
'id': user.id,
|
||||
'photoUrl': user.photoUrl,
|
||||
};
|
||||
|
||||
prefs = await SharedPreferences.getInstance();
|
||||
if (prefs.getString("googleUser") == null) {
|
||||
prefs.setString("googleUser", jsonEncode(data));
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _handleAuthenticationEvent(
|
||||
GoogleSignInAuthenticationEvent event,
|
||||
) async {
|
||||
if (!mounted) return;
|
||||
|
||||
// #docregion CheckAuthorization
|
||||
final GoogleSignInAccount? user = // ...
|
||||
// #enddocregion CheckAuthorization
|
||||
switch (event) {
|
||||
GoogleSignInAuthenticationEventSignIn() => event.user,
|
||||
GoogleSignInAuthenticationEventSignOut() => null,
|
||||
};
|
||||
|
||||
if (user == null || !mounted) return;
|
||||
|
||||
// Check for existing authorization.
|
||||
// #docregion CheckAuthorization
|
||||
final GoogleSignInClientAuthorization? authorization = await user
|
||||
?.authorizationClient
|
||||
.authorizationForScopes(scopes);
|
||||
// #enddocregion CheckAuthorization
|
||||
|
||||
print(user);
|
||||
saveUser(user);
|
||||
|
||||
if (!mounted) return;
|
||||
|
||||
final response = await _loginController.login(user.email, user.id);
|
||||
if (response.isSuccess) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text("Login successful!"),
|
||||
behavior: SnackBarBehavior.floating,
|
||||
backgroundColor: Colors.green,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(response.error ?? "Login failed"),
|
||||
behavior: SnackBarBehavior.floating,
|
||||
backgroundColor: Colors.deepOrange,
|
||||
),
|
||||
);
|
||||
}
|
||||
if (!mounted) return;
|
||||
context.go('/');
|
||||
}
|
||||
|
||||
Future<void> _handleAuthenticationError(Object e) async {
|
||||
print(e.toString());
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_authSubscription?.cancel();
|
||||
_emailController.dispose();
|
||||
_passwordController.dispose();
|
||||
_loginController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _onLogin() async {
|
||||
if (!(_formKey.currentState?.validate() ?? false)) return;
|
||||
|
||||
final email = _emailController.text.trim();
|
||||
final password = _passwordController.text;
|
||||
|
||||
// Cache credentials in the onboarding model for use by the rest
|
||||
// of the registration/login flow.
|
||||
onboardingController.updateEmail(email);
|
||||
onboardingController.updatePassword(password);
|
||||
|
||||
setState(() => _loading = true);
|
||||
|
||||
final response = await _loginController.login(email, password);
|
||||
|
||||
if (!mounted) return;
|
||||
|
||||
setState(() => _loading = false);
|
||||
|
||||
if (response.isSuccess) {
|
||||
AppSnackBar.showSuccess(context, 'Login successful!');
|
||||
} else {
|
||||
AppSnackBar.showError(context, response.error ?? 'Login failed');
|
||||
}
|
||||
|
||||
if (!mounted) return;
|
||||
|
||||
if (response.isSuccess) {
|
||||
context.go('/');
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
body: SingleChildScrollView(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(25),
|
||||
child: Form(
|
||||
key: _formKey,
|
||||
child: Center(
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final maxWidth = constraints.maxWidth > ResponsivePolicy.md
|
||||
? ResponsivePolicy.md.toDouble()
|
||||
: constraints.maxWidth;
|
||||
return SizedBox(
|
||||
width: maxWidth,
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
SizedBox(height: 75),
|
||||
Text(
|
||||
'Login',
|
||||
style: TextStyle(
|
||||
fontSize: 30,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
),
|
||||
SizedBox(height: 5),
|
||||
Text(
|
||||
'Welcome back to Velocity',
|
||||
style: TextStyle(fontSize: 18),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
SizedBox(height: 30),
|
||||
Image.asset('assets/password.png', width: 300),
|
||||
Text(
|
||||
'Tap on your preferred social media platform to get started',
|
||||
style: TextStyle(fontSize: 18),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
// Username Field
|
||||
SizedBox(height: 20),
|
||||
// Forgot Password Link
|
||||
// Divider with "or" text
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Divider(color: Colors.grey.shade300),
|
||||
),
|
||||
Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Text(
|
||||
'choose from the options below to continue',
|
||||
style: TextStyle(
|
||||
color: Colors.grey.shade600,
|
||||
fontSize: 14,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Divider(color: Colors.grey.shade300),
|
||||
),
|
||||
],
|
||||
),
|
||||
SizedBox(height: 30),
|
||||
// Social Login Buttons
|
||||
if (GoogleSignIn.instance.supportsAuthenticate())
|
||||
_buildGoogleButton()
|
||||
else ...<Widget>[if (kIsWeb) web.renderButton()],
|
||||
SizedBox(height: 40),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
context.go('/');
|
||||
},
|
||||
child: Text(
|
||||
'Continue as guest?',
|
||||
style: TextStyle(fontSize: 14),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
return OnboardingLayout(
|
||||
showBackButton: true,
|
||||
trailingLabel: 'Sign up',
|
||||
trailingRoute: '/onboarding/phone',
|
||||
trailingIcon: Icons.person_add_alt_1_rounded,
|
||||
bottomLinkLabel: 'Continue as guest?',
|
||||
bottomLinkRoute: '/',
|
||||
child: OnboardingCard(
|
||||
child: Form(
|
||||
key: _formKey,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
const OnboardingHero(
|
||||
icon: Icons.lock_open_rounded,
|
||||
title: 'Welcome back',
|
||||
subtitle: 'Sign in to continue with Velocity',
|
||||
),
|
||||
const SizedBox(height: 28),
|
||||
OnboardingTextField(
|
||||
controller: _emailController,
|
||||
hintText: 'Email address',
|
||||
prefixIcon: Icons.email_outlined,
|
||||
keyboardType: TextInputType.emailAddress,
|
||||
validator: (value) {
|
||||
if (value == null || value.trim().isEmpty) {
|
||||
return 'Please enter your email address';
|
||||
}
|
||||
final emailRegex = RegExp(r'^[\w\.\-+]+@[\w\.\-]+\.\w+$');
|
||||
if (!emailRegex.hasMatch(value.trim())) {
|
||||
return 'Please enter a valid email address';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
OnboardingTextField(
|
||||
controller: _passwordController,
|
||||
hintText: 'Password',
|
||||
prefixIcon: Icons.lock_outline,
|
||||
obscureText: _obscurePassword,
|
||||
enableSuggestions: false,
|
||||
autocorrect: false,
|
||||
suffixIcon: IconButton(
|
||||
icon: Icon(
|
||||
_obscurePassword
|
||||
? Icons.visibility_rounded
|
||||
: Icons.visibility_off_rounded,
|
||||
),
|
||||
tooltip: _obscurePassword ? 'Show password' : 'Hide password',
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
_obscurePassword = !_obscurePassword;
|
||||
});
|
||||
},
|
||||
),
|
||||
validator: (value) {
|
||||
if (value == null || value.isEmpty) {
|
||||
return 'Please enter your password';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 28),
|
||||
Skeletonizer(
|
||||
enabled: _loading,
|
||||
child: OnboardingPrimaryButton(
|
||||
label: 'Sign in',
|
||||
icon: Icons.arrow_forward_rounded,
|
||||
onPressed: _onLogin,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildGoogleButton() {
|
||||
return Skeletonizer(
|
||||
enabled: _loading,
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: () {
|
||||
signInWithGoogle();
|
||||
},
|
||||
icon: Image.asset('assets/google.png', width: 20, height: 20),
|
||||
label: Text('Google', style: TextStyle(fontSize: 16)),
|
||||
style: OutlinedButton.styleFrom(
|
||||
padding: EdgeInsets.symmetric(vertical: 16),
|
||||
side: BorderSide(color: Colors.grey.shade300),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
SizedBox(width: 16),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,37 +1,50 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:google_sign_in/google_sign_in.dart';
|
||||
import 'package:qpay/http/http.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
class OnboardingModel {
|
||||
class RegistrationModel {
|
||||
String? email;
|
||||
String? firstName;
|
||||
String? lastName;
|
||||
String? phone;
|
||||
String? password;
|
||||
String? workflowId;
|
||||
String? username;
|
||||
GoogleSignInAccount? googleUser;
|
||||
bool isLoading = false;
|
||||
}
|
||||
|
||||
class OnboardingController extends ChangeNotifier {
|
||||
OnboardingModel model = OnboardingModel();
|
||||
RegistrationModel model = RegistrationModel();
|
||||
|
||||
void resetState() {
|
||||
model = OnboardingModel();
|
||||
model = RegistrationModel();
|
||||
}
|
||||
|
||||
void updateModel(Map mapModel) {
|
||||
model.workflowId = mapModel['workflowId'];
|
||||
model.phone = mapModel['phone'];
|
||||
model.username = mapModel['username'];
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
updatePhone(String phone) {
|
||||
void updateEmail(String email) {
|
||||
model.email = email;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void updateFirstName(String firstName) {
|
||||
model.firstName = firstName;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void updateLastName(String lastName) {
|
||||
model.lastName = lastName;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void updatePhone(String phone) {
|
||||
model.phone = phone;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
updateGoogleUser(GoogleSignInAccount googleUser) {
|
||||
model.googleUser = googleUser;
|
||||
void updatePassword(String password) {
|
||||
model.password = password;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,255 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
class PasswordScreen extends StatefulWidget {
|
||||
const PasswordScreen({super.key});
|
||||
|
||||
@override
|
||||
State<PasswordScreen> createState() => _PasswordScreenState();
|
||||
}
|
||||
|
||||
class _PasswordScreenState extends State<PasswordScreen> {
|
||||
final TextEditingController _passwordController = TextEditingController();
|
||||
final TextEditingController _confirmPasswordController =
|
||||
TextEditingController();
|
||||
final FocusNode _passwordFocusNode = FocusNode();
|
||||
final FocusNode _confirmPasswordFocusNode = FocusNode();
|
||||
bool _obscurePassword = true;
|
||||
bool _obscureConfirmPassword = true;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_passwordController.dispose();
|
||||
_confirmPasswordController.dispose();
|
||||
_passwordFocusNode.dispose();
|
||||
_confirmPasswordFocusNode.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
body: SingleChildScrollView(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(25),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
SizedBox(height: 75),
|
||||
Text(
|
||||
'Set New Password',
|
||||
style: TextStyle(
|
||||
fontSize: 30,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
),
|
||||
SizedBox(height: 5),
|
||||
Text(
|
||||
'Create a strong password to secure your account',
|
||||
style: TextStyle(fontSize: 18),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
Image.asset('assets/password.png', width: 300),
|
||||
// Password Input
|
||||
TextField(
|
||||
controller: _passwordController,
|
||||
focusNode: _passwordFocusNode,
|
||||
obscureText: _obscurePassword,
|
||||
decoration: InputDecoration(
|
||||
labelText: 'New Password',
|
||||
hintText: 'Enter your new password',
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: BorderSide(color: Colors.grey.shade300),
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: BorderSide(
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
width: 2,
|
||||
),
|
||||
),
|
||||
contentPadding: EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 16,
|
||||
),
|
||||
suffixIcon: IconButton(
|
||||
icon: Icon(
|
||||
_obscurePassword
|
||||
? Icons.visibility
|
||||
: Icons.visibility_off,
|
||||
color: Colors.grey.shade600,
|
||||
),
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
_obscurePassword = !_obscurePassword;
|
||||
});
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
SizedBox(height: 20),
|
||||
// Confirm Password Input
|
||||
TextField(
|
||||
controller: _confirmPasswordController,
|
||||
focusNode: _confirmPasswordFocusNode,
|
||||
obscureText: _obscureConfirmPassword,
|
||||
decoration: InputDecoration(
|
||||
labelText: 'Confirm Password',
|
||||
hintText: 'Confirm your new password',
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: BorderSide(color: Colors.grey.shade300),
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: BorderSide(
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
width: 2,
|
||||
),
|
||||
),
|
||||
contentPadding: EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 16,
|
||||
),
|
||||
suffixIcon: IconButton(
|
||||
icon: Icon(
|
||||
_obscureConfirmPassword
|
||||
? Icons.visibility
|
||||
: Icons.visibility_off,
|
||||
color: Colors.grey.shade600,
|
||||
),
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
_obscureConfirmPassword = !_obscureConfirmPassword;
|
||||
});
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
SizedBox(height: 20),
|
||||
// Password Requirements
|
||||
Container(
|
||||
padding: EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey.shade50,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: Colors.grey.shade200),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Password Requirements:',
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 14,
|
||||
color: Colors.grey.shade700,
|
||||
),
|
||||
),
|
||||
SizedBox(height: 8),
|
||||
_buildRequirement('At least 8 characters', true),
|
||||
_buildRequirement('Contains uppercase letter', true),
|
||||
_buildRequirement('Contains lowercase letter', true),
|
||||
_buildRequirement('Contains number', true),
|
||||
_buildRequirement('Contains special character', false),
|
||||
],
|
||||
),
|
||||
),
|
||||
SizedBox(height: 40),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
bottomNavigationBar: SizedBox(
|
||||
height: 130,
|
||||
child: Column(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 25),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: ElevatedButton(
|
||||
onPressed: () {
|
||||
// TODO: Add password update logic
|
||||
String password = _passwordController.text;
|
||||
String confirmPassword =
|
||||
_confirmPasswordController.text;
|
||||
print('Password: $password');
|
||||
print('Confirm Password: $confirmPassword');
|
||||
|
||||
context.go('/onboarding/login');
|
||||
},
|
||||
style: ElevatedButton.styleFrom(
|
||||
padding: EdgeInsets.symmetric(vertical: 16),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
'Update Password',
|
||||
style: TextStyle(fontSize: 16),
|
||||
),
|
||||
),
|
||||
),
|
||||
SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: OutlinedButton(
|
||||
onPressed: () {
|
||||
context.go('/onboarding/login');
|
||||
},
|
||||
style: OutlinedButton.styleFrom(
|
||||
padding: EdgeInsets.symmetric(vertical: 16),
|
||||
side: BorderSide(color: Colors.grey.shade300),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
'Back to Login',
|
||||
style: TextStyle(fontSize: 16),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
SizedBox(height: 20),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
context.go('/');
|
||||
},
|
||||
child: Text('Continue as guest?', style: TextStyle(fontSize: 14)),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildRequirement(String text, bool isMet) {
|
||||
return Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 2),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
isMet ? Icons.check_circle : Icons.circle_outlined,
|
||||
size: 16,
|
||||
color: isMet ? Colors.green : Colors.grey.shade600,
|
||||
),
|
||||
SizedBox(width: 8),
|
||||
Text(
|
||||
text,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: isMet ? Colors.grey.shade600 : Colors.grey.shade400,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -35,30 +35,29 @@ class PhoneController extends AbstractController {
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
|
||||
Future<ApiResponse<Map<String, dynamic>>> register() async {
|
||||
prefs = await SharedPreferences.getInstance();
|
||||
|
||||
Map user = {
|
||||
"username": onboardingController.model.googleUser?.email,
|
||||
"email": onboardingController.model.googleUser?.email,
|
||||
"firstName": onboardingController.model.googleUser?.displayName?.split(" ").first,
|
||||
"lastName": onboardingController.model.googleUser?.displayName?.split(" ").last,
|
||||
"password": onboardingController.model.googleUser?.id,
|
||||
"photoUrl": onboardingController.model.googleUser?.photoUrl,
|
||||
"username": onboardingController.model.email,
|
||||
"email": onboardingController.model.email,
|
||||
"firstName": onboardingController.model.firstName,
|
||||
"lastName": onboardingController.model.lastName,
|
||||
"password": onboardingController.model.password,
|
||||
"phone": onboardingController.model.phone,
|
||||
"tempUid": prefs.get("userId")
|
||||
"tempUid": prefs.get("userId"),
|
||||
};
|
||||
|
||||
try {
|
||||
model.isLoading = true;
|
||||
notifyListeners();
|
||||
|
||||
Map response = await http.post(
|
||||
'/auth/register',
|
||||
user
|
||||
user,
|
||||
);
|
||||
model.status = response['state'];
|
||||
if(response['state'] == 'failed') {
|
||||
if (response['state'] == 'failed') {
|
||||
model.errorMessage = response['message'];
|
||||
showErrorSnackBar(model.errorMessage);
|
||||
model.isLoading = false;
|
||||
@@ -79,8 +78,9 @@ class PhoneController extends AbstractController {
|
||||
);
|
||||
model.isLoading = false;
|
||||
notifyListeners();
|
||||
return ApiResponse.failure("Problem during registration, please try again or contact support?");
|
||||
return ApiResponse.failure(
|
||||
"Problem during registration, please try again or contact support?",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_contacts/flutter_contacts.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:qpay/models/responsive_policy.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:qpay/screens/onboarding/onboarding_controller.dart';
|
||||
import 'package:qpay/screens/onboarding/phone/phone_controller.dart';
|
||||
import 'package:qpay/screens/onboarding/widgets/onboarding_layout.dart';
|
||||
import 'package:skeletonizer/skeletonizer.dart';
|
||||
|
||||
class PhoneScreen extends StatefulWidget {
|
||||
@@ -14,10 +15,18 @@ class PhoneScreen extends StatefulWidget {
|
||||
|
||||
class _PhoneScreenState extends State<PhoneScreen> {
|
||||
late PhoneController phoneController;
|
||||
late OnboardingController onboardingController;
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
final TextEditingController _phoneController = TextEditingController();
|
||||
final TextEditingController _emailController = TextEditingController();
|
||||
final TextEditingController _passwordController = TextEditingController();
|
||||
final TextEditingController _confirmPasswordController =
|
||||
TextEditingController();
|
||||
final TextEditingController _fullNameController = TextEditingController();
|
||||
|
||||
String selectedCountryCode = '+263'; // Default to ZW
|
||||
bool _obscurePassword = true;
|
||||
bool _obscureConfirmPassword = true;
|
||||
|
||||
// Common country codes with flags and names
|
||||
final List<Map<String, String>> countryCodes = [
|
||||
@@ -48,27 +57,81 @@ class _PhoneScreenState extends State<PhoneScreen> {
|
||||
void initState() {
|
||||
super.initState();
|
||||
phoneController = PhoneController(context);
|
||||
onboardingController = Provider.of<OnboardingController>(
|
||||
context,
|
||||
listen: false,
|
||||
);
|
||||
// Pre-fill the email field if it has already been captured.
|
||||
final existingEmail = onboardingController.model.email;
|
||||
if (existingEmail != null && existingEmail.isNotEmpty) {
|
||||
_emailController.text = existingEmail;
|
||||
}
|
||||
// Pre-fill the password field if it has already been captured.
|
||||
final existingPassword = onboardingController.model.password;
|
||||
if (existingPassword != null && existingPassword.isNotEmpty) {
|
||||
_passwordController.text = existingPassword;
|
||||
_confirmPasswordController.text = existingPassword;
|
||||
}
|
||||
// Pre-fill the full name from firstName + lastName if previously captured.
|
||||
final firstName = onboardingController.model.firstName;
|
||||
final lastName = onboardingController.model.lastName;
|
||||
final combined = [
|
||||
if (firstName != null && firstName.isNotEmpty) firstName,
|
||||
if (lastName != null && lastName.isNotEmpty) lastName,
|
||||
].join(' ');
|
||||
if (combined.isNotEmpty) {
|
||||
_fullNameController.text = combined;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> updatePhoneNumber(String phoneNumber) async {
|
||||
String existingPhone = phoneNumber;
|
||||
if (existingPhone.isNotEmpty) {
|
||||
// Try to extract country code from existing phone number
|
||||
for (var country in countryCodes) {
|
||||
if (existingPhone.startsWith(country['code']!)) {
|
||||
setState(() {
|
||||
selectedCountryCode = country['code']!;
|
||||
});
|
||||
_phoneController.text = existingPhone.substring(
|
||||
country['code']!.length,
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
// If no country code found, use default and set the full number
|
||||
if (_phoneController.text.isEmpty) {
|
||||
_phoneController.text = existingPhone;
|
||||
}
|
||||
@override
|
||||
void dispose() {
|
||||
_phoneController.dispose();
|
||||
_emailController.dispose();
|
||||
_passwordController.dispose();
|
||||
_confirmPasswordController.dispose();
|
||||
_fullNameController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
/// Splits a full name into a [firstName] and [lastName] using a single
|
||||
/// space as the delimiter.
|
||||
///
|
||||
/// Examples:
|
||||
/// "John" -> firstName: "John", lastName: ""
|
||||
/// "John Doe" -> firstName: "John", lastName: "Doe"
|
||||
/// "John Michael Doe" -> firstName: "John", lastName: "Michael Doe"
|
||||
/// " John Doe " -> firstName: "John", lastName: "Doe"
|
||||
({String firstName, String lastName}) splitFullName(String fullName) {
|
||||
final trimmed = fullName.trim();
|
||||
if (trimmed.isEmpty) {
|
||||
return (firstName: '', lastName: '');
|
||||
}
|
||||
final parts = trimmed.split(RegExp(r'\s+'));
|
||||
if (parts.length == 1) {
|
||||
return (firstName: parts.first, lastName: '');
|
||||
}
|
||||
return (firstName: parts.first, lastName: parts.sublist(1).join(' '));
|
||||
}
|
||||
|
||||
Future<void> _onSubmit() async {
|
||||
if (!(_formKey.currentState?.validate() ?? false)) return;
|
||||
|
||||
final fullPhoneNumber = _phoneController.text;
|
||||
final email = _emailController.text.trim();
|
||||
final password = _passwordController.text;
|
||||
final nameParts = splitFullName(_fullNameController.text);
|
||||
|
||||
phoneController.updatePhone(fullPhoneNumber);
|
||||
onboardingController.updateEmail(email);
|
||||
onboardingController.updatePassword(password);
|
||||
onboardingController.updateFirstName(nameParts.firstName);
|
||||
onboardingController.updateLastName(nameParts.lastName);
|
||||
|
||||
await phoneController.register();
|
||||
if (phoneController.model.status != 'failed') {
|
||||
if (!mounted) return;
|
||||
context.go('/onboarding/verify');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -77,117 +140,139 @@ class _PhoneScreenState extends State<PhoneScreen> {
|
||||
return ListenableBuilder(
|
||||
listenable: phoneController,
|
||||
builder: (context, child) {
|
||||
return Scaffold(
|
||||
body: SingleChildScrollView(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(25.0),
|
||||
child: Form(
|
||||
key: _formKey,
|
||||
child: Center(
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final maxWidth =
|
||||
constraints.maxWidth > ResponsivePolicy.md
|
||||
? ResponsivePolicy.md.toDouble()
|
||||
: constraints.maxWidth;
|
||||
return SizedBox(
|
||||
width: maxWidth,
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: <Widget>[
|
||||
SizedBox(height: 75),
|
||||
Text(
|
||||
'Verify Your Device',
|
||||
style: TextStyle(
|
||||
fontSize: 30,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
SizedBox(height: 10),
|
||||
Text(
|
||||
'We will send you a verification code to this number',
|
||||
style: TextStyle(fontSize: 18),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
Text(
|
||||
'(as well as to the email address from your social media account if available)',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
color: Colors.grey,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
Image.asset('assets/phone.png', width: 300),
|
||||
SizedBox(height: 20),
|
||||
_buildPhoneField(),
|
||||
SizedBox(height: 30),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 25,
|
||||
),
|
||||
child: Skeletonizer(
|
||||
enabled: phoneController.model.isLoading,
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: ElevatedButton(
|
||||
onPressed: () async {
|
||||
if (_formKey.currentState!
|
||||
.validate()) {
|
||||
String fullPhoneNumber =
|
||||
_phoneController.text;
|
||||
|
||||
phoneController.updatePhone(
|
||||
fullPhoneNumber,
|
||||
);
|
||||
await phoneController.register();
|
||||
if (phoneController.model.status !=
|
||||
'failed') {
|
||||
context.go('/onboarding/verify');
|
||||
}
|
||||
}
|
||||
},
|
||||
child: Text(
|
||||
'Send Code',
|
||||
style: TextStyle(fontSize: 16),
|
||||
),
|
||||
),
|
||||
),
|
||||
SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: OutlinedButton(
|
||||
onPressed: () {
|
||||
context.go('/onboarding/login');
|
||||
},
|
||||
child: Text(
|
||||
'Go to Login',
|
||||
style: TextStyle(fontSize: 16),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
SizedBox(height: 10),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
context.go('/');
|
||||
},
|
||||
child: Text(
|
||||
'Continue as guest?',
|
||||
style: TextStyle(fontSize: 14),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
return OnboardingLayout(
|
||||
showBackButton: true,
|
||||
trailingLabel: 'Sign in',
|
||||
trailingRoute: '/onboarding/login',
|
||||
trailingIcon: Icons.login_rounded,
|
||||
bottomLinkLabel: 'Continue as guest?',
|
||||
bottomLinkRoute: '/',
|
||||
child: OnboardingCard(
|
||||
child: Form(
|
||||
key: _formKey,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
const OnboardingHero(
|
||||
icon: Icons.person_add_alt_1_rounded,
|
||||
title: 'Create your account',
|
||||
subtitle:
|
||||
'It only takes a minute — we\'ll send a verification code to confirm your details.',
|
||||
),
|
||||
const SizedBox(height: 28),
|
||||
OnboardingTextField(
|
||||
controller: _fullNameController,
|
||||
hintText: 'Full name',
|
||||
prefixIcon: Icons.person_outline,
|
||||
textCapitalization: TextCapitalization.words,
|
||||
validator: (value) {
|
||||
if (value == null || value.trim().isEmpty) {
|
||||
return 'Please enter your full name';
|
||||
}
|
||||
if (value.trim().length < 2) {
|
||||
return 'Name is too short';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
_buildPhoneField(),
|
||||
const SizedBox(height: 14),
|
||||
OnboardingTextField(
|
||||
controller: _emailController,
|
||||
hintText: 'Email address',
|
||||
prefixIcon: Icons.email_outlined,
|
||||
keyboardType: TextInputType.emailAddress,
|
||||
validator: (value) {
|
||||
if (value == null || value.trim().isEmpty) {
|
||||
return 'Please enter your email address';
|
||||
}
|
||||
final emailRegex = RegExp(r'^[\w\.\-+]+@[\w\.\-]+\.\w+$');
|
||||
if (!emailRegex.hasMatch(value.trim())) {
|
||||
return 'Please enter a valid email address';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
OnboardingTextField(
|
||||
controller: _passwordController,
|
||||
hintText: 'Create a password',
|
||||
prefixIcon: Icons.lock_outline,
|
||||
obscureText: _obscurePassword,
|
||||
enableSuggestions: false,
|
||||
autocorrect: false,
|
||||
suffixIcon: IconButton(
|
||||
icon: Icon(
|
||||
_obscurePassword
|
||||
? Icons.visibility_rounded
|
||||
: Icons.visibility_off_rounded,
|
||||
),
|
||||
tooltip: _obscurePassword
|
||||
? 'Show password'
|
||||
: 'Hide password',
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
_obscurePassword = !_obscurePassword;
|
||||
});
|
||||
},
|
||||
),
|
||||
validator: (value) {
|
||||
if (value == null || value.isEmpty) {
|
||||
return 'Please enter a password';
|
||||
}
|
||||
if (value.length < 8) {
|
||||
return 'Password must be at least 8 characters';
|
||||
}
|
||||
if (value.length > 128) {
|
||||
return 'Password is too long';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
OnboardingTextField(
|
||||
controller: _confirmPasswordController,
|
||||
hintText: 'Confirm your password',
|
||||
prefixIcon: Icons.lock_outline,
|
||||
obscureText: _obscureConfirmPassword,
|
||||
enableSuggestions: false,
|
||||
autocorrect: false,
|
||||
suffixIcon: IconButton(
|
||||
icon: Icon(
|
||||
_obscureConfirmPassword
|
||||
? Icons.visibility_rounded
|
||||
: Icons.visibility_off_rounded,
|
||||
),
|
||||
tooltip: _obscureConfirmPassword
|
||||
? 'Show password'
|
||||
: 'Hide password',
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
_obscureConfirmPassword = !_obscureConfirmPassword;
|
||||
});
|
||||
},
|
||||
),
|
||||
validator: (value) {
|
||||
if (value == null || value.isEmpty) {
|
||||
return 'Please confirm your password';
|
||||
}
|
||||
if (value != _passwordController.text) {
|
||||
return 'Passwords do not match';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 28),
|
||||
Skeletonizer(
|
||||
enabled: phoneController.model.isLoading,
|
||||
child: OnboardingPrimaryButton(
|
||||
label: 'Send verification code',
|
||||
icon: Icons.arrow_forward_rounded,
|
||||
onPressed: _onSubmit,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -197,94 +282,82 @@ class _PhoneScreenState extends State<PhoneScreen> {
|
||||
}
|
||||
|
||||
Widget _buildPhoneField() {
|
||||
return Column(
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
return Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.start,
|
||||
children: [
|
||||
// Country code dropdown
|
||||
Container(
|
||||
width: 100,
|
||||
height: 55,
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(
|
||||
color: Theme.of(context).colorScheme.primary.withOpacity(0.2),
|
||||
),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: DropdownButtonHideUnderline(
|
||||
child: DropdownButton<String>(
|
||||
value: selectedCountryCode,
|
||||
isExpanded: true,
|
||||
icon: const Icon(Icons.arrow_drop_down),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
items: countryCodes.map((country) {
|
||||
return DropdownMenuItem<String>(
|
||||
value: country['code'],
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(country['flag']!),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
country['code']!,
|
||||
style: const TextStyle(fontSize: 16),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
onChanged: (String? newValue) {
|
||||
if (newValue != null) {
|
||||
setState(() {
|
||||
selectedCountryCode = newValue;
|
||||
});
|
||||
}
|
||||
},
|
||||
),
|
||||
// Country code dropdown
|
||||
SizedBox(
|
||||
width: 110,
|
||||
child: Container(
|
||||
height: 47,
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.surface,
|
||||
border: Border.all(
|
||||
color: colorScheme.onSurface.withValues(alpha: 0.12),
|
||||
),
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
// Phone number field
|
||||
Expanded(
|
||||
child: TextFormField(
|
||||
controller: _phoneController,
|
||||
keyboardType: TextInputType.numberWithOptions(decimal: true),
|
||||
decoration: InputDecoration(
|
||||
hintText: 'Enter phone number',
|
||||
focusedErrorBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: BorderSide(
|
||||
color: Theme.of(context).colorScheme.secondary,
|
||||
child: DropdownButtonHideUnderline(
|
||||
child: DropdownButton<String>(
|
||||
value: selectedCountryCode,
|
||||
isExpanded: true,
|
||||
icon: const Icon(Icons.arrow_drop_down_rounded),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
items: countryCodes.map((country) {
|
||||
return DropdownMenuItem<String>(
|
||||
value: country['code'],
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(country['flag']!),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
country['code']!,
|
||||
style: const TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: BorderSide(
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.primary.withOpacity(0.2),
|
||||
),
|
||||
),
|
||||
),
|
||||
validator: (value) {
|
||||
if (value == null || value.isEmpty) {
|
||||
return 'Please enter a phone number';
|
||||
);
|
||||
}).toList(),
|
||||
onChanged: (String? newValue) {
|
||||
if (newValue != null) {
|
||||
setState(() {
|
||||
selectedCountryCode = newValue;
|
||||
});
|
||||
}
|
||||
// Remove any non-digit characters for validation
|
||||
String digitsOnly = value.replaceAll(RegExp(r'[^\d]'), '');
|
||||
if (digitsOnly.length < 7) {
|
||||
return 'Phone number must be at least 7 digits';
|
||||
}
|
||||
if (digitsOnly.length > 15) {
|
||||
return 'Phone number is too long';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
// Phone number field
|
||||
Expanded(
|
||||
child: OnboardingTextField(
|
||||
controller: _phoneController,
|
||||
hintText: 'Phone number',
|
||||
prefixIcon: Icons.phone_outlined,
|
||||
keyboardType: TextInputType.numberWithOptions(decimal: true),
|
||||
validator: (value) {
|
||||
if (value == null || value.isEmpty) {
|
||||
return 'Please enter a phone number';
|
||||
}
|
||||
// Remove any non-digit characters for validation
|
||||
final digitsOnly = value.replaceAll(RegExp(r'[^\d]'), '');
|
||||
if (digitsOnly.length < 7) {
|
||||
return 'Phone number must be at least 7 digits';
|
||||
}
|
||||
if (digitsOnly.length > 15) {
|
||||
return 'Phone number is too long';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
@@ -1,276 +0,0 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_dotenv/flutter_dotenv.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:google_sign_in/google_sign_in.dart';
|
||||
import 'package:google_sign_in_web/web_only.dart' as web;
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:qpay/models/responsive_policy.dart';
|
||||
import 'package:qpay/screens/onboarding/onboarding_controller.dart';
|
||||
import 'package:skeletonizer/skeletonizer.dart';
|
||||
|
||||
class RegisterScreen extends StatefulWidget {
|
||||
const RegisterScreen({super.key});
|
||||
|
||||
@override
|
||||
State<RegisterScreen> createState() => _RegisterScreenState();
|
||||
}
|
||||
|
||||
class _RegisterScreenState extends State<RegisterScreen> {
|
||||
late OnboardingController onboardingController;
|
||||
late GoogleSignIn googleSignIn;
|
||||
|
||||
bool _loading = false;
|
||||
|
||||
List<String> scopes = <String>[
|
||||
'https://www.googleapis.com/auth/contacts.readonly',
|
||||
];
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
onboardingController = Provider.of<OnboardingController>(
|
||||
context,
|
||||
listen: false,
|
||||
);
|
||||
// onboardingController.resetState();
|
||||
|
||||
googleSignIn = GoogleSignIn.instance;
|
||||
if(kIsWeb){
|
||||
googleSignIn.initialize();
|
||||
googleSignIn.authenticationEvents
|
||||
.listen(_handleAuthenticationEvent)
|
||||
.onError(_handleAuthenticationError);
|
||||
|
||||
}else {
|
||||
unawaited(googleSignIn.initialize(serverClientId: dotenv.env['CLIENTID']));
|
||||
}
|
||||
}
|
||||
|
||||
void signInWithGoogle() {
|
||||
if (GoogleSignIn.instance.supportsAuthenticate()){
|
||||
unawaited(GoogleSignIn.instance.authenticate().then((value) {
|
||||
googleSignIn.authenticationEvents
|
||||
.listen(_handleAuthenticationEvent)
|
||||
.onError(_handleAuthenticationError);
|
||||
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _handleAuthenticationEvent(GoogleSignInAuthenticationEvent event) async {
|
||||
// #docregion CheckAuthorization
|
||||
final GoogleSignInAccount? user = // ...
|
||||
// #enddocregion CheckAuthorization
|
||||
switch (event) {
|
||||
GoogleSignInAuthenticationEventSignIn() => event.user,
|
||||
GoogleSignInAuthenticationEventSignOut() => null,
|
||||
};
|
||||
|
||||
// Check for existing authorization.
|
||||
// #docregion CheckAuthorization
|
||||
final GoogleSignInClientAuthorization? authorization = await user
|
||||
?.authorizationClient
|
||||
.authorizationForScopes(scopes);
|
||||
// #enddocregion CheckAuthorization
|
||||
print(user);
|
||||
onboardingController.updateGoogleUser(user as GoogleSignInAccount);
|
||||
|
||||
}
|
||||
|
||||
Future<void> _handleAuthenticationError(Object e) async {
|
||||
print(e.toString());
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ListenableBuilder(
|
||||
listenable: onboardingController,
|
||||
builder: (context, child) {
|
||||
return Scaffold(
|
||||
body: SingleChildScrollView(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(25),
|
||||
child: Center(
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final maxWidth = constraints.maxWidth > ResponsivePolicy.md
|
||||
? ResponsivePolicy.md.toDouble()
|
||||
: constraints.maxWidth;
|
||||
return SizedBox(
|
||||
width: maxWidth,
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
SizedBox(height: 75),
|
||||
Text(
|
||||
'Registration',
|
||||
style: TextStyle(
|
||||
fontSize: 30,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
),
|
||||
SizedBox(height: 5),
|
||||
Text(
|
||||
"Let's start by getting some of your online details to get you started",
|
||||
style: TextStyle(fontSize: 18),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
Image.asset('assets/social.png', width: 300),
|
||||
SizedBox(height: 20),
|
||||
|
||||
// Social Login Buttons
|
||||
if(onboardingController.model.googleUser == null)
|
||||
Column(
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(child: Divider(color: Colors.grey.shade300)),
|
||||
Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Text(
|
||||
'Choose from the options below to continue',
|
||||
style: TextStyle(
|
||||
color: Colors.grey.shade600,
|
||||
fontSize: 14,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
Expanded(child: Divider(color: Colors.grey.shade300)),
|
||||
],
|
||||
),
|
||||
SizedBox(height: 20),
|
||||
if(GoogleSignIn.instance.supportsAuthenticate())
|
||||
_buildGoogleButton()
|
||||
else ...<Widget>[
|
||||
if (kIsWeb)
|
||||
web.renderButton()
|
||||
],
|
||||
],
|
||||
),
|
||||
if(onboardingController.model.googleUser != null)
|
||||
Column(
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 100,
|
||||
child: Divider(
|
||||
color: Colors.grey.shade300,
|
||||
thickness: 1,
|
||||
),
|
||||
),
|
||||
SizedBox(height: 20,),
|
||||
Text(
|
||||
"Welcome, ${onboardingController.model.googleUser?.displayName}",
|
||||
style: TextStyle(fontSize: 25),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
SizedBox(height: 10,),
|
||||
Text("Tap Proceed to continue", style: TextStyle(fontSize: 18),)
|
||||
],
|
||||
),
|
||||
SizedBox(height: 50),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 25),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: ElevatedButton(
|
||||
onPressed: () {
|
||||
if (onboardingController.model.googleUser == null) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (BuildContext dialogContext) {
|
||||
return AlertDialog(
|
||||
title: const Text('Sign-In Required'),
|
||||
content: const Text(
|
||||
"Please sign in with one of the social media platforms to proceed."),
|
||||
actions: <Widget>[
|
||||
TextButton(
|
||||
child: const Text('OK'),
|
||||
onPressed: () {
|
||||
Navigator.of(dialogContext).pop(); // Close the dialog
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
} else {
|
||||
context.go('/onboarding/phone');
|
||||
}
|
||||
},
|
||||
child: Text('Proceed', style: TextStyle(fontSize: 16)),
|
||||
),
|
||||
),
|
||||
SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: OutlinedButton(
|
||||
onPressed: () {
|
||||
context.go('/onboarding/login');
|
||||
},
|
||||
child: Text(
|
||||
'Go to Login',
|
||||
style: TextStyle(fontSize: 16),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
SizedBox(height: 10),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
context.go('/');
|
||||
},
|
||||
child: Text('Continue as guest?', style: TextStyle(fontSize: 14)),
|
||||
),
|
||||
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildGoogleButton(){
|
||||
return Skeletonizer(
|
||||
enabled: _loading,
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: () {
|
||||
signInWithGoogle();
|
||||
},
|
||||
icon: Image.asset(
|
||||
'assets/google.png',
|
||||
width: 20,
|
||||
height: 20,
|
||||
),
|
||||
label: Text('Google', style: TextStyle(fontSize: 16)),
|
||||
style: OutlinedButton.styleFrom(
|
||||
padding: EdgeInsets.symmetric(vertical: 16),
|
||||
side: BorderSide(color: Colors.grey.shade300),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
SizedBox(width: 16),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,6 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:qpay/abstracts/AbstractController.dart';
|
||||
@@ -19,7 +22,7 @@ class VerifyController extends AbstractController {
|
||||
|
||||
VerifyModel model = VerifyModel();
|
||||
|
||||
VerifyController(this.context){
|
||||
VerifyController(this.context) {
|
||||
onboardingController = Provider.of<OnboardingController>(
|
||||
context,
|
||||
listen: false,
|
||||
@@ -31,9 +34,64 @@ class VerifyController extends AbstractController {
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<ApiResponse<Map<String, dynamic>>> verifyOtp () async {
|
||||
/// Extracts a human-readable error message from any exception thrown during
|
||||
/// an HTTP call. Supports:
|
||||
/// * [DioException] – the response body, request body, and a top-level
|
||||
/// `message` field are inspected.
|
||||
/// * Dart [Exception] / [Error] whose stringified form is a JSON object
|
||||
/// (e.g. `{state: failed, status: manual, body: {...}, message: ...}`).
|
||||
/// * Any other throwable – falls back to its `toString()`.
|
||||
String _extractErrorMessage(Object error) {
|
||||
try {
|
||||
if (error is DioException) {
|
||||
final data = error.response?.data;
|
||||
if (data is Map) {
|
||||
final message = data['message'];
|
||||
if (message is String && message.isNotEmpty) return message;
|
||||
}
|
||||
if (data is String && data.isNotEmpty) {
|
||||
final parsed = _tryParseJson(data);
|
||||
if (parsed is Map) {
|
||||
final message = parsed['message'];
|
||||
if (message is String && message.isNotEmpty) return message;
|
||||
}
|
||||
return data;
|
||||
}
|
||||
if (error.type == DioExceptionType.connectionTimeout ||
|
||||
error.type == DioExceptionType.receiveTimeout ||
|
||||
error.type == DioExceptionType.sendTimeout) {
|
||||
return 'The request timed out. Please check your connection and try again.';
|
||||
}
|
||||
if (error.type == DioExceptionType.connectionError) {
|
||||
return 'No internet connection. Please check your network and try again.';
|
||||
}
|
||||
return error.message ?? 'Network error. Please try again.';
|
||||
}
|
||||
|
||||
final raw = error.toString();
|
||||
// The server sometimes throws a stringified JSON object as an exception.
|
||||
final parsed = _tryParseJson(raw);
|
||||
if (parsed is Map) {
|
||||
final message = parsed['message'];
|
||||
if (message is String && message.isNotEmpty) return message;
|
||||
}
|
||||
return raw;
|
||||
} catch (_) {
|
||||
return 'An unexpected error occurred. Please try again.';
|
||||
}
|
||||
}
|
||||
|
||||
dynamic _tryParseJson(String input) {
|
||||
try {
|
||||
return jsonDecode(input);
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Future<ApiResponse<Map<String, dynamic>>> verifyOtp() async {
|
||||
Map user = {
|
||||
"username": onboardingController.model.googleUser?.email,
|
||||
"username": onboardingController.model.email,
|
||||
"otpCode": model.code,
|
||||
"otpType": "REGISTRATION",
|
||||
"workflowId": onboardingController.model.workflowId,
|
||||
@@ -41,62 +99,61 @@ class VerifyController extends AbstractController {
|
||||
|
||||
try {
|
||||
model.isLoading = true;
|
||||
model.errorMessage = null;
|
||||
notifyListeners();
|
||||
|
||||
Map response = await http.post(
|
||||
'/auth/verify',
|
||||
user
|
||||
);
|
||||
Map response = await http.post('/auth/verify', user);
|
||||
model.status = response['state'];
|
||||
model.isLoading = false;
|
||||
notifyListeners();
|
||||
|
||||
if(response['state'] == 'failed') {
|
||||
model.errorMessage = response['message'];
|
||||
return ApiResponse.failure(response['message'] ?? "Verification failed");
|
||||
if (response['state'] == 'failed') {
|
||||
final message =
|
||||
response['message']?.toString() ?? "Verification failed";
|
||||
model.errorMessage = message;
|
||||
return ApiResponse.failure(message);
|
||||
}
|
||||
return ApiResponse.success(Map<String, dynamic>.from(response));
|
||||
} catch (e) {
|
||||
logger.e(e);
|
||||
final message = _extractErrorMessage(e);
|
||||
model.errorMessage = message;
|
||||
model.isLoading = false;
|
||||
notifyListeners();
|
||||
return ApiResponse.failure(
|
||||
"Problem during registration, please try again or contact support?",
|
||||
);
|
||||
return ApiResponse.failure(message);
|
||||
}
|
||||
}
|
||||
|
||||
Future<ApiResponse<Map<String, dynamic>>> resendOtp () async {
|
||||
Future<ApiResponse<Map<String, dynamic>>> resendOtp() async {
|
||||
Map user = {
|
||||
"username": onboardingController.model.googleUser?.email,
|
||||
"username": onboardingController.model.email,
|
||||
"phone": onboardingController.model.phone,
|
||||
"workflowId": onboardingController.model.workflowId,
|
||||
};
|
||||
|
||||
try {
|
||||
model.isLoading = true;
|
||||
model.errorMessage = null;
|
||||
notifyListeners();
|
||||
|
||||
Map response = await http.post(
|
||||
'/auth/resend',
|
||||
user
|
||||
);
|
||||
Map response = await http.post('/auth/resend', user);
|
||||
model.status = response['state'];
|
||||
model.isLoading = false;
|
||||
notifyListeners();
|
||||
|
||||
if(response['state'] == 'failed') {
|
||||
model.errorMessage = response['message'];
|
||||
return ApiResponse.failure(response['message'] ?? "Resend failed");
|
||||
if (response['state'] == 'failed') {
|
||||
final message = response['message']?.toString() ?? "Resend failed";
|
||||
model.errorMessage = message;
|
||||
return ApiResponse.failure(message);
|
||||
}
|
||||
return ApiResponse.success(Map<String, dynamic>.from(response));
|
||||
} catch (e) {
|
||||
logger.e(e);
|
||||
final message = _extractErrorMessage(e);
|
||||
model.errorMessage = message;
|
||||
model.isLoading = false;
|
||||
notifyListeners();
|
||||
return ApiResponse.failure(
|
||||
"Problem during registration, please try again or contact support?",
|
||||
);
|
||||
return ApiResponse.failure(message);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:qpay/models/responsive_policy.dart';
|
||||
import 'package:qpay/screens/onboarding/verify/verify_controller.dart';
|
||||
import 'package:qpay/screens/onboarding/widgets/onboarding_layout.dart';
|
||||
import 'package:skeletonizer/skeletonizer.dart';
|
||||
|
||||
class VerifyScreen extends StatefulWidget {
|
||||
@@ -18,7 +18,6 @@ class _VerifyScreenState extends State<VerifyScreen> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
verifyController = VerifyController(context);
|
||||
}
|
||||
|
||||
@@ -48,6 +47,37 @@ class _VerifyScreenState extends State<VerifyScreen> {
|
||||
void _onCodeChanged(String value, int index) {
|
||||
if (value.length == 1 && index < 5) {
|
||||
_focusNodes[index + 1].requestFocus();
|
||||
} else if (value.isEmpty && index > 0) {
|
||||
_focusNodes[index - 1].requestFocus();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _submitCode() async {
|
||||
showErrorMessage(null);
|
||||
String code = '';
|
||||
for (var controller in _codeControllers) {
|
||||
code += controller.text;
|
||||
}
|
||||
|
||||
if (code.length != 6) {
|
||||
showErrorMessage('Invalid code');
|
||||
return;
|
||||
}
|
||||
verifyController.updateCode(code);
|
||||
final response = await verifyController.verifyOtp();
|
||||
if (!mounted) return;
|
||||
if (response.isSuccess) {
|
||||
if (verifyController.model.status == 'done') {
|
||||
context.go('/onboarding/complete');
|
||||
} else {
|
||||
showErrorMessage(
|
||||
verifyController.model.errorMessage ?? 'Verification failed',
|
||||
);
|
||||
}
|
||||
} else {
|
||||
showErrorMessage(
|
||||
response.error ?? 'Verification failed. Please try again.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,170 +86,145 @@ class _VerifyScreenState extends State<VerifyScreen> {
|
||||
return ListenableBuilder(
|
||||
listenable: verifyController,
|
||||
builder: (context, child) {
|
||||
return Scaffold(
|
||||
body: SingleChildScrollView(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(25),
|
||||
child: Center(
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final maxWidth = constraints.maxWidth > ResponsivePolicy.md
|
||||
? ResponsivePolicy.md.toDouble()
|
||||
: constraints.maxWidth;
|
||||
return SizedBox(
|
||||
width: maxWidth,
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
SizedBox(height: 75),
|
||||
Text(
|
||||
'Verify Your Account',
|
||||
style: TextStyle(
|
||||
fontSize: 30,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
),
|
||||
SizedBox(height: 5),
|
||||
Text(
|
||||
'Enter the 6-digit code sent to your phone/email',
|
||||
style: TextStyle(fontSize: 18),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
Image.asset('assets/verify.png', width: 300),
|
||||
// Verification Code Input
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||
children: List.generate(
|
||||
6,
|
||||
(index) => SizedBox(
|
||||
width: 50,
|
||||
child: TextFormField(
|
||||
controller: _codeControllers[index],
|
||||
focusNode: _focusNodes[index],
|
||||
textAlign: TextAlign.center,
|
||||
keyboardType: TextInputType.number,
|
||||
maxLength: 1,
|
||||
decoration: InputDecoration(
|
||||
counterText: '',
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: BorderSide(color: Colors.grey.shade300),
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: BorderSide(
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
width: 2,
|
||||
),
|
||||
),
|
||||
contentPadding: EdgeInsets.symmetric(vertical: 16),
|
||||
),
|
||||
onChanged: (value) => _onCodeChanged(value, index),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
SizedBox(height: 5),
|
||||
if(errorMessage != null)
|
||||
Text(errorMessage ?? '', style: TextStyle(color: Colors.red)),
|
||||
SizedBox(height: 30),
|
||||
// Resend Code Section
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text(
|
||||
"Didn't receive the code? ",
|
||||
style: TextStyle(color: Colors.grey.shade600, fontSize: 16),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
verifyController.resendOtp();
|
||||
},
|
||||
child: Text(
|
||||
'Resend Code',
|
||||
style: TextStyle(
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
// SizedBox(height: 20),
|
||||
// // Timer for resend (optional)
|
||||
// Text(
|
||||
// 'Resend available in 2:30',
|
||||
// style: TextStyle(color: Colors.grey.shade500, fontSize: 14),
|
||||
// ),
|
||||
SizedBox(height: 40),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 25),
|
||||
child: Skeletonizer(
|
||||
enabled: verifyController.model.isLoading,
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: ElevatedButton(
|
||||
onPressed: () async {
|
||||
showErrorMessage(null);
|
||||
String code = '';
|
||||
for (var controller in _codeControllers) {
|
||||
code += controller.text;
|
||||
}
|
||||
|
||||
if(code.length != 6) {
|
||||
showErrorMessage('Invalid code');
|
||||
return;
|
||||
}
|
||||
verifyController.updateCode(code);
|
||||
await verifyController.verifyOtp();
|
||||
if(verifyController.model.status == 'done') {
|
||||
context.go('/onboarding/complete');
|
||||
}
|
||||
},
|
||||
child: Text(
|
||||
'Verify Code',
|
||||
style: TextStyle(fontSize: 16),
|
||||
),
|
||||
),
|
||||
),
|
||||
SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: OutlinedButton(
|
||||
onPressed: () {
|
||||
context.go('/onboarding/login');
|
||||
},
|
||||
child: Text(
|
||||
'Back to Login',
|
||||
style: TextStyle(fontSize: 16),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
SizedBox(height: 10),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
context.go('/');
|
||||
},
|
||||
child: Text('Continue as guest?', style: TextStyle(fontSize: 14)),
|
||||
),
|
||||
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
return OnboardingLayout(
|
||||
showBackButton: true,
|
||||
trailingLabel: 'Sign in',
|
||||
trailingRoute: '/onboarding/login',
|
||||
trailingIcon: Icons.login_rounded,
|
||||
bottomLinkLabel: 'Continue as guest?',
|
||||
bottomLinkRoute: '/',
|
||||
child: OnboardingCard(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
const OnboardingHero(
|
||||
icon: Icons.sms_rounded,
|
||||
title: 'Verify your account',
|
||||
subtitle:
|
||||
'Enter the 6-digit code we sent to your phone or email.',
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
_buildCodeInputs(),
|
||||
const SizedBox(height: 16),
|
||||
if (errorMessage != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
child: Center(
|
||||
child: Text(
|
||||
errorMessage!,
|
||||
style: TextStyle(
|
||||
color: Theme.of(context).colorScheme.error,
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
_buildResendRow(),
|
||||
const SizedBox(height: 28),
|
||||
Skeletonizer(
|
||||
enabled: verifyController.model.isLoading,
|
||||
child: OnboardingPrimaryButton(
|
||||
label: 'Verify code',
|
||||
icon: Icons.check_rounded,
|
||||
onPressed: _submitCode,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildCodeInputs() {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final borderRadius = BorderRadius.circular(14);
|
||||
|
||||
return Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: List.generate(6, (index) {
|
||||
return SizedBox(
|
||||
width: 48,
|
||||
child: TextFormField(
|
||||
controller: _codeControllers[index],
|
||||
focusNode: _focusNodes[index],
|
||||
textAlign: TextAlign.center,
|
||||
keyboardType: TextInputType.number,
|
||||
maxLength: 1,
|
||||
style: TextStyle(
|
||||
fontSize: 22,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: colorScheme.onSurface,
|
||||
),
|
||||
decoration: InputDecoration(
|
||||
counterText: '',
|
||||
filled: true,
|
||||
fillColor: colorScheme.surface,
|
||||
contentPadding: const EdgeInsets.symmetric(vertical: 14),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: borderRadius,
|
||||
borderSide: BorderSide(
|
||||
color: colorScheme.onSurface.withValues(alpha: 0.12),
|
||||
),
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: borderRadius,
|
||||
borderSide: BorderSide(
|
||||
color: colorScheme.primary,
|
||||
width: 1.6,
|
||||
),
|
||||
),
|
||||
errorBorder: OutlineInputBorder(
|
||||
borderRadius: borderRadius,
|
||||
borderSide: BorderSide(color: colorScheme.error),
|
||||
),
|
||||
focusedErrorBorder: OutlineInputBorder(
|
||||
borderRadius: borderRadius,
|
||||
borderSide: BorderSide(
|
||||
color: colorScheme.error,
|
||||
width: 1.6,
|
||||
),
|
||||
),
|
||||
),
|
||||
onChanged: (value) => _onCodeChanged(value, index),
|
||||
),
|
||||
);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildResendRow() {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
return Center(
|
||||
child: Wrap(
|
||||
crossAxisAlignment: WrapCrossAlignment.center,
|
||||
children: [
|
||||
Text(
|
||||
"Didn't receive the code?",
|
||||
style: TextStyle(
|
||||
color: colorScheme.onSurface.withValues(alpha: 0.65),
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
verifyController.resendOtp();
|
||||
},
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor: colorScheme.primary,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6),
|
||||
textStyle: const TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
child: const Text('Resend code'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
611
lib/screens/onboarding/widgets/onboarding_layout.dart
Normal file
611
lib/screens/onboarding/widgets/onboarding_layout.dart
Normal file
@@ -0,0 +1,611 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:qpay/models/responsive_policy.dart';
|
||||
|
||||
/// Shared layout for the onboarding flow (login, register, verify, complete).
|
||||
///
|
||||
/// Provides the consistent Velocity look & feel:
|
||||
/// - Soft brand gradient background
|
||||
/// - Top bar with the Velocity brand mark and contextual trailing action
|
||||
/// - Centered content card (form area)
|
||||
/// - Bottom "Continue as guest?" + footer
|
||||
///
|
||||
/// All screens wrap their content with this layout so the flow feels cohesive
|
||||
/// across mobile and web.
|
||||
class OnboardingLayout extends StatelessWidget {
|
||||
const OnboardingLayout({
|
||||
super.key,
|
||||
required this.child,
|
||||
this.trailingLabel,
|
||||
this.trailingRoute,
|
||||
this.trailingIcon,
|
||||
this.showBackButton = false,
|
||||
this.onBack,
|
||||
this.bottomLinkLabel,
|
||||
this.bottomLinkRoute,
|
||||
this.bottomLinkOnPressed,
|
||||
});
|
||||
|
||||
/// The body of the screen (typically a form inside an [OnboardingCard]).
|
||||
final Widget child;
|
||||
|
||||
/// Optional label for the trailing action in the top bar
|
||||
/// (e.g. "Sign in", "Back"). Hidden if null.
|
||||
final String? trailingLabel;
|
||||
|
||||
/// Route to navigate to when the trailing action is pressed.
|
||||
final String? trailingRoute;
|
||||
|
||||
/// Optional icon for the trailing action (defaults to chevron/arrow).
|
||||
final IconData? trailingIcon;
|
||||
|
||||
/// If true, shows a back-arrow icon button on the leading side of the top bar.
|
||||
final bool showBackButton;
|
||||
|
||||
/// Optional callback for the back button. If not provided, the button pops
|
||||
/// the route via [GoRouter].
|
||||
final VoidCallback? onBack;
|
||||
|
||||
/// Optional label for a link rendered below the main content
|
||||
/// (e.g. "Continue as guest?").
|
||||
final String? bottomLinkLabel;
|
||||
|
||||
/// Route to navigate to when the bottom link is pressed.
|
||||
final String? bottomLinkRoute;
|
||||
|
||||
/// Optional callback for the bottom link (overrides [bottomLinkRoute]).
|
||||
final VoidCallback? bottomLinkOnPressed;
|
||||
|
||||
// Max content width on large screens (desktop / web) for the page chrome
|
||||
// (top bar, footer, etc.).
|
||||
static const double _maxContentWidth = 1200;
|
||||
// Max content width on tablet — the current mobile cap lifted slightly
|
||||
// so the page breathes a bit on landscape phones / small tablets.
|
||||
static const double _maxTabletWidth = 760;
|
||||
// Max width for the form card on desktop — keeps forms a comfortable
|
||||
// reading/interaction width on wide screens.
|
||||
static const double _formMaxWidth = 480;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final colorScheme = theme.colorScheme;
|
||||
final width = MediaQuery.of(context).size.width;
|
||||
final isTablet = width >= ResponsivePolicy.md;
|
||||
final isDesktop = width >= ResponsivePolicy.lg;
|
||||
|
||||
return Scaffold(
|
||||
// Apply the gradient to the Scaffold body itself via a Container so
|
||||
// it is guaranteed to cover the full viewport (including the area
|
||||
// under the SafeArea) regardless of how tall the form content ends
|
||||
// up being. The previous Stack + Positioned.fill version shrunk to
|
||||
// fit the non-positioned SingleChildScrollView, which left an
|
||||
// unstyled area at the bottom of the page.
|
||||
body: Container(
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
colors: [
|
||||
colorScheme.primary.withValues(alpha: 0.08),
|
||||
colorScheme.surface,
|
||||
colorScheme.secondary.withValues(alpha: 0.05),
|
||||
],
|
||||
),
|
||||
),
|
||||
child: SafeArea(
|
||||
child: LayoutBuilder(
|
||||
builder: (context, viewportConstraints) {
|
||||
final horizontalPadding = isDesktop ? 48.0 : 20.0;
|
||||
final shellWidth = viewportConstraints.maxWidth < _maxContentWidth
|
||||
? viewportConstraints.maxWidth
|
||||
: _maxContentWidth;
|
||||
final availableContentWidth =
|
||||
(shellWidth - (horizontalPadding * 2)).clamp(
|
||||
0.0,
|
||||
double.infinity,
|
||||
);
|
||||
final contentMaxWidth = isDesktop
|
||||
? _maxContentWidth
|
||||
: (isTablet
|
||||
? (_maxTabletWidth < availableContentWidth
|
||||
? _maxTabletWidth
|
||||
: availableContentWidth)
|
||||
: availableContentWidth);
|
||||
|
||||
return SingleChildScrollView(
|
||||
child: ConstrainedBox(
|
||||
constraints: BoxConstraints(
|
||||
minHeight: viewportConstraints.maxHeight,
|
||||
),
|
||||
child: IntrinsicHeight(
|
||||
child: Center(
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(
|
||||
maxWidth: _maxContentWidth,
|
||||
),
|
||||
child: Padding(
|
||||
padding: EdgeInsets.symmetric(
|
||||
horizontal: horizontalPadding,
|
||||
vertical: isDesktop ? 32 : 16,
|
||||
),
|
||||
child: SizedBox(
|
||||
width: contentMaxWidth,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
const SizedBox(height: 4),
|
||||
_buildTopBar(
|
||||
context,
|
||||
theme,
|
||||
colorScheme,
|
||||
isDesktop,
|
||||
),
|
||||
SizedBox(height: isDesktop ? 32 : 20),
|
||||
// On desktop, narrow the form to a
|
||||
// comfortable width and center it. On
|
||||
// mobile/tablet, let it expand to fill
|
||||
// the available space.
|
||||
isDesktop
|
||||
? Center(
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(
|
||||
maxWidth: _formMaxWidth,
|
||||
),
|
||||
child: child,
|
||||
),
|
||||
)
|
||||
: child,
|
||||
if (bottomLinkLabel != null ||
|
||||
bottomLinkOnPressed != null) ...[
|
||||
const SizedBox(height: 16),
|
||||
_buildBottomLink(context, theme, colorScheme),
|
||||
],
|
||||
const Spacer(),
|
||||
_buildFooter(theme, colorScheme),
|
||||
const SizedBox(height: 12),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTopBar(
|
||||
BuildContext context,
|
||||
ThemeData theme,
|
||||
ColorScheme colorScheme,
|
||||
bool isDesktop,
|
||||
) {
|
||||
return Row(
|
||||
children: [
|
||||
if (showBackButton)
|
||||
IconButton(
|
||||
onPressed:
|
||||
onBack ??
|
||||
() {
|
||||
if (context.canPop()) {
|
||||
context.pop();
|
||||
} else {
|
||||
context.go('/onboarding/landing');
|
||||
}
|
||||
},
|
||||
icon: Icon(Icons.arrow_back_rounded, color: colorScheme.onSurface),
|
||||
tooltip: 'Back',
|
||||
style: IconButton.styleFrom(
|
||||
backgroundColor: colorScheme.surface,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
side: BorderSide(
|
||||
color: colorScheme.onSurface.withValues(alpha: 0.08),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
else
|
||||
GestureDetector(
|
||||
onTap: () => context.go('/onboarding/landing'),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.all(8),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.primary.withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Icon(
|
||||
Icons.bolt_rounded,
|
||||
color: colorScheme.primary,
|
||||
size: 24,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Text(
|
||||
'Velocity',
|
||||
style: theme.textTheme.titleLarge?.copyWith(
|
||||
color: colorScheme.primary,
|
||||
fontWeight: FontWeight.w800,
|
||||
letterSpacing: -0.5,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
if (trailingLabel != null && trailingRoute != null)
|
||||
TextButton(
|
||||
onPressed: () => context.go(trailingRoute!),
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor: colorScheme.primary,
|
||||
padding: EdgeInsets.symmetric(horizontal: isDesktop ? 16 : 8),
|
||||
textStyle: const TextStyle(
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (trailingIcon != null) ...[
|
||||
Icon(trailingIcon, size: 18),
|
||||
const SizedBox(width: 6),
|
||||
],
|
||||
Text(trailingLabel!),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildBottomLink(
|
||||
BuildContext context,
|
||||
ThemeData theme,
|
||||
ColorScheme colorScheme,
|
||||
) {
|
||||
return Center(
|
||||
child: TextButton(
|
||||
onPressed:
|
||||
bottomLinkOnPressed ??
|
||||
() {
|
||||
if (bottomLinkRoute != null) context.go(bottomLinkRoute!);
|
||||
},
|
||||
child: Text(
|
||||
bottomLinkLabel ?? '',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: colorScheme.onSurface.withValues(alpha: 0.7),
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildFooter(ThemeData theme, ColorScheme colorScheme) {
|
||||
return Center(
|
||||
child: Text(
|
||||
'© Velocity · Fast, secure local payments',
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onSurface.withValues(alpha: 0.45),
|
||||
letterSpacing: 0.2,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A rounded card used to wrap onboarding forms. Provides the matching
|
||||
/// border, shadow, and inner padding used throughout the onboarding flow.
|
||||
class OnboardingCard extends StatelessWidget {
|
||||
const OnboardingCard({
|
||||
super.key,
|
||||
required this.child,
|
||||
this.padding = const EdgeInsets.fromLTRB(24, 28, 24, 24),
|
||||
});
|
||||
|
||||
final Widget child;
|
||||
final EdgeInsetsGeometry padding;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
padding: padding,
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.surface,
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
border: Border.all(
|
||||
color: colorScheme.onSurface.withValues(alpha: 0.06),
|
||||
width: 1,
|
||||
),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: colorScheme.primary.withValues(alpha: 0.06),
|
||||
blurRadius: 24,
|
||||
offset: const Offset(0, 12),
|
||||
),
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: 0.03),
|
||||
blurRadius: 6,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Hero section used at the top of onboarding cards: a tinted circular icon
|
||||
/// badge plus a title and optional subtitle.
|
||||
class OnboardingHero extends StatelessWidget {
|
||||
const OnboardingHero({
|
||||
super.key,
|
||||
required this.icon,
|
||||
required this.title,
|
||||
this.subtitle,
|
||||
this.accent,
|
||||
});
|
||||
|
||||
final IconData icon;
|
||||
final String title;
|
||||
final String? subtitle;
|
||||
final Color? accent;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final colorScheme = theme.colorScheme;
|
||||
final tint = accent ?? colorScheme.primary;
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: tint.withValues(alpha: 0.12),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Icon(icon, color: tint, size: 32),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
title,
|
||||
textAlign: TextAlign.center,
|
||||
style: theme.textTheme.headlineSmall?.copyWith(
|
||||
fontWeight: FontWeight.w800,
|
||||
color: colorScheme.onSurface,
|
||||
letterSpacing: -0.5,
|
||||
),
|
||||
),
|
||||
if (subtitle != null) ...[
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
subtitle!,
|
||||
textAlign: TextAlign.center,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: colorScheme.onSurface.withValues(alpha: 0.65),
|
||||
height: 1.45,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Modern, theme-aligned text field used by the onboarding forms.
|
||||
///
|
||||
/// Wraps a [TextFormField] and applies the consistent border, prefix icon,
|
||||
/// and focus styling that matches the rest of the Velocity design system.
|
||||
class OnboardingTextField extends StatelessWidget {
|
||||
const OnboardingTextField({
|
||||
super.key,
|
||||
required this.controller,
|
||||
this.hintText,
|
||||
this.labelText,
|
||||
this.prefixIcon,
|
||||
this.suffixIcon,
|
||||
this.keyboardType,
|
||||
this.textCapitalization = TextCapitalization.none,
|
||||
this.autocorrect = false,
|
||||
this.enableSuggestions = true,
|
||||
this.obscureText = false,
|
||||
this.validator,
|
||||
this.onChanged,
|
||||
});
|
||||
|
||||
final TextEditingController controller;
|
||||
final String? hintText;
|
||||
final String? labelText;
|
||||
final IconData? prefixIcon;
|
||||
final Widget? suffixIcon;
|
||||
final TextInputType? keyboardType;
|
||||
final TextCapitalization textCapitalization;
|
||||
final bool autocorrect;
|
||||
final bool enableSuggestions;
|
||||
final bool obscureText;
|
||||
final String? Function(String?)? validator;
|
||||
final ValueChanged<String>? onChanged;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final borderRadius = BorderRadius.circular(14);
|
||||
|
||||
final enabledBorder = OutlineInputBorder(
|
||||
borderRadius: borderRadius,
|
||||
borderSide: BorderSide(
|
||||
color: colorScheme.onSurface.withValues(alpha: 0.12),
|
||||
width: 1,
|
||||
),
|
||||
);
|
||||
final focusedBorder = OutlineInputBorder(
|
||||
borderRadius: borderRadius,
|
||||
borderSide: BorderSide(color: colorScheme.primary, width: 1.6),
|
||||
);
|
||||
final errorBorder = OutlineInputBorder(
|
||||
borderRadius: borderRadius,
|
||||
borderSide: BorderSide(color: colorScheme.error, width: 1),
|
||||
);
|
||||
final focusedErrorBorder = OutlineInputBorder(
|
||||
borderRadius: borderRadius,
|
||||
borderSide: BorderSide(color: colorScheme.error, width: 1.6),
|
||||
);
|
||||
|
||||
return TextFormField(
|
||||
controller: controller,
|
||||
keyboardType: keyboardType,
|
||||
textCapitalization: textCapitalization,
|
||||
autocorrect: autocorrect,
|
||||
enableSuggestions: enableSuggestions,
|
||||
obscureText: obscureText,
|
||||
validator: validator,
|
||||
onChanged: onChanged,
|
||||
style: TextStyle(
|
||||
color: colorScheme.onSurface,
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
decoration: InputDecoration(
|
||||
hintText: hintText,
|
||||
labelText: labelText,
|
||||
prefixIcon: prefixIcon != null
|
||||
? Icon(
|
||||
prefixIcon,
|
||||
color: colorScheme.onSurface.withValues(alpha: 0.55),
|
||||
)
|
||||
: null,
|
||||
suffixIcon: suffixIcon,
|
||||
filled: true,
|
||||
fillColor: colorScheme.surface,
|
||||
enabledBorder: enabledBorder,
|
||||
focusedBorder: focusedBorder,
|
||||
errorBorder: errorBorder,
|
||||
focusedErrorBorder: focusedErrorBorder,
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 16,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Primary CTA button used across the onboarding screens.
|
||||
class OnboardingPrimaryButton extends StatelessWidget {
|
||||
const OnboardingPrimaryButton({
|
||||
super.key,
|
||||
required this.label,
|
||||
this.icon,
|
||||
this.onPressed,
|
||||
this.fullWidth = true,
|
||||
this.height = 52,
|
||||
});
|
||||
|
||||
final String label;
|
||||
final IconData? icon;
|
||||
final VoidCallback? onPressed;
|
||||
final bool fullWidth;
|
||||
final double height;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
return SizedBox(
|
||||
width: fullWidth ? double.infinity : null,
|
||||
height: height,
|
||||
child: ElevatedButton(
|
||||
onPressed: onPressed,
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: colorScheme.primary,
|
||||
foregroundColor: Colors.white,
|
||||
elevation: 0,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
),
|
||||
textStyle: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: 0.3,
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text(label),
|
||||
if (icon != null) ...[
|
||||
const SizedBox(width: 8),
|
||||
Icon(icon, size: 20),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Secondary outlined button used across the onboarding screens.
|
||||
class OnboardingSecondaryButton extends StatelessWidget {
|
||||
const OnboardingSecondaryButton({
|
||||
super.key,
|
||||
required this.label,
|
||||
this.icon,
|
||||
this.onPressed,
|
||||
this.fullWidth = true,
|
||||
this.height = 52,
|
||||
});
|
||||
|
||||
final String label;
|
||||
final IconData? icon;
|
||||
final VoidCallback? onPressed;
|
||||
final bool fullWidth;
|
||||
final double height;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
return SizedBox(
|
||||
width: fullWidth ? double.infinity : null,
|
||||
height: height,
|
||||
child: OutlinedButton(
|
||||
onPressed: onPressed,
|
||||
style: OutlinedButton.styleFrom(
|
||||
side: BorderSide(color: colorScheme.primary, width: 1.5),
|
||||
foregroundColor: colorScheme.primary,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
),
|
||||
textStyle: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: 0.3,
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
if (icon != null) ...[
|
||||
Icon(icon, size: 20),
|
||||
const SizedBox(width: 8),
|
||||
],
|
||||
Text(label),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user