963 lines
35 KiB
Dart
963 lines
35 KiB
Dart
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<LoginPage> createState() => _LoginPageState();
|
|
}
|
|
|
|
class _LoginPageState extends State<LoginPage> {
|
|
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<bool> _submitAuth({
|
|
required String email,
|
|
required String password,
|
|
}) async {
|
|
final supabase = context.read<SupabaseProvider>();
|
|
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<void> _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<ValidationResult?> _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<ValidationResult?> _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<void> _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 = <String>[
|
|
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<String>(_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<Offset>(
|
|
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<String>(
|
|
_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<SupabaseProvider>();
|
|
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<double>(
|
|
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),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|