diff --git a/lib/features/authentication/presentation/widgets/auth_button.dart b/lib/features/authentication/presentation/widgets/auth_button.dart new file mode 100644 index 0000000..acf6c7e --- /dev/null +++ b/lib/features/authentication/presentation/widgets/auth_button.dart @@ -0,0 +1,120 @@ +import 'package:flutter/material.dart'; + +/// Custom authentication button with loading states and variants +class AuthButton extends StatelessWidget { + final String text; + final VoidCallback? onPressed; + final bool isLoading; + final AuthButtonVariant variant; + final bool fullWidth; + final Widget? icon; + + const AuthButton({ + super.key, + required this.text, + this.onPressed, + this.isLoading = false, + this.variant = AuthButtonVariant.primary, + this.fullWidth = true, + this.icon, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colors = _getColors(theme); + + return SizedBox( + width: fullWidth ? double.infinity : null, + height: 48, + child: ElevatedButton( + onPressed: isLoading ? null : onPressed, + style: ElevatedButton.styleFrom( + backgroundColor: colors.background, + foregroundColor: colors.foreground, + disabledBackgroundColor: colors.disabledBackground, + disabledForegroundColor: colors.disabledForeground, + elevation: variant == AuthButtonVariant.primary ? 2 : 0, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + side: variant == AuthButtonVariant.outline + ? BorderSide(color: colors.background) + : BorderSide.none, + ), + ), + child: isLoading + ? SizedBox( + height: 20, + width: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + valueColor: AlwaysStoppedAnimation(colors.foreground), + ), + ) + : Row( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + if (icon != null) ...[ + icon!, + const SizedBox(width: 8), + ], + Text( + text, + style: theme.textTheme.labelLarge?.copyWith( + fontWeight: FontWeight.w600, + color: colors.foreground, + ), + ), + ], + ), + ), + ); + } + + _ButtonColors _getColors(ThemeData theme) { + switch (variant) { + case AuthButtonVariant.primary: + return _ButtonColors( + background: theme.colorScheme.primary, + foreground: theme.colorScheme.onPrimary, + disabledBackground: theme.colorScheme.primary.withOpacity(0.12), + disabledForeground: theme.colorScheme.onPrimary.withOpacity(0.38), + ); + case AuthButtonVariant.secondary: + return _ButtonColors( + background: theme.colorScheme.secondary, + foreground: theme.colorScheme.onSecondary, + disabledBackground: theme.colorScheme.secondary.withOpacity(0.12), + disabledForeground: theme.colorScheme.onSecondary.withOpacity(0.38), + ); + case AuthButtonVariant.outline: + return _ButtonColors( + background: Colors.transparent, + foreground: theme.colorScheme.primary, + disabledBackground: Colors.transparent, + disabledForeground: theme.colorScheme.primary.withOpacity(0.38), + ); + } + } +} + +enum AuthButtonVariant { + primary, + secondary, + outline, +} + +class _ButtonColors { + final Color background; + final Color foreground; + final Color disabledBackground; + final Color disabledForeground; + + const _ButtonColors({ + required this.background, + required this.foreground, + required this.disabledBackground, + required this.disabledForeground, + }); +} \ No newline at end of file diff --git a/lib/features/authentication/presentation/widgets/auth_form.dart b/lib/features/authentication/presentation/widgets/auth_form.dart new file mode 100644 index 0000000..9fa00dd --- /dev/null +++ b/lib/features/authentication/presentation/widgets/auth_form.dart @@ -0,0 +1,302 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +/// Reusable authentication form with email and password fields +class AuthForm extends StatefulWidget { + final GlobalKey formKey; + final String email; + final ValueChanged? onEmailChanged; + final String? emailError; + final String password; + final ValueChanged? onPasswordChanged; + final String? passwordError; + final VoidCallback? onSubmit; + final String submitButtonText; + final bool isLoading; + final bool showPasswordToggle; + final String? confirmPassword; + final ValueChanged? onConfirmPasswordChanged; + final String? confirmPasswordError; + final bool autofocusEmail; + final String? emailLabel; + final String? passwordLabel; + final String? confirmPasswordLabel; + + const AuthForm({ + super.key, + required this.formKey, + this.email = '', + this.onEmailChanged, + this.emailError, + this.password = '', + this.onPasswordChanged, + this.passwordError, + this.onSubmit, + this.submitButtonText = 'Submit', + this.isLoading = false, + this.showPasswordToggle = true, + this.confirmPassword, + this.onConfirmPasswordChanged, + this.confirmPasswordError, + this.autofocusEmail = true, + this.emailLabel = 'Email', + this.passwordLabel = 'Password', + this.confirmPasswordLabel = 'Confirm Password', + }); + + @override + State createState() => _AuthFormState(); +} + +class _AuthFormState extends State { + bool _obscurePassword = true; + bool _obscureConfirmPassword = true; + + @override + Widget build(BuildContext context) { + return Form( + key: widget.formKey, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _buildEmailField(), + const SizedBox(height: 16), + _buildPasswordField(), + if (widget.confirmPassword != null) ...[ + const SizedBox(height: 16), + _buildConfirmPasswordField(), + ], + const SizedBox(height: 24), + _buildSubmitButton(), + ], + ), + ); + } + + Widget _buildEmailField() { + return TextFormField( + initialValue: widget.email, + onChanged: widget.onEmailChanged, + autofocus: widget.autofocusEmail, + keyboardType: TextInputType.emailAddress, + textInputAction: TextInputAction.next, + decoration: InputDecoration( + labelText: widget.emailLabel, + hintText: 'Enter your email address', + prefixIcon: const Icon(Icons.email_outlined), + errorText: widget.emailError, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: BorderSide( + color: widget.emailError != null + ? Theme.of(context).colorScheme.error + : Theme.of(context).colorScheme.outline, + ), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: BorderSide( + color: widget.emailError != null + ? Theme.of(context).colorScheme.error + : Theme.of(context).colorScheme.primary, + width: 2, + ), + ), + ), + validator: (value) { + if (value == null || value.trim().isEmpty) { + return 'Email is required'; + } + + // Basic email validation regex + final emailRegex = RegExp( + r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$', + ); + + if (!emailRegex.hasMatch(value.trim())) { + return 'Please enter a valid email address'; + } + + return null; + }, + inputFormatters: [ + FilteringTextInputFormatter.deny(RegExp(r'\s')), // Prevent spaces + ], + ); + } + + Widget _buildPasswordField() { + return TextFormField( + initialValue: widget.password, + onChanged: widget.onPasswordChanged, + obscureText: _obscurePassword, + textInputAction: widget.confirmPassword != null + ? TextInputAction.next + : TextInputAction.done, + onFieldSubmitted: (_) { + if (widget.confirmPassword != null) { + // Move focus to confirm password field + FocusScope.of(context).nextFocus(); + } else { + // Submit form + widget.onSubmit?.call(); + } + }, + decoration: InputDecoration( + labelText: widget.passwordLabel, + hintText: 'Enter your password', + prefixIcon: const Icon(Icons.lock_outline), + suffixIcon: widget.showPasswordToggle + ? IconButton( + icon: Icon( + _obscurePassword ? Icons.visibility_off : Icons.visibility, + ), + onPressed: () { + setState(() { + _obscurePassword = !_obscurePassword; + }); + }, + tooltip: _obscurePassword ? 'Show password' : 'Hide password', + ) + : null, + errorText: widget.passwordError, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: BorderSide( + color: widget.passwordError != null + ? Theme.of(context).colorScheme.error + : Theme.of(context).colorScheme.outline, + ), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: BorderSide( + color: widget.passwordError != null + ? Theme.of(context).colorScheme.error + : Theme.of(context).colorScheme.primary, + width: 2, + ), + ), + ), + validator: (value) { + if (value == null || value.isEmpty) { + return 'Password is required'; + } + + if (value.length < 6) { + return 'Password must be at least 6 characters'; + } + + // Check for at least one letter and one number + final hasLetter = RegExp(r'[a-zA-Z]').hasMatch(value); + final hasNumber = RegExp(r'\d').hasMatch(value); + + if (!hasLetter || !hasNumber) { + return 'Password must contain both letters and numbers'; + } + + return null; + }, + ); + } + + Widget _buildConfirmPasswordField() { + return TextFormField( + initialValue: widget.confirmPassword, + onChanged: widget.onConfirmPasswordChanged, + obscureText: _obscureConfirmPassword, + textInputAction: TextInputAction.done, + onFieldSubmitted: (_) => widget.onSubmit?.call(), + decoration: InputDecoration( + labelText: widget.confirmPasswordLabel, + hintText: 'Confirm your password', + prefixIcon: const Icon(Icons.lock_outline), + suffixIcon: widget.showPasswordToggle + ? IconButton( + icon: Icon( + _obscureConfirmPassword ? Icons.visibility_off : Icons.visibility, + ), + onPressed: () { + setState(() { + _obscureConfirmPassword = !_obscureConfirmPassword; + }); + }, + tooltip: _obscureConfirmPassword ? 'Show password' : 'Hide password', + ) + : null, + errorText: widget.confirmPasswordError, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: BorderSide( + color: widget.confirmPasswordError != null + ? Theme.of(context).colorScheme.error + : Theme.of(context).colorScheme.outline, + ), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: BorderSide( + color: widget.confirmPasswordError != null + ? Theme.of(context).colorScheme.error + : Theme.of(context).colorScheme.primary, + width: 2, + ), + ), + ), + validator: (value) { + if (value == null || value.isEmpty) { + return 'Please confirm your password'; + } + + if (value != widget.password) { + return 'Passwords do not match'; + } + + return null; + }, + ); + } + + Widget _buildSubmitButton() { + return ElevatedButton( + onPressed: widget.isLoading ? null : widget.onSubmit, + style: ElevatedButton.styleFrom( + backgroundColor: Theme.of(context).colorScheme.primary, + foregroundColor: Theme.of(context).colorScheme.onPrimary, + disabledBackgroundColor: Theme.of(context).colorScheme.primary.withOpacity(0.12), + disabledForegroundColor: Theme.of(context).colorScheme.onPrimary.withOpacity(0.38), + elevation: 2, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + minimumSize: const Size(double.infinity, 48), + ), + child: widget.isLoading + ? SizedBox( + height: 20, + width: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + valueColor: AlwaysStoppedAnimation( + Theme.of(context).colorScheme.onPrimary, + ), + ), + ) + : Text( + widget.submitButtonText, + style: Theme.of(context).textTheme.labelLarge?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + ); + } +} \ No newline at end of file