completed group batch feature
This commit is contained in:
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