Files
velocity-pay-flutter/lib/screens/onboarding/widgets/onboarding_layout.dart

612 lines
19 KiB
Dart

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),
],
),
),
);
}
}