import "dart:async"; import "dart:math" as math; import "package:bus_running_record/pages/auth/verify_email_page.dart"; import "package:bus_running_record/provider/supabase_state.dart"; import "package:flutter/material.dart" as material; import "package:flutter/services.dart" as flutter_services show TextInput, TextInputAction; import "package:go_router/go_router.dart"; import "package:provider/provider.dart"; import "package:shadcn_flutter/shadcn_flutter.dart"; class LoginPage extends StatefulWidget { static const String routePath = "/login"; static final GoRoute route = GoRoute( path: routePath, builder: (context, state) => const LoginPage(), ); const LoginPage({super.key}); @override State createState() => _LoginPageState(); } class _LoginPageState extends State { static const _loginEmailKey = TextFieldKey("login_email"); static const _loginPasswordKey = TextFieldKey("login_password"); static const _signUpEmailKey = TextFieldKey("signup_email"); static const _signUpPasswordKey = TextFieldKey("signup_password"); static const _confirmPasswordKey = TextFieldKey("signup_confirm_password"); bool _isSubmitting = false; bool _isExitingScreen = false; bool _isSignUpMode = false; bool _isSignUpPasswordStep = false; bool _desktopChecklistEntered = false; String? _error; int _signInShakeNonce = 0; String _loginEmailDraft = ""; String _loginPasswordDraft = ""; String _signUpPasswordDraft = ""; String _signUpEmailDraft = ""; String _signUpConfirmPasswordDraft = ""; final FocusNode _loginEmailFocusNode = FocusNode(); final FocusNode _loginPasswordFocusNode = FocusNode(); final FocusNode _signUpEmailFocusNode = FocusNode(); final FocusNode _signUpPasswordFocusNode = FocusNode(); final FocusNode _signUpConfirmPasswordFocusNode = FocusNode(); @override void initState() { super.initState(); _signUpPasswordFocusNode.addListener(_onSignUpPasswordFocusChanged); } @override void dispose() { _loginEmailFocusNode.dispose(); _loginPasswordFocusNode.dispose(); _signUpEmailFocusNode.dispose(); _signUpPasswordFocusNode.removeListener(_onSignUpPasswordFocusChanged); _signUpPasswordFocusNode.dispose(); _signUpConfirmPasswordFocusNode.dispose(); super.dispose(); } void _onSignUpPasswordFocusChanged() { if (!mounted) return; setState(() {}); } Future _submitAuth({ required String email, required String password, }) async { final supabase = context.read(); setState(() { _isSubmitting = true; _error = null; }); try { if (_isSignUpMode) { await supabase.signUpWithPassword(email: email, password: password); if (mounted) { flutter_services.TextInput.finishAutofillContext(shouldSave: true); // Force token-based verification flow after sign-up. await supabase.signOut(); if (!mounted) return false; final encodedEmail = Uri.encodeQueryComponent(email); await _animateOutAndGo( "${VerifyEmailPage.routePath}?email=$encodedEmail", ); } return true; } else { await supabase.signInWithPassword(email: email, password: password); } if (mounted) { flutter_services.TextInput.finishAutofillContext(shouldSave: true); } return true; } catch (error, stackTrace) { _logAuthError("submitAuth", error, stackTrace); if (!_isSignUpMode && _isEmailNotConfirmedError(error)) { try { await supabase.signOut(); await supabase.resendSignUpOtp(email: email); if (mounted) { final encodedEmail = Uri.encodeQueryComponent(email); await _animateOutAndGo( "${VerifyEmailPage.routePath}?email=$encodedEmail", ); } return true; } catch (resendError, resendStackTrace) { _logAuthError( "submitAuth/signInEmailNotConfirmedResend", resendError, resendStackTrace, ); if (mounted) { setState( () => _error = "Email is not confirmed yet. We couldn't resend a token right now.", ); } return false; } } if (_isSignUpMode && _isExistingAccountError(error)) { if (mounted) { await supabase.signOut(); if (!mounted) return false; final encodedEmail = Uri.encodeQueryComponent(email); await _animateOutAndGo( "${VerifyEmailPage.routePath}?email=$encodedEmail", ); } return true; } if (mounted) { if (!_isSignUpMode) { _triggerSignInShake(); } setState(() => _error = _formatAuthError(error)); } return false; } finally { if (mounted) { setState(() => _isSubmitting = false); } } } void _logAuthError(String operation, Object error, StackTrace stackTrace) { debugPrint("[AuthPage] $operation failed (${error.runtimeType}): $error"); debugPrintStack(stackTrace: stackTrace); } void _triggerSignInShake() { if (!mounted) return; setState(() { _signInShakeNonce += 1; }); } Future _animateOutAndGo(String location) async { if (!mounted) return; if (_isExitingScreen) return; FocusScope.of(context).unfocus(); setState(() { _isExitingScreen = true; }); await Future.delayed(const Duration(milliseconds: 260)); if (mounted) { context.go(location); } } bool _isExistingAccountError(Object error) { final message = error.toString().toLowerCase(); return message.contains("already registered") || message.contains("already exists") || message.contains("user already exists") || message.contains("database error saving new user") || (message.contains("unexpected_failure") && message.contains("saving new user")) || message.contains("duplicate key value violates unique constraint"); } bool _isEmailNotConfirmedError(Object error) { final message = error.toString().toLowerCase(); return message.contains("email not confirmed") || message.contains("email_not_confirmed"); } String _formatAuthError(Object error) { final raw = error.toString().trim(); final wrapped = RegExp( r'^[A-Za-z_]\w*\(message:\s*(.*?)(?:,\s*[A-Za-z_]\w*:\s*.*)?\)$', dotAll: true, ).firstMatch(raw); final message = wrapped?.group(1) ?? raw; return message.trim(); } void _submitLoginFromKeyboard(BuildContext fieldContext) { try { fieldContext.submitForm(); } catch (error, stackTrace) { debugPrint("[AuthPage] submitLoginFromKeyboard fallback: $error"); debugPrintStack(stackTrace: stackTrace); final email = _loginEmailDraft.trim(); final password = _loginPasswordDraft; if (email.isNotEmpty && password.isNotEmpty) { unawaited(_submitAuth(email: email, password: password)); } } finally { FocusScope.of(fieldContext).unfocus(); } } void _submitSignUpEmailFromKeyboard(BuildContext fieldContext) { try { fieldContext.submitForm(); } catch (error, stackTrace) { debugPrint("[AuthPage] submitSignUpEmailFromKeyboard fallback: $error"); debugPrintStack(stackTrace: stackTrace); final email = _signUpEmailDraft.trim(); if (email.isNotEmpty) { _handleSignUpEmailContinue(email); } } finally { FocusScope.of(fieldContext).unfocus(); } } void _submitSignUpPasswordFromKeyboard(BuildContext fieldContext) { try { fieldContext.submitForm(); } catch (error, stackTrace) { debugPrint("[AuthPage] submitSignUpPasswordFromKeyboard fallback: $error"); debugPrintStack(stackTrace: stackTrace); final email = _signUpEmailDraft.trim(); final password = _signUpPasswordDraft; if (email.isNotEmpty && password.isNotEmpty) { unawaited(_submitAuth(email: email, password: password)); } } finally { FocusScope.of(fieldContext).unfocus(); } } Future _validateLoginSubmission(String? value) async { final email = _loginEmailDraft.trim(); final password = value ?? ""; final success = await _submitAuth(email: email, password: password); if (success) { return null; } return InvalidResult( _error ?? "Unable to sign in. Please try again.", state: FormValidationMode.submitted, ); } Future _validateSignUpSubmission(String? value) async { final email = _signUpEmailDraft.trim(); final password = value ?? ""; final success = await _submitAuth(email: email, password: password); if (success) { return null; } return InvalidResult( _error ?? "Unable to create account. Please try again.", state: FormValidationMode.submitted, ); } void _toggleMode() { if (_isSubmitting || _isExitingScreen) return; setState(() { _isSignUpMode = !_isSignUpMode; _error = null; }); } void _continueToPasswordStep(String email) { setState(() { _signUpEmailDraft = email; _isSignUpPasswordStep = true; _desktopChecklistEntered = false; _error = null; }); WidgetsBinding.instance.addPostFrameCallback((_) { if (!mounted) return; _signUpPasswordFocusNode.requestFocus(); setState(() { _desktopChecklistEntered = true; }); }); } void _handleSignUpEmailContinue(String email) { _continueToPasswordStep(email); } Future _backToEmailStep() async { final isDesktop = MediaQuery.of(context).size.width >= 600; if (isDesktop && _desktopChecklistEntered) { setState(() { _desktopChecklistEntered = false; }); await Future.delayed(const Duration(milliseconds: 50)); if (!mounted) return; } setState(() { _isSignUpPasswordStep = false; _desktopChecklistEntered = false; _error = null; }); WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted) { _signUpEmailFocusNode.requestFocus(); } }); } Widget _buildErrorBlock(String? initializationError) { final messages = [ if (initializationError != null) "Supabase init failed: $initializationError", if (_error != null) _error!, ]; return AnimatedCrossFade( duration: const Duration(milliseconds: 260), sizeCurve: Curves.easeInOutCubic, firstCurve: Curves.easeOutCubic, secondCurve: Curves.easeInCubic, crossFadeState: messages.isEmpty ? CrossFadeState.showFirst : CrossFadeState.showSecond, firstChild: const SizedBox(key: ValueKey("no-error"), height: 24), secondChild: Column( key: const ValueKey("with-error"), children: [ const Gap(24), for (var i = 0; i < messages.length; i++) ...[ Text( messages[i], style: TextStyle( color: Theme.of(context).colorScheme.destructive, ), ).small.semiBold, if (i < messages.length - 1) const Gap(8), ], const Gap(24), ], ), ); } Widget _buildPasswordRequirement({required bool met, required String label}) { final color = met ? Theme.of(context).colorScheme.primary : Theme.of(context).colorScheme.mutedForeground; return Row( children: [ Icon( met ? LucideIcons.circleCheck : LucideIcons.circle, color: color, size: 14, ), const Gap(8), Text(label, style: TextStyle(color: color)).small, ], ); } Widget _buildLoginForm(String? initializationError) { return AutofillGroup( child: Form( key: const ValueKey("login-form"), child: Column( crossAxisAlignment: CrossAxisAlignment.center, mainAxisSize: MainAxisSize.min, children: [ Text("Welcome back").x3Large.black, Text("Sign in with your account.").xSmall.muted, const Gap(26), SizedBox( width: double.infinity, child: FormField( key: _loginEmailKey, label: const Text("Email"), validator: const NotEmptyValidator(message: "Email is required") & const EmailValidator(message: "Enter a valid email"), showErrors: const { FormValidationMode.changed, FormValidationMode.submitted, }, child: TextField( initialValue: _loginEmailDraft, placeholder: const Text("Enter your email"), focusNode: _loginEmailFocusNode, keyboardType: TextInputType.emailAddress, textInputAction: flutter_services.TextInputAction.next, autofillHints: const [AutofillHints.email], features: const [ InputFeature.leading(Icon(LucideIcons.mail)), ], onChanged: (value) { _loginEmailDraft = value; }, onSubmitted: (_) => _loginPasswordFocusNode.requestFocus(), ), ), ), const Gap(16), SizedBox( width: double.infinity, child: FormField( key: _loginPasswordKey, label: const Text("Password"), validator: const NotEmptyValidator(message: "Password is required") & const LengthValidator( min: 8, message: "Minimum 8 characters", ) & ValidationMode( ValidatorBuilder(_validateLoginSubmission), mode: {FormValidationMode.submitted}, ), showErrors: const { FormValidationMode.changed, FormValidationMode.submitted, }, child: TextField( initialValue: _loginPasswordDraft, placeholder: const Text("Your password"), focusNode: _loginPasswordFocusNode, obscureText: true, textInputAction: flutter_services.TextInputAction.done, features: const [ InputFeature.leading(Icon(LucideIcons.lock)), ], autofillHints: const [AutofillHints.password], onChanged: (value) { _loginPasswordDraft = value; }, onEditingComplete: () => _submitLoginFromKeyboard(context), onSubmitted: (_) => _submitLoginFromKeyboard(context), ), ), ), _buildErrorBlock(initializationError), SizedBox( width: double.infinity, child: SubmitButton( loading: const Text("Signing in..."), loadingTrailing: AspectRatio( aspectRatio: 1, child: CircularProgressIndicator( onSurface: true, strokeWidth: 2, ), ), alignment: Alignment.center, child: const Text("Sign in"), ), ), const Gap(8), Button.text( onPressed: _isSubmitting ? null : _toggleMode, child: const Text("Need an account? Create one"), ), const Gap(24), material.ElevatedButton( onPressed: () { context.go("/deprecated"); }, child: const Text("Use Legacy System"), ) ], ), ), ); } Widget _buildSignUpForm(String? initializationError) { final isDesktop = MediaQuery.of(context).size.width >= 600; final showPasswordChecklist = isDesktop ? _desktopChecklistEntered : _signUpPasswordFocusNode.hasFocus; return AnimatedSwitcher( duration: const Duration(milliseconds: 260), switchInCurve: Curves.easeOutCubic, switchOutCurve: Curves.easeInCubic, layoutBuilder: (currentChild, previousChildren) { return Stack( clipBehavior: Clip.none, alignment: Alignment.center, children: [ ...previousChildren, if (currentChild != null) currentChild, ], ); }, transitionBuilder: (child, animation) { final slide = Tween( begin: const Offset(0.05, 0), end: Offset.zero, ).animate( CurvedAnimation(parent: animation, curve: Curves.easeOutCubic), ); return FadeTransition( opacity: animation, child: SlideTransition(position: slide, child: child), ); }, child: _isSignUpPasswordStep ? AutofillGroup( child: Form( key: const ValueKey("signup-password-step"), child: Column( crossAxisAlignment: CrossAxisAlignment.center, mainAxisSize: MainAxisSize.min, children: [ Text("Create account").x3Large.black, Text( "Set a password for\n$_signUpEmailDraft.", ).xSmall.muted.textCenter, const Gap(26), SizedBox( width: double.infinity, child: FormField( key: _signUpPasswordKey, label: const Text("Password"), validator: const NotEmptyValidator( message: "Password is required", ) & const LengthValidator( min: 8, message: "Minimum 8 characters", ) & const SafePasswordValidator( requireDigit: true, requireLowercase: true, requireUppercase: true, requireSpecialChar: true, message: "Password must include lowercase, uppercase, number, and symbol.", ), showErrors: const { FormValidationMode.changed, FormValidationMode.submitted, }, child: TextField( initialValue: _signUpPasswordDraft, placeholder: const Text("Create a password"), obscureText: true, features: const [ InputFeature.leading(Icon(LucideIcons.lock)), ], autofillHints: const [AutofillHints.newPassword], focusNode: _signUpPasswordFocusNode, textInputAction: flutter_services.TextInputAction.next, onSubmitted: (_) => _signUpConfirmPasswordFocusNode.requestFocus(), onChanged: (value) { setState(() { _signUpPasswordDraft = value; }); }, ), ), ), AnimatedSize( duration: const Duration(milliseconds: 220), curve: Curves.easeInOutCubic, child: showPasswordChecklist ? Column( children: [ const Gap(12), Align( alignment: Alignment.centerLeft, child: AnimatedOpacity( duration: const Duration(milliseconds: 180), opacity: 1, child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ _buildPasswordRequirement( met: RegExp( r"[A-Z]", ).hasMatch(_signUpPasswordDraft), label: "Uppercase letter", ), const Gap(6), _buildPasswordRequirement( met: RegExp( r"[a-z]", ).hasMatch(_signUpPasswordDraft), label: "Lowercase letter", ), const Gap(6), _buildPasswordRequirement( met: RegExp( r"\d", ).hasMatch(_signUpPasswordDraft), label: "Number", ), const Gap(6), _buildPasswordRequirement( met: RegExp( r"[\W_]", ).hasMatch(_signUpPasswordDraft), label: "Special character (e.g. !?<>@#\$%)", ), const Gap(6), _buildPasswordRequirement( met: _signUpPasswordDraft.length >= 8, label: "8 characters or more", ), ], ), ), ), ], ) : const SizedBox.shrink(), ), const Gap(16), SizedBox( width: double.infinity, child: FormField( key: _confirmPasswordKey, label: const Text("Confirm Password"), validator: CompareWith.equal( _signUpPasswordKey, message: "Passwords do not match", ) & ValidationMode( ValidatorBuilder( _validateSignUpSubmission, ), mode: {FormValidationMode.submitted}, ), showErrors: const { FormValidationMode.changed, FormValidationMode.submitted, }, child: TextField( initialValue: _signUpConfirmPasswordDraft, placeholder: const Text("Confirm password"), focusNode: _signUpConfirmPasswordFocusNode, obscureText: true, textInputAction: flutter_services.TextInputAction.done, features: const [ InputFeature.leading(Icon(LucideIcons.lockKeyhole)), ], autofillHints: const [AutofillHints.newPassword], onChanged: (value) { _signUpConfirmPasswordDraft = value; }, onEditingComplete: () => _submitSignUpPasswordFromKeyboard(context), onSubmitted: (_) => _submitSignUpPasswordFromKeyboard(context), ), ), ), _buildErrorBlock(initializationError), SizedBox( width: double.infinity, child: SubmitButton( loading: const Text("Creating account..."), loadingTrailing: AspectRatio( aspectRatio: 1, child: CircularProgressIndicator( onSurface: true, strokeWidth: 2, ), ), alignment: Alignment.center, child: const Text("Create account"), ), ), const Gap(8), Button.text( onPressed: _isSubmitting ? null : () => unawaited(_backToEmailStep()), child: const Text("Back"), ), ], ), ), ) : AutofillGroup( child: Form( key: const ValueKey("signup-email-step"), onSubmit: (context, values) { final email = (_signUpEmailKey[values] ?? "").trim(); _handleSignUpEmailContinue(email); }, child: Column( crossAxisAlignment: CrossAxisAlignment.center, mainAxisSize: MainAxisSize.min, children: [ Text("Create account").x3Large.black, Text( "Start with your email address", ).xSmall.muted.textCenter, const Gap(26), SizedBox( width: double.infinity, child: FormField( key: _signUpEmailKey, label: const Text("Email"), validator: const NotEmptyValidator( message: "Email is required", ) & const EmailValidator( message: "Enter a valid email", ), showErrors: const { FormValidationMode.changed, FormValidationMode.submitted, }, child: TextField( initialValue: _signUpEmailDraft, placeholder: const Text("Enter your email"), focusNode: _signUpEmailFocusNode, keyboardType: TextInputType.emailAddress, textInputAction: flutter_services.TextInputAction.done, autofillHints: const [AutofillHints.email], features: const [ InputFeature.leading(Icon(LucideIcons.mail)), ], onChanged: (value) { _signUpEmailDraft = value; }, onEditingComplete: () => _submitSignUpEmailFromKeyboard(context), onSubmitted: (_) => _submitSignUpEmailFromKeyboard(context), ), ), ), _buildErrorBlock(initializationError), SizedBox( width: double.infinity, child: SubmitButton( loading: const Text("Continuing..."), loadingTrailing: AspectRatio( aspectRatio: 1, child: CircularProgressIndicator( onSurface: true, strokeWidth: 2, ), ), alignment: Alignment.center, child: const Text("Continue"), ), ), const Gap(8), Button.text( onPressed: _isSubmitting ? null : _toggleMode, child: const Text("Already have an account? Sign in"), ), ], ), ), ), ); } @override Widget build(BuildContext context) { final supabase = context.watch(); const String? initializationError = null; final topPadding = MediaQuery.of(context).padding.top; final bottomPadding = MediaQuery.of(context).padding.bottom; bool isMobile = MediaQuery.of(context).size.width < 600; final header = Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text("ROADBOUND", style: TextStyle(height: 0.9, fontSize: 24)).black, Text( "by IMBENJI.NET LTD", style: TextStyle(height: 0.9, fontSize: 12), ).bold.muted, ], ); final showValidatingState = !_isSignUpMode && supabase.isValidatingSession; final shouldHideForExit = _isExitingScreen && !showValidatingState; final content = Column( mainAxisSize: MainAxisSize.min, children: [ Padding( padding: const EdgeInsets.all(16), child: Stack( clipBehavior: Clip.none, alignment: Alignment.center, children: [ IgnorePointer( ignoring: _isSignUpMode || shouldHideForExit, child: ExcludeFocus( excluding: _isSignUpMode, child: AnimatedSlide( duration: const Duration(milliseconds: 320), curve: Curves.easeInOutCubic, offset: _isSignUpMode ? const Offset(-1.0, 0) : Offset.zero, child: AnimatedOpacity( duration: const Duration(milliseconds: 220), opacity: _isSignUpMode ? 0 : 1, child: _signInShakeNonce == 0 ? _buildLoginForm(initializationError) : TweenAnimationBuilder( key: ValueKey("login-shake-$_signInShakeNonce"), tween: Tween(begin: 0, end: 1), duration: const Duration(milliseconds: 700), curve: Curves.easeOut, builder: (context, value, child) { final amplitude = 12 * (1 - value); final dx = math.sin(value * math.pi * 6) * amplitude; return Transform.translate( offset: Offset(dx, 0), child: child, ); }, child: _buildLoginForm(initializationError), ), ), ), ), ), IgnorePointer( ignoring: !_isSignUpMode || shouldHideForExit, child: ExcludeFocus( excluding: !_isSignUpMode, child: AnimatedSlide( duration: const Duration(milliseconds: 320), curve: Curves.easeInOutCubic, offset: _isSignUpMode ? Offset.zero : const Offset(1.0, 0), child: AnimatedOpacity( duration: const Duration(milliseconds: 220), opacity: _isSignUpMode ? 1 : 0, child: _buildSignUpForm(initializationError), ), ), ), ), if (showValidatingState) Positioned.fill( child: IgnorePointer( child: Container( color: Theme.of( context, ).colorScheme.background.withValues(alpha: 0.85), child: const Center( child: Column( mainAxisSize: MainAxisSize.min, children: [ CircularProgressIndicator(), Gap(12), Text("Validating session..."), ], ), ), ), ), ), ], ), ), ], ); bool isKeyboardOpen = MediaQuery.of(context).viewInsets.bottom > 0; return Scaffold( child: Center( child: ConstrainedBox( constraints: const BoxConstraints(maxWidth: 460), child: Padding( padding: const EdgeInsets.symmetric(horizontal: 16), child: Column( children: [ Gap(topPadding + 16), header, if (false) ...[ content, const Spacer(), ] else ...[ Expanded( child: Center( child: AnimatedSlide( duration: const Duration(milliseconds: 260), curve: Curves.easeInCubic, offset: _isExitingScreen ? const Offset(0, -0.08) : Offset.zero, child: AnimatedOpacity( duration: const Duration(milliseconds: 220), curve: Curves.easeInCubic, opacity: shouldHideForExit ? 0 : 1, child: content, ), ), ), ), ], Visibility( visible: !isKeyboardOpen, maintainState: true, maintainAnimation: true, maintainSize: true, child: Column( children: [ Text( "IMBENJI.NET LTD is a private limited company registered in England & Wales. Company number: 16955294.", ).xSmall.muted.textCenter, Gap(bottomPadding + 16), ], ), ), ], ), ), ), ), ); } }