1097 lines
34 KiB
Dart
1097 lines
34 KiB
Dart
import 'dart:math' as math;
|
|
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
import 'package:go_router/go_router.dart';
|
|
import '../../../../core/theme/app_typography.dart';
|
|
|
|
import '../../../../core/config/dev_config.dart';
|
|
import '../../../../core/constants/enums.dart';
|
|
import '../../../../core/constants/route_constants.dart';
|
|
import '../../../../core/constants/storage_keys.dart';
|
|
import '../../../../core/theme/theme_provider.dart';
|
|
import '../../../../core/utils/responsive_utils.dart';
|
|
import '../../../../core/utils/validators.dart';
|
|
import '../../../../shared/models/user_model.dart';
|
|
import '../../../../shared/providers/auth_provider.dart';
|
|
import '../../../../shared/widgets/app_button.dart';
|
|
import '../../../../shared/widgets/app_toast.dart';
|
|
import '../../../../shared/widgets/sidebar_logo.dart';
|
|
import '../../../settings/presentation/providers/settings_provider.dart';
|
|
import '../../data/repositories/auth_repository_impl.dart';
|
|
import '../widgets/login_colors.dart';
|
|
import '../widgets/login_hero_panel.dart';
|
|
|
|
class LoginScreen extends ConsumerStatefulWidget {
|
|
const LoginScreen({super.key, this.resetToken});
|
|
|
|
/// Token from email deep link (`/reset-password?token=` or `/login?token=`).
|
|
final String? resetToken;
|
|
|
|
@override
|
|
ConsumerState<LoginScreen> createState() => _LoginScreenState();
|
|
}
|
|
|
|
enum _AuthBackFace { forgot, reset }
|
|
|
|
class _LoginScreenState extends ConsumerState<LoginScreen>
|
|
with SingleTickerProviderStateMixin {
|
|
final _loginFormKey = GlobalKey<FormState>();
|
|
final _forgotFormKey = GlobalKey<FormState>();
|
|
final _resetFormKey = GlobalKey<FormState>();
|
|
final _cardMeasureKey = GlobalKey();
|
|
final _emailController = TextEditingController();
|
|
final _passwordController = TextEditingController();
|
|
final _forgotEmailController = TextEditingController();
|
|
final _newPasswordController = TextEditingController();
|
|
final _confirmPasswordController = TextEditingController();
|
|
|
|
late final AnimationController _flipController;
|
|
late final Animation<double> _flipAnimation;
|
|
|
|
bool _isLoading = false;
|
|
bool _isForgotLoading = false;
|
|
bool _isResetLoading = false;
|
|
bool _obscurePassword = true;
|
|
bool _obscureNewPassword = true;
|
|
bool _obscureConfirmPassword = true;
|
|
bool _rememberMe = false;
|
|
String? _forgotSuccessMessage;
|
|
String? _forgotErrorMessage;
|
|
String? _resetErrorMessage;
|
|
double? _cardHeight;
|
|
_AuthBackFace _backFace = _AuthBackFace.forgot;
|
|
String? _resetToken;
|
|
|
|
bool get _showingBack => _flipAnimation.value > 0.5;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_flipController = AnimationController(
|
|
vsync: this,
|
|
duration: const Duration(milliseconds: 700),
|
|
);
|
|
_flipAnimation = CurvedAnimation(
|
|
parent: _flipController,
|
|
curve: Curves.easeInOutCubic,
|
|
reverseCurve: Curves.easeInOutCubic,
|
|
);
|
|
|
|
final token = widget.resetToken?.trim();
|
|
if (token != null && token.isNotEmpty) {
|
|
_resetToken = token;
|
|
_backFace = _AuthBackFace.reset;
|
|
_flipController.value = 1.0;
|
|
}
|
|
|
|
WidgetsBinding.instance.addPostFrameCallback((_) => _loadRememberedEmail());
|
|
}
|
|
|
|
Future<void> _loadRememberedEmail() async {
|
|
final prefs = ref.read(sharedPreferencesProvider);
|
|
final remember = prefs.getBool(StorageKeys.rememberMe) ?? false;
|
|
final email = prefs.getString(StorageKeys.rememberedEmail);
|
|
if (!mounted) return;
|
|
setState(() {
|
|
_rememberMe = remember;
|
|
if (remember && email != null) {
|
|
_emailController.text = email;
|
|
}
|
|
});
|
|
}
|
|
|
|
Future<void> _persistRememberMe() async {
|
|
final prefs = ref.read(sharedPreferencesProvider);
|
|
if (_rememberMe) {
|
|
await prefs.setBool(StorageKeys.rememberMe, true);
|
|
await prefs.setString(
|
|
StorageKeys.rememberedEmail,
|
|
_emailController.text.trim(),
|
|
);
|
|
} else {
|
|
await prefs.remove(StorageKeys.rememberMe);
|
|
await prefs.remove(StorageKeys.rememberedEmail);
|
|
}
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_flipController.dispose();
|
|
_emailController.dispose();
|
|
_passwordController.dispose();
|
|
_forgotEmailController.dispose();
|
|
_newPasswordController.dispose();
|
|
_confirmPasswordController.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
Future<void> _login() async {
|
|
if (!_loginFormKey.currentState!.validate()) return;
|
|
|
|
setState(() => _isLoading = true);
|
|
await _persistRememberMe();
|
|
|
|
final success = await ref.read(authStateProvider.notifier).login(
|
|
LoginRequest(
|
|
email: _emailController.text.trim(),
|
|
password: _passwordController.text,
|
|
),
|
|
);
|
|
setState(() => _isLoading = false);
|
|
|
|
if (!mounted) return;
|
|
|
|
if (success) {
|
|
context.go(RouteConstants.dashboard);
|
|
} else {
|
|
final error = ref.read(authStateProvider).error;
|
|
showAppToastFromSnackBar(
|
|
context,
|
|
SnackBar(content: Text(error ?? 'Invalid credentials')),
|
|
);
|
|
}
|
|
}
|
|
|
|
Future<void> _submitForgotPassword() async {
|
|
if (!_forgotFormKey.currentState!.validate()) return;
|
|
|
|
setState(() {
|
|
_isForgotLoading = true;
|
|
_forgotSuccessMessage = null;
|
|
_forgotErrorMessage = null;
|
|
});
|
|
|
|
final repository = ref.read(authRepositoryProvider);
|
|
final result = await repository.forgotPassword(
|
|
ForgotPasswordRequest(email: _forgotEmailController.text.trim()),
|
|
);
|
|
|
|
if (!mounted) return;
|
|
setState(() => _isForgotLoading = false);
|
|
|
|
if (result.failure != null) {
|
|
setState(
|
|
() => _forgotErrorMessage =
|
|
result.failure?.message ?? 'Unable to send reset email.',
|
|
);
|
|
return;
|
|
}
|
|
|
|
setState(
|
|
() => _forgotSuccessMessage =
|
|
'Password reset instructions have been sent to your email.',
|
|
);
|
|
}
|
|
|
|
Future<void> _submitResetPassword() async {
|
|
if (!_resetFormKey.currentState!.validate()) return;
|
|
|
|
final token = _resetToken?.trim() ?? '';
|
|
if (token.isEmpty) {
|
|
setState(
|
|
() => _resetErrorMessage =
|
|
'This reset link is invalid or has expired. Request a new one.',
|
|
);
|
|
return;
|
|
}
|
|
|
|
setState(() {
|
|
_isResetLoading = true;
|
|
_resetErrorMessage = null;
|
|
});
|
|
|
|
final repository = ref.read(authRepositoryProvider);
|
|
final result = await repository.resetPassword(
|
|
ResetPasswordRequest(
|
|
token: token,
|
|
newPassword: _newPasswordController.text,
|
|
confirmPassword: _confirmPasswordController.text,
|
|
),
|
|
);
|
|
|
|
if (!mounted) return;
|
|
setState(() => _isResetLoading = false);
|
|
|
|
if (result.failure != null) {
|
|
setState(
|
|
() => _resetErrorMessage =
|
|
result.failure?.message ?? 'Unable to reset password.',
|
|
);
|
|
return;
|
|
}
|
|
|
|
// Clear reset form + token, then leave the deep-link route for a clean login.
|
|
_newPasswordController.clear();
|
|
_confirmPasswordController.clear();
|
|
_resetToken = null;
|
|
_resetFormKey.currentState?.reset();
|
|
setState(() {
|
|
_resetErrorMessage = null;
|
|
_backFace = _AuthBackFace.forgot;
|
|
_obscureNewPassword = true;
|
|
_obscureConfirmPassword = true;
|
|
});
|
|
|
|
showAppToastFromSnackBar(
|
|
context,
|
|
const SnackBar(
|
|
content: Text('Password reset successfully. Please sign in.'),
|
|
),
|
|
);
|
|
|
|
if (!mounted) return;
|
|
context.go(RouteConstants.login);
|
|
}
|
|
|
|
Future<void> _ensureCardHeightLocked() async {
|
|
if (_cardHeight != null) return;
|
|
await Future<void>.delayed(Duration.zero);
|
|
if (!mounted) return;
|
|
final box =
|
|
_cardMeasureKey.currentContext?.findRenderObject() as RenderBox?;
|
|
if (box != null && box.hasSize) {
|
|
setState(() => _cardHeight = box.size.height);
|
|
}
|
|
}
|
|
|
|
Future<void> _flipToForgot() async {
|
|
if (_flipController.isAnimating || _isLoading || _showingBack) return;
|
|
if (_forgotEmailController.text.isEmpty &&
|
|
_emailController.text.trim().isNotEmpty) {
|
|
_forgotEmailController.text = _emailController.text.trim();
|
|
}
|
|
setState(() {
|
|
_backFace = _AuthBackFace.forgot;
|
|
_forgotSuccessMessage = null;
|
|
_forgotErrorMessage = null;
|
|
});
|
|
await _ensureCardHeightLocked();
|
|
await _flipController.forward();
|
|
}
|
|
|
|
Future<void> _flipToSignIn() async {
|
|
if (_flipController.isAnimating ||
|
|
_isForgotLoading ||
|
|
_isResetLoading ||
|
|
!_showingBack) {
|
|
return;
|
|
}
|
|
await _flipController.reverse();
|
|
if (!mounted) return;
|
|
setState(() {
|
|
_backFace = _AuthBackFace.forgot;
|
|
_resetErrorMessage = null;
|
|
});
|
|
if (widget.resetToken != null) {
|
|
context.go(RouteConstants.login);
|
|
}
|
|
}
|
|
|
|
InputDecoration _fieldDecoration({
|
|
required LoginColors colors,
|
|
required String label,
|
|
required IconData icon,
|
|
Widget? suffix,
|
|
}) {
|
|
final radius = BorderRadius.circular(12);
|
|
return InputDecoration(
|
|
labelText: label,
|
|
floatingLabelBehavior: FloatingLabelBehavior.auto,
|
|
labelStyle: AppTypography.label2(color: colors.onSurfaceVariant),
|
|
floatingLabelStyle: AppTypography.label3(
|
|
weight: AppTypography.semiBold,
|
|
color: colors.primary,
|
|
),
|
|
prefixIcon: Icon(icon, color: colors.iconMuted, size: 18),
|
|
suffixIcon: suffix,
|
|
filled: true,
|
|
fillColor: colors.fieldFillColor,
|
|
contentPadding: const EdgeInsets.fromLTRB(12, 18, 12, 14),
|
|
border: OutlineInputBorder(
|
|
borderRadius: radius,
|
|
borderSide: BorderSide(color: colors.outlineSoft, width: 1.5),
|
|
),
|
|
enabledBorder: OutlineInputBorder(
|
|
borderRadius: radius,
|
|
borderSide: BorderSide(color: colors.outlineSoft, width: 1.5),
|
|
),
|
|
focusedBorder: OutlineInputBorder(
|
|
borderRadius: radius,
|
|
borderSide: BorderSide(color: colors.primary, width: 1.5),
|
|
),
|
|
errorBorder: OutlineInputBorder(
|
|
borderRadius: radius,
|
|
borderSide: BorderSide(color: colors.colorScheme.error, width: 1.5),
|
|
),
|
|
focusedErrorBorder: OutlineInputBorder(
|
|
borderRadius: radius,
|
|
borderSide: BorderSide(color: colors.colorScheme.error, width: 1.5),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _cardShell({
|
|
required LoginColors colors,
|
|
required Widget child,
|
|
}) {
|
|
return Container(
|
|
decoration: BoxDecoration(
|
|
color: colors.surface.withValues(alpha: 0.92),
|
|
borderRadius: BorderRadius.circular(28),
|
|
border: Border.all(color: colors.outlineSoft),
|
|
boxShadow: [
|
|
BoxShadow(
|
|
color: colors.cardShadowColor,
|
|
blurRadius: 32,
|
|
offset: const Offset(0, 12),
|
|
),
|
|
],
|
|
),
|
|
padding: const EdgeInsets.fromLTRB(36, 40, 36, 32),
|
|
child: child,
|
|
);
|
|
}
|
|
|
|
Widget _buildLogo(String logoUrl, LoginColors colors) {
|
|
return Center(
|
|
child: DecoratedBox(
|
|
decoration: BoxDecoration(
|
|
// Light: blend into the card. Dark: keep white so the logo stays readable.
|
|
color: colors.isDark ? Colors.white : colors.cardBackground,
|
|
borderRadius: BorderRadius.circular(12),
|
|
),
|
|
child: Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
|
|
child: SidebarLogo(
|
|
logoUrl: logoUrl,
|
|
height: 64,
|
|
width: 220,
|
|
fit: BoxFit.contain,
|
|
showBackground: false,
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _secureAccessFooter(LoginColors colors) {
|
|
return Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Row(
|
|
children: [
|
|
Expanded(child: Divider(color: colors.outlineSoft, height: 1)),
|
|
Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: 10),
|
|
child: Text(
|
|
'SECURE ACCESS',
|
|
style: AppTypography.caption1(color: colors.onSurfaceVariant)
|
|
.copyWith(letterSpacing: 0.6),
|
|
),
|
|
),
|
|
Expanded(child: Divider(color: colors.outlineSoft, height: 1)),
|
|
],
|
|
),
|
|
const SizedBox(height: 14),
|
|
Row(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: [
|
|
Icon(
|
|
Icons.verified_user_outlined,
|
|
size: 14,
|
|
color: colors.success,
|
|
),
|
|
const SizedBox(width: 6),
|
|
Text(
|
|
'Your data is protected and encrypted',
|
|
style: AppTypography.body4(color: colors.onSurfaceVariant),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
Widget _backToSignInButton(LoginColors colors) {
|
|
return Center(
|
|
child: TextButton(
|
|
onPressed: _flipToSignIn,
|
|
style: TextButton.styleFrom(
|
|
foregroundColor: colors.linkColor,
|
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8),
|
|
),
|
|
child: Text(
|
|
'Back to Sign In',
|
|
style: AppTypography.body3(weight: AppTypography.semiBold),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _primaryButtonTheme({
|
|
required LoginColors colors,
|
|
required Widget child,
|
|
}) {
|
|
return Theme(
|
|
data: Theme.of(context).copyWith(
|
|
elevatedButtonTheme: ElevatedButtonThemeData(
|
|
style: ElevatedButton.styleFrom(
|
|
backgroundColor: colors.primary,
|
|
foregroundColor: colors.onPrimary,
|
|
disabledBackgroundColor: colors.primary.withValues(alpha: 0.75),
|
|
minimumSize: const Size(double.infinity, 52),
|
|
elevation: 0,
|
|
shadowColor: colors.primary.withValues(alpha: 0.35),
|
|
shape: RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.circular(12),
|
|
),
|
|
textStyle: AppTypography.label1(weight: AppTypography.bold),
|
|
),
|
|
),
|
|
),
|
|
child: child,
|
|
);
|
|
}
|
|
|
|
Widget _buildSignInFace({
|
|
required LoginColors colors,
|
|
required String logoUrl,
|
|
}) {
|
|
return Form(
|
|
key: _loginFormKey,
|
|
child: SingleChildScrollView(
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: [
|
|
_buildLogo(logoUrl, colors),
|
|
const SizedBox(height: 20),
|
|
Text(
|
|
'Welcome',
|
|
style: AppTypography.heading5(
|
|
weight: AppTypography.bold,
|
|
color: colors.headingColor,
|
|
),
|
|
),
|
|
const SizedBox(height: 5),
|
|
Text(
|
|
'Sign in to access your BCPL workspace.',
|
|
style: AppTypography.body3(color: colors.subtitleColor),
|
|
),
|
|
const SizedBox(height: 26),
|
|
TextFormField(
|
|
controller: _emailController,
|
|
keyboardType: TextInputType.emailAddress,
|
|
autofillHints: const [AutofillHints.email],
|
|
validator: Validators.email,
|
|
style: AppTypography.body3(
|
|
weight: AppTypography.medium,
|
|
color: colors.headingColor,
|
|
),
|
|
decoration: _fieldDecoration(
|
|
colors: colors,
|
|
label: 'Email address',
|
|
icon: Icons.mail_outline_rounded,
|
|
),
|
|
),
|
|
const SizedBox(height: 18),
|
|
TextFormField(
|
|
controller: _passwordController,
|
|
obscureText: _obscurePassword,
|
|
autofillHints: const [AutofillHints.password],
|
|
validator: (v) => Validators.required(v, fieldName: 'Password'),
|
|
style: AppTypography.body3(
|
|
weight: AppTypography.medium,
|
|
color: colors.headingColor,
|
|
),
|
|
decoration: _fieldDecoration(
|
|
colors: colors,
|
|
label: 'Password',
|
|
icon: Icons.lock_outline_rounded,
|
|
suffix: IconButton(
|
|
tooltip: _obscurePassword ? 'Show password' : 'Hide password',
|
|
icon: Icon(
|
|
_obscurePassword
|
|
? Icons.visibility_outlined
|
|
: Icons.visibility_off_outlined,
|
|
color: colors.iconMuted,
|
|
size: 18,
|
|
),
|
|
onPressed: () =>
|
|
setState(() => _obscurePassword = !_obscurePassword),
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(height: 10),
|
|
Row(
|
|
children: [
|
|
SizedBox(
|
|
height: 34,
|
|
width: 34,
|
|
child: Checkbox(
|
|
value: _rememberMe,
|
|
activeColor: colors.primary,
|
|
checkColor: colors.onPrimary,
|
|
side: BorderSide(color: colors.outline, width: 1.5),
|
|
shape: RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.circular(5),
|
|
),
|
|
onChanged: (v) => setState(() => _rememberMe = v ?? false),
|
|
),
|
|
),
|
|
Text(
|
|
'Remember me',
|
|
style: AppTypography.body3(color: colors.labelColor),
|
|
),
|
|
const Spacer(),
|
|
TextButton(
|
|
onPressed: _flipToForgot,
|
|
style: TextButton.styleFrom(
|
|
foregroundColor: colors.linkColor,
|
|
padding: const EdgeInsets.symmetric(horizontal: 4),
|
|
minimumSize: Size.zero,
|
|
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
|
),
|
|
child: Text(
|
|
'Forgot password?',
|
|
style: AppTypography.body3(
|
|
weight: AppTypography.semiBold,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 14),
|
|
_primaryButtonTheme(
|
|
colors: colors,
|
|
child: AppButton(
|
|
label: _isLoading ? 'Signing in…' : 'Sign in',
|
|
isLoading: _isLoading,
|
|
onPressed: _login,
|
|
),
|
|
),
|
|
const SizedBox(height: 26),
|
|
_secureAccessFooter(colors),
|
|
if (DevConfig.screenPreviewEnabled) ...[
|
|
const SizedBox(height: 20),
|
|
Divider(color: colors.outlineSoft),
|
|
const SizedBox(height: 14),
|
|
Text(
|
|
'Login API unavailable? Browse all screens without signing in:',
|
|
textAlign: TextAlign.center,
|
|
style: AppTypography.body4(color: colors.subtitleColor),
|
|
),
|
|
const SizedBox(height: 12),
|
|
Theme(
|
|
data: Theme.of(context).copyWith(
|
|
outlinedButtonTheme: OutlinedButtonThemeData(
|
|
style: OutlinedButton.styleFrom(
|
|
foregroundColor: colors.primary,
|
|
side: BorderSide(color: colors.outlineSoft),
|
|
minimumSize: const Size(double.infinity, 46),
|
|
shape: RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.circular(12),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
child: AppButton(
|
|
label: 'Explore All Screens',
|
|
isOutlined: true,
|
|
onPressed: () {
|
|
ref.read(authStateProvider.notifier).loginAsDemo();
|
|
context.go(RouteConstants.screenGallery);
|
|
},
|
|
),
|
|
),
|
|
],
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildForgotFace({
|
|
required LoginColors colors,
|
|
required String logoUrl,
|
|
}) {
|
|
final fillHeight = _cardHeight != null;
|
|
return Form(
|
|
key: _forgotFormKey,
|
|
child: Column(
|
|
mainAxisSize: fillHeight ? MainAxisSize.max : MainAxisSize.min,
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: [
|
|
_buildLogo(logoUrl, colors),
|
|
const SizedBox(height: 20),
|
|
Text(
|
|
'Forgot Password',
|
|
style: AppTypography.heading5(
|
|
weight: AppTypography.bold,
|
|
color: colors.headingColor,
|
|
),
|
|
),
|
|
const SizedBox(height: 5),
|
|
Text(
|
|
'Enter your registered email address to receive password reset instructions.',
|
|
style: AppTypography.body3(color: colors.subtitleColor)
|
|
.copyWith(height: 1.45),
|
|
),
|
|
const SizedBox(height: 26),
|
|
TextFormField(
|
|
controller: _forgotEmailController,
|
|
keyboardType: TextInputType.emailAddress,
|
|
autofillHints: const [AutofillHints.email],
|
|
validator: Validators.email,
|
|
style: AppTypography.body3(
|
|
weight: AppTypography.medium,
|
|
color: colors.headingColor,
|
|
),
|
|
decoration: _fieldDecoration(
|
|
colors: colors,
|
|
label: 'Email address',
|
|
icon: Icons.mail_outline_rounded,
|
|
),
|
|
),
|
|
if (_forgotErrorMessage != null) ...[
|
|
const SizedBox(height: 12),
|
|
Text(
|
|
_forgotErrorMessage!,
|
|
style: AppTypography.body3(color: colors.colorScheme.error),
|
|
),
|
|
],
|
|
if (_forgotSuccessMessage != null) ...[
|
|
const SizedBox(height: 12),
|
|
Text(
|
|
_forgotSuccessMessage!,
|
|
style: AppTypography.body3(color: colors.primary),
|
|
),
|
|
],
|
|
const SizedBox(height: 18),
|
|
_primaryButtonTheme(
|
|
colors: colors,
|
|
child: AppButton(
|
|
label: _isForgotLoading ? 'Sending…' : 'Send Reset Link',
|
|
isLoading: _isForgotLoading,
|
|
onPressed: _submitForgotPassword,
|
|
),
|
|
),
|
|
if (fillHeight)
|
|
const Spacer()
|
|
else
|
|
const SizedBox(height: 22),
|
|
_backToSignInButton(colors),
|
|
const SizedBox(height: 18),
|
|
_secureAccessFooter(colors),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildResetFace({
|
|
required LoginColors colors,
|
|
required String logoUrl,
|
|
}) {
|
|
final fillHeight = _cardHeight != null;
|
|
return Form(
|
|
key: _resetFormKey,
|
|
child: Column(
|
|
mainAxisSize: fillHeight ? MainAxisSize.max : MainAxisSize.min,
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: [
|
|
_buildLogo(logoUrl, colors),
|
|
const SizedBox(height: 20),
|
|
Text(
|
|
'Reset Password',
|
|
style: AppTypography.heading5(
|
|
weight: AppTypography.bold,
|
|
color: colors.headingColor,
|
|
),
|
|
),
|
|
const SizedBox(height: 5),
|
|
Text(
|
|
'Choose a new password for your account.',
|
|
style: AppTypography.body3(color: colors.subtitleColor)
|
|
.copyWith(height: 1.45),
|
|
),
|
|
const SizedBox(height: 26),
|
|
TextFormField(
|
|
controller: _newPasswordController,
|
|
obscureText: _obscureNewPassword,
|
|
autofillHints: const [AutofillHints.newPassword],
|
|
validator: Validators.password,
|
|
style: AppTypography.body3(
|
|
weight: AppTypography.medium,
|
|
color: colors.headingColor,
|
|
),
|
|
decoration: _fieldDecoration(
|
|
colors: colors,
|
|
label: 'New password',
|
|
icon: Icons.lock_outline_rounded,
|
|
suffix: IconButton(
|
|
tooltip:
|
|
_obscureNewPassword ? 'Show password' : 'Hide password',
|
|
icon: Icon(
|
|
_obscureNewPassword
|
|
? Icons.visibility_outlined
|
|
: Icons.visibility_off_outlined,
|
|
color: colors.iconMuted,
|
|
size: 18,
|
|
),
|
|
onPressed: () => setState(
|
|
() => _obscureNewPassword = !_obscureNewPassword,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(height: 18),
|
|
TextFormField(
|
|
controller: _confirmPasswordController,
|
|
obscureText: _obscureConfirmPassword,
|
|
autofillHints: const [AutofillHints.newPassword],
|
|
validator: (v) {
|
|
if (v != _newPasswordController.text) {
|
|
return 'Passwords do not match';
|
|
}
|
|
return Validators.password(v);
|
|
},
|
|
style: AppTypography.body3(
|
|
weight: AppTypography.medium,
|
|
color: colors.headingColor,
|
|
),
|
|
decoration: _fieldDecoration(
|
|
colors: colors,
|
|
label: 'Confirm password',
|
|
icon: Icons.lock_outline_rounded,
|
|
suffix: IconButton(
|
|
tooltip: _obscureConfirmPassword
|
|
? 'Show password'
|
|
: 'Hide password',
|
|
icon: Icon(
|
|
_obscureConfirmPassword
|
|
? Icons.visibility_outlined
|
|
: Icons.visibility_off_outlined,
|
|
color: colors.iconMuted,
|
|
size: 18,
|
|
),
|
|
onPressed: () => setState(
|
|
() => _obscureConfirmPassword = !_obscureConfirmPassword,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
if (_resetErrorMessage != null) ...[
|
|
const SizedBox(height: 12),
|
|
Text(
|
|
_resetErrorMessage!,
|
|
style: AppTypography.body3(color: colors.colorScheme.error),
|
|
),
|
|
],
|
|
if ((_resetToken ?? '').isEmpty) ...[
|
|
const SizedBox(height: 12),
|
|
Text(
|
|
'This reset link is invalid or has expired. Request a new one from Forgot Password.',
|
|
style: AppTypography.body3(color: colors.colorScheme.error),
|
|
),
|
|
],
|
|
const SizedBox(height: 18),
|
|
_primaryButtonTheme(
|
|
colors: colors,
|
|
child: AppButton(
|
|
label: _isResetLoading ? 'Updating…' : 'Reset Password',
|
|
isLoading: _isResetLoading,
|
|
onPressed: _submitResetPassword,
|
|
),
|
|
),
|
|
if (fillHeight)
|
|
const Spacer()
|
|
else
|
|
const SizedBox(height: 22),
|
|
_backToSignInButton(colors),
|
|
const SizedBox(height: 18),
|
|
_secureAccessFooter(colors),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
void _scheduleCardHeightCapture() {
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
if (!mounted || _flipController.value > 0) return;
|
|
final box =
|
|
_cardMeasureKey.currentContext?.findRenderObject() as RenderBox?;
|
|
if (box == null || !box.hasSize) return;
|
|
final height = box.size.height;
|
|
if (_cardHeight == null || (_cardHeight! - height).abs() > 1) {
|
|
setState(() => _cardHeight = height);
|
|
}
|
|
});
|
|
}
|
|
|
|
Widget _buildAuthCard(LoginColors colors) {
|
|
final companyProfile = ref.watch(appSettingsProvider).companyProfile;
|
|
final branding = ref.watch(brandingProvider);
|
|
final logoUrl = resolveSidebarLogoUrl(
|
|
companyProfileLogo: companyProfile.logoUrl,
|
|
brandingLogo: branding.logoUrl,
|
|
);
|
|
|
|
if (_flipController.isDismissed) {
|
|
_scheduleCardHeightCapture();
|
|
}
|
|
|
|
return ConstrainedBox(
|
|
constraints: const BoxConstraints(maxWidth: 420),
|
|
child: AnimatedBuilder(
|
|
animation: _flipAnimation,
|
|
builder: (context, _) {
|
|
final t = _flipAnimation.value;
|
|
final angle = t * math.pi;
|
|
final showFront = angle <= math.pi / 2;
|
|
// Soft mid-flip fade for a premium feel.
|
|
final fade = 0.82 + 0.18 * math.cos(angle * 2).abs();
|
|
|
|
final front = KeyedSubtree(
|
|
key: _cardMeasureKey,
|
|
child: _cardShell(
|
|
colors: colors,
|
|
child: _buildSignInFace(colors: colors, logoUrl: logoUrl),
|
|
),
|
|
);
|
|
final backChild = _backFace == _AuthBackFace.reset
|
|
? _buildResetFace(colors: colors, logoUrl: logoUrl)
|
|
: _buildForgotFace(colors: colors, logoUrl: logoUrl);
|
|
final back = _cardShell(
|
|
colors: colors,
|
|
child: _cardHeight != null
|
|
? SizedBox.expand(child: backChild)
|
|
: backChild,
|
|
);
|
|
|
|
final face = showFront
|
|
? front
|
|
: Transform(
|
|
alignment: Alignment.center,
|
|
transform: Matrix4.identity()..rotateY(math.pi),
|
|
child: back,
|
|
);
|
|
|
|
final flipped = Opacity(
|
|
opacity: fade.clamp(0.82, 1.0),
|
|
child: Transform(
|
|
alignment: Alignment.center,
|
|
transform: Matrix4.identity()
|
|
..setEntry(3, 2, 0.00115)
|
|
..rotateY(angle),
|
|
child: face,
|
|
),
|
|
);
|
|
|
|
if (_cardHeight == null) return flipped;
|
|
|
|
// Lock height only while flipping / on the back face so the two
|
|
// faces match. Keep the front face unconstrained so validation
|
|
// errors can expand without overflowing.
|
|
final lockHeight =
|
|
_flipController.isAnimating || !_flipController.isDismissed;
|
|
if (!lockHeight) return flipped;
|
|
|
|
return SizedBox(
|
|
height: _cardHeight,
|
|
width: double.infinity,
|
|
child: flipped,
|
|
);
|
|
},
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildThemeToggle(LoginColors colors) {
|
|
final mode = ref.watch(themeModeProvider);
|
|
final isDarkActive = Theme.of(context).brightness == Brightness.dark ||
|
|
mode == ThemeModeOption.dark;
|
|
|
|
return Material(
|
|
color: colors.surface,
|
|
shape: StadiumBorder(
|
|
side: BorderSide(color: colors.outlineSoft),
|
|
),
|
|
elevation: 1,
|
|
shadowColor: colors.cardShadowColor,
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(6),
|
|
child: Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
_ThemeChip(
|
|
icon: Icons.light_mode_outlined,
|
|
active: !isDarkActive,
|
|
colors: colors,
|
|
onTap: () => ref
|
|
.read(themeModeProvider.notifier)
|
|
.setThemeMode(ThemeModeOption.light),
|
|
),
|
|
const SizedBox(width: 4),
|
|
_ThemeChip(
|
|
icon: Icons.dark_mode_outlined,
|
|
active: isDarkActive,
|
|
colors: colors,
|
|
onTap: () => ref
|
|
.read(themeModeProvider.notifier)
|
|
.setThemeMode(ThemeModeOption.dark),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildAmbient(LoginColors colors) {
|
|
return Stack(
|
|
children: [
|
|
Positioned.fill(
|
|
child: DecoratedBox(
|
|
decoration: BoxDecoration(
|
|
gradient: LinearGradient(
|
|
begin: Alignment.topCenter,
|
|
end: Alignment.bottomCenter,
|
|
colors: [colors.bgGradA, colors.bg],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
Positioned(
|
|
top: -120,
|
|
right: -100,
|
|
child: _Blob(color: colors.primaryContainer, size: 420),
|
|
),
|
|
Positioned(
|
|
bottom: -140,
|
|
left: -120,
|
|
child: _Blob(color: colors.tertiaryContainer, size: 360),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final isWide = context.isDesktop;
|
|
final colors = LoginColors.of(context);
|
|
|
|
return Scaffold(
|
|
backgroundColor: colors.bg,
|
|
body: Stack(
|
|
children: [
|
|
if (isWide)
|
|
Row(
|
|
children: [
|
|
const Expanded(flex: 105, child: LoginHeroPanel()),
|
|
Expanded(
|
|
flex: 100,
|
|
child: Stack(
|
|
children: [
|
|
Positioned.fill(child: _buildAmbient(colors)),
|
|
Center(
|
|
child: SingleChildScrollView(
|
|
padding: const EdgeInsets.symmetric(
|
|
horizontal: 32,
|
|
vertical: 40,
|
|
),
|
|
child: _buildAuthCard(colors),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
)
|
|
else
|
|
Stack(
|
|
children: [
|
|
Positioned.fill(child: _buildAmbient(colors)),
|
|
SingleChildScrollView(
|
|
child: Column(
|
|
children: [
|
|
const SizedBox(
|
|
height: 340,
|
|
width: double.infinity,
|
|
child: LoginHeroPanel(compact: true),
|
|
),
|
|
Padding(
|
|
padding: const EdgeInsets.fromLTRB(16, 24, 16, 40),
|
|
child: Center(child: _buildAuthCard(colors)),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
Positioned(
|
|
top: 22,
|
|
right: 22,
|
|
child: SafeArea(child: _buildThemeToggle(colors)),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _ThemeChip extends StatelessWidget {
|
|
const _ThemeChip({
|
|
required this.icon,
|
|
required this.active,
|
|
required this.colors,
|
|
required this.onTap,
|
|
});
|
|
|
|
final IconData icon;
|
|
final bool active;
|
|
final LoginColors colors;
|
|
final VoidCallback onTap;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Material(
|
|
color: active ? colors.primary : Colors.transparent,
|
|
shape: const CircleBorder(),
|
|
child: InkWell(
|
|
customBorder: const CircleBorder(),
|
|
onTap: onTap,
|
|
child: SizedBox(
|
|
width: 34,
|
|
height: 34,
|
|
child: Icon(
|
|
icon,
|
|
size: 17,
|
|
color: active ? colors.onPrimary : colors.onSurfaceVariant,
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _Blob extends StatelessWidget {
|
|
const _Blob({required this.color, required this.size});
|
|
|
|
final Color color;
|
|
final double size;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return IgnorePointer(
|
|
child: Container(
|
|
width: size,
|
|
height: size,
|
|
decoration: BoxDecoration(
|
|
shape: BoxShape.circle,
|
|
color: color.withValues(alpha: 0.35),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|