import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter/semantics.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; final String? formWideError; final VoidCallback? onErrorDismissed; 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', this.formWideError, this.onErrorDismissed, }); @override State createState() => _AuthFormState(); } class _AuthFormState extends State { bool _obscurePassword = true; bool _obscureConfirmPassword = true; String? _lastAnnouncedError; @override Widget build(BuildContext context) { return Form( key: widget.formKey, child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ // Form-wide error display if (widget.formWideError != null) ...[ _buildFormWideError(), const SizedBox(height: 16), ], _buildEmailField(), if (widget.emailError != null) ...[ const SizedBox(height: 8), _buildFieldError(widget.emailError!), ] else const SizedBox(height: 16), _buildPasswordField(), if (widget.passwordError != null) ...[ const SizedBox(height: 8), _buildFieldError(widget.passwordError!), ] else const SizedBox(height: 16), if (widget.confirmPassword != null) ...[ _buildConfirmPasswordField(), if (widget.confirmPasswordError != null) ...[ const SizedBox(height: 8), _buildFieldError(widget.confirmPasswordError!), ] else const SizedBox(height: 16), ], const SizedBox(height: 24), _buildSubmitButton(), ], ), ); } Widget _buildEmailField() { return TextFormField( initialValue: widget.email, onChanged: (value) { // Clear errors when user starts typing _clearErrorsOnUserInput('email'); widget.onEmailChanged?.call(value); }, 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: (value) { // Clear errors when user starts typing _clearErrorsOnUserInput('password'); widget.onPasswordChanged?.call(value); }, 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: (value) { // Clear errors when user starts typing _clearErrorsOnUserInput('confirmPassword'); widget.onConfirmPasswordChanged?.call(value); }, 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, ), ), ); } /// Builds form-wide error display with dismiss functionality Widget _buildFormWideError() { return Container( padding: const EdgeInsets.all(12), decoration: BoxDecoration( color: Theme.of(context).colorScheme.errorContainer, borderRadius: BorderRadius.circular(8), border: Border.all( color: Theme.of(context).colorScheme.error, width: 1, ), ), child: Row( children: [ Icon( Icons.error_outline, size: 20, color: Theme.of(context).colorScheme.error, ), const SizedBox(width: 8), Expanded( child: Text( widget.formWideError!, style: Theme.of(context).textTheme.bodyMedium?.copyWith( color: Theme.of(context).colorScheme.onErrorContainer, ), ), ), IconButton( icon: Icon( Icons.close, size: 18, color: Theme.of(context).colorScheme.error, ), onPressed: () { widget.onErrorDismissed?.call(); // Announce dismissal for accessibility _announceForAccessibility('Error message dismissed'); }, tooltip: 'Dismiss error', visualDensity: VisualDensity.compact, ), ], ), ); } /// Builds field-specific error display with icon Widget _buildFieldError(String error) { return Container( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), decoration: BoxDecoration( color: Theme.of(context).colorScheme.errorContainer.withOpacity(0.5), borderRadius: BorderRadius.circular(6), border: Border.left( color: Theme.of(context).colorScheme.error, width: 3, ), ), child: Row( children: [ Icon( Icons.info_outline, size: 16, color: Theme.of(context).colorScheme.error, ), const SizedBox(width: 8), Expanded( child: Text( error, style: Theme.of(context).textTheme.bodySmall?.copyWith( color: Theme.of(context).colorScheme.error, fontWeight: FontWeight.w500, ), ), ), ], ), ); } /// Clears errors when user starts typing in fields void _clearErrorsOnUserInput(String fieldType) { // Check if we need to announce error clearing for accessibility bool hadError = false; switch (fieldType) { case 'email': hadError = widget.emailError != null || widget.formWideError != null; break; case 'password': hadError = widget.passwordError != null || widget.formWideError != null; break; case 'confirmPassword': hadError = widget.confirmPasswordError != null || widget.formWideError != null; break; } // Don't announce anything if there was no error before if (!hadError) return; // If parent doesn't handle error clearing, we can at least announce if (widget.formWideError != null && _lastAnnouncedError != widget.formWideError) { _announceForAccessibility('Error cleared'); _lastAnnouncedError = widget.formWideError; } } /// Announces messages for screen readers void _announceForAccessibility(String message) { // Use Flutter's built-in semantics service for accessibility announcements SemanticsService.announce(message, TextDirection.ltr); } }