diff --git a/lib/modules/auth/presentation/screens/login_screen.dart b/lib/modules/auth/presentation/screens/login_screen.dart index 24f6b8d..899faa7 100644 --- a/lib/modules/auth/presentation/screens/login_screen.dart +++ b/lib/modules/auth/presentation/screens/login_screen.dart @@ -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 createState() => _LoginScreenState(); } -class _LoginScreenState extends ConsumerState { - final _formKey = GlobalKey(); +class _LoginScreenState extends ConsumerState + with SingleTickerProviderStateMixin { + final _loginFormKey = GlobalKey(); + final _forgotFormKey = GlobalKey(); + final _cardMeasureKey = GlobalKey(); final _emailController = TextEditingController(); final _passwordController = TextEditingController(); + final _forgotEmailController = TextEditingController(); + + late final AnimationController _flipController; + late final Animation _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 { @override void dispose() { + _flipController.dispose(); _emailController.dispose(); _passwordController.dispose(); + _forgotEmailController.dispose(); super.dispose(); } Future _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 { } } + Future _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 _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.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 { ); } - 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 { ], ), 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 { horizontal: 32, vertical: 40, ), - child: _buildLoginCard(colors), + child: _buildAuthCard(colors), ), ), ], @@ -524,7 +857,7 @@ class _LoginScreenState extends ConsumerState { ), Padding( padding: const EdgeInsets.fromLTRB(16, 24, 16, 40), - child: Center(child: _buildLoginCard(colors)), + child: Center(child: _buildAuthCard(colors)), ), ], ),