forgot design flip

This commit is contained in:
Surendiran 2026-07-15 12:42:41 +05:30
parent 9856ff08b2
commit f871af752b

View File

@ -1,3 +1,5 @@
import 'dart:math' as math;
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
@ -17,6 +19,7 @@ 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';
@ -27,17 +30,40 @@ class LoginScreen extends ConsumerStatefulWidget {
ConsumerState<LoginScreen> createState() => _LoginScreenState();
}
class _LoginScreenState extends ConsumerState<LoginScreen> {
final _formKey = GlobalKey<FormState>();
class _LoginScreenState extends ConsumerState<LoginScreen>
with SingleTickerProviderStateMixin {
final _loginFormKey = GlobalKey<FormState>();
final _forgotFormKey = GlobalKey<FormState>();
final _cardMeasureKey = GlobalKey();
final _emailController = TextEditingController();
final _passwordController = TextEditingController();
final _forgotEmailController = TextEditingController();
late final AnimationController _flipController;
late final Animation<double> _flipAnimation;
bool _isLoading = false;
bool _isForgotLoading = false;
bool _obscurePassword = true;
bool _rememberMe = false;
String? _forgotSuccessMessage;
String? _forgotErrorMessage;
double? _cardHeight;
bool get _showingForgot => _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,
);
WidgetsBinding.instance.addPostFrameCallback((_) => _loadRememberedEmail());
}
@ -70,13 +96,15 @@ class _LoginScreenState extends ConsumerState<LoginScreen> {
@override
void dispose() {
_flipController.dispose();
_emailController.dispose();
_passwordController.dispose();
_forgotEmailController.dispose();
super.dispose();
}
Future<void> _login() async {
if (!_formKey.currentState!.validate()) return;
if (!_loginFormKey.currentState!.validate()) return;
setState(() => _isLoading = true);
await _persistRememberMe();
@ -102,6 +130,64 @@ class _LoginScreenState extends ConsumerState<LoginScreen> {
}
}
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.toString());
return;
}
setState(
() => _forgotSuccessMessage =
'Password reset instructions have been sent to your email.',
);
}
Future<void> _flipToForgot() async {
if (_flipController.isAnimating || _isLoading || _showingForgot) return;
if (_forgotEmailController.text.isEmpty &&
_emailController.text.trim().isNotEmpty) {
_forgotEmailController.text = _emailController.text.trim();
}
setState(() {
_forgotSuccessMessage = null;
_forgotErrorMessage = null;
});
// Lock card height from the sign-in face before flipping.
if (_cardHeight == null) {
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);
}
}
await _flipController.forward();
}
void _flipToSignIn() {
if (_flipController.isAnimating || _isForgotLoading || !_showingForgot) {
return;
}
_flipController.reverse();
}
InputDecoration _fieldDecoration({
required LoginColors colors,
required String label,
@ -149,16 +235,11 @@ class _LoginScreenState extends ConsumerState<LoginScreen> {
);
}
Widget _buildLoginCard(LoginColors colors) {
final companyProfile = ref.watch(appSettingsProvider).companyProfile;
final branding = ref.watch(brandingProvider);
final logoUrl = resolveSidebarLogoUrl(
companyProfileLogo: companyProfile.logoUrl,
brandingLogo: branding.logoUrl,
);
Widget _cardShell({
required LoginColors colors,
required Widget child,
}) {
return Container(
constraints: const BoxConstraints(maxWidth: 420),
decoration: BoxDecoration(
color: colors.surface.withValues(alpha: 0.92),
borderRadius: BorderRadius.circular(28),
@ -172,240 +253,492 @@ class _LoginScreenState extends ConsumerState<LoginScreen> {
],
),
padding: const EdgeInsets.fromLTRB(36, 40, 36, 32),
child: Form(
key: _formKey,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Center(
child: SidebarLogo(
logoUrl: logoUrl,
height: 64,
width: 220,
fit: BoxFit.contain,
showBackground: false,
child: child,
);
}
Widget _buildLogo(String logoUrl) {
return Center(
child: SidebarLogo(
logoUrl: logoUrl,
height: 64,
width: 220,
fit: BoxFit.contain,
showBackground: false,
),
);
}
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: GoogleFonts.inter(
fontSize: 15,
fontWeight: FontWeight.w700,
letterSpacing: 0.2,
),
),
),
),
child: child,
);
}
Widget _buildSignInFace({
required LoginColors colors,
required String logoUrl,
}) {
return Form(
key: _loginFormKey,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_buildLogo(logoUrl),
const SizedBox(height: 20),
Text(
'Welcome back',
style: GoogleFonts.manrope(
fontSize: 24,
fontWeight: FontWeight.w800,
letterSpacing: -0.3,
color: colors.headingColor,
),
),
const SizedBox(height: 5),
Text(
'Sign in to continue to your BCPL workspace.',
style: GoogleFonts.inter(
fontSize: 13.5,
color: colors.subtitleColor,
),
),
const SizedBox(height: 26),
TextFormField(
controller: _emailController,
keyboardType: TextInputType.emailAddress,
autofillHints: const [AutofillHints.email],
validator: Validators.email,
style: GoogleFonts.inter(
fontSize: 14.5,
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: GoogleFonts.inter(
fontSize: 14.5,
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: GoogleFonts.inter(
fontSize: 13,
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: GoogleFonts.inter(
fontSize: 13,
fontWeight: FontWeight.w600,
),
),
),
],
),
const SizedBox(height: 14),
_primaryButtonTheme(
colors: colors,
child: AppButton(
label: _isLoading ? 'Signing in…' : 'Sign in',
isLoading: _isLoading,
onPressed: _login,
),
),
const SizedBox(height: 26),
Row(
children: [
Expanded(child: Divider(color: colors.outlineSoft, height: 1)),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 10),
child: Text(
'SECURE ACCESS',
style: GoogleFonts.inter(
fontSize: 11.5,
letterSpacing: 0.6,
color: colors.onSurfaceVariant,
),
),
),
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: GoogleFonts.inter(
fontSize: 12,
color: colors.onSurfaceVariant,
),
),
],
),
const SizedBox(height: 22),
Text(
'${AppConstants.appName} · v${AppConstants.appVersion}',
textAlign: TextAlign.center,
style: GoogleFonts.inter(
fontSize: 11,
letterSpacing: 0.3,
color: colors.onSurfaceVariant,
),
),
if (DevConfig.screenPreviewEnabled) ...[
const SizedBox(height: 20),
Divider(color: colors.outlineSoft),
const SizedBox(height: 14),
Text(
'Welcome back',
style: GoogleFonts.manrope(
fontSize: 24,
fontWeight: FontWeight.w800,
letterSpacing: -0.3,
color: colors.headingColor,
),
),
const SizedBox(height: 5),
Text(
'Sign in to continue to your BCPL workspace.',
'Login API unavailable? Browse all screens without signing in:',
textAlign: TextAlign.center,
style: GoogleFonts.inter(
fontSize: 13.5,
fontSize: 12,
color: colors.subtitleColor,
),
),
const SizedBox(height: 26),
TextFormField(
controller: _emailController,
keyboardType: TextInputType.emailAddress,
autofillHints: const [AutofillHints.email],
validator: Validators.email,
style: GoogleFonts.inter(
fontSize: 14.5,
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: GoogleFonts.inter(
fontSize: 14.5,
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: GoogleFonts.inter(
fontSize: 13,
color: colors.labelColor,
),
),
const Spacer(),
TextButton(
onPressed: () => context.push(RouteConstants.forgotPassword),
style: TextButton.styleFrom(
foregroundColor: colors.linkColor,
padding: const EdgeInsets.symmetric(horizontal: 4),
minimumSize: Size.zero,
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
child: Text(
'Forgot password?',
style: GoogleFonts.inter(
fontSize: 13,
fontWeight: FontWeight.w600,
),
),
),
],
),
const SizedBox(height: 14),
const SizedBox(height: 12),
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),
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),
),
textStyle: GoogleFonts.inter(
fontSize: 15,
fontWeight: FontWeight.w700,
letterSpacing: 0.2,
),
),
),
),
child: AppButton(
label: _isLoading ? 'Signing in…' : 'Sign in',
isLoading: _isLoading,
onPressed: _login,
label: 'Explore All Screens',
isOutlined: true,
onPressed: () {
ref.read(authStateProvider.notifier).loginAsDemo();
context.go(RouteConstants.screenGallery);
},
),
),
const SizedBox(height: 26),
Row(
children: [
Expanded(child: Divider(color: colors.outlineSoft, height: 1)),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 10),
child: Text(
'SECURE ACCESS',
style: GoogleFonts.inter(
fontSize: 11.5,
letterSpacing: 0.6,
color: colors.onSurfaceVariant,
),
),
),
Expanded(child: Divider(color: colors.outlineSoft, height: 1)),
],
],
],
),
);
}
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),
const SizedBox(height: 20),
Text(
'Forgot Password',
style: GoogleFonts.manrope(
fontSize: 24,
fontWeight: FontWeight.w800,
letterSpacing: -0.3,
color: colors.headingColor,
),
const SizedBox(height: 14),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.verified_user_outlined,
size: 14,
color: colors.success,
),
const SizedBox(height: 5),
Text(
'Enter your registered email address to receive password reset instructions.',
style: GoogleFonts.inter(
fontSize: 13.5,
height: 1.45,
color: colors.subtitleColor,
),
),
const SizedBox(height: 26),
TextFormField(
controller: _forgotEmailController,
keyboardType: TextInputType.emailAddress,
autofillHints: const [AutofillHints.email],
validator: Validators.email,
style: GoogleFonts.inter(
fontSize: 14.5,
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: GoogleFonts.inter(
fontSize: 13,
color: colors.colorScheme.error,
),
),
],
if (_forgotSuccessMessage != null) ...[
const SizedBox(height: 12),
Text(
_forgotSuccessMessage!,
style: GoogleFonts.inter(
fontSize: 13,
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),
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: GoogleFonts.inter(
fontSize: 13.5,
fontWeight: FontWeight.w600,
),
const SizedBox(width: 6),
Text(
'Your data is protected and encrypted',
),
),
),
const SizedBox(height: 18),
Row(
children: [
Expanded(child: Divider(color: colors.outlineSoft, height: 1)),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 10),
child: Text(
'SECURE ACCESS',
style: GoogleFonts.inter(
fontSize: 12,
fontSize: 11.5,
letterSpacing: 0.6,
color: colors.onSurfaceVariant,
),
),
],
),
const SizedBox(height: 22),
Text(
'${AppConstants.appName} · v${AppConstants.appVersion}',
textAlign: TextAlign.center,
style: GoogleFonts.inter(
fontSize: 11,
letterSpacing: 0.3,
color: colors.onSurfaceVariant,
),
),
if (DevConfig.screenPreviewEnabled) ...[
const SizedBox(height: 20),
Divider(color: colors.outlineSoft),
const SizedBox(height: 14),
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(
'Login API unavailable? Browse all screens without signing in:',
textAlign: TextAlign.center,
'Your data is protected and encrypted',
style: GoogleFonts.inter(
fontSize: 12,
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);
},
color: colors.onSurfaceVariant,
),
),
],
],
),
),
const SizedBox(height: 22),
Text(
'${AppConstants.appName} · v${AppConstants.appVersion}',
textAlign: TextAlign.center,
style: GoogleFonts.inter(
fontSize: 11,
letterSpacing: 0.3,
color: colors.onSurfaceVariant,
),
),
],
),
);
}
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 = _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;
return SizedBox(
height: _cardHeight,
width: double.infinity,
child: flipped,
);
},
),
);
}
@ -502,7 +835,7 @@ class _LoginScreenState extends ConsumerState<LoginScreen> {
horizontal: 32,
vertical: 40,
),
child: _buildLoginCard(colors),
child: _buildAuthCard(colors),
),
),
],
@ -524,7 +857,7 @@ class _LoginScreenState extends ConsumerState<LoginScreen> {
),
Padding(
padding: const EdgeInsets.fromLTRB(16, 24, 16, 40),
child: Center(child: _buildLoginCard(colors)),
child: Center(child: _buildAuthCard(colors)),
),
],
),