forgot design flip
This commit is contained in:
parent
9856ff08b2
commit
f871af752b
@ -1,3 +1,5 @@
|
|||||||
|
import 'dart:math' as math;
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:go_router/go_router.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/app_toast.dart';
|
||||||
import '../../../../shared/widgets/sidebar_logo.dart';
|
import '../../../../shared/widgets/sidebar_logo.dart';
|
||||||
import '../../../settings/presentation/providers/settings_provider.dart';
|
import '../../../settings/presentation/providers/settings_provider.dart';
|
||||||
|
import '../../data/repositories/auth_repository_impl.dart';
|
||||||
import '../widgets/login_colors.dart';
|
import '../widgets/login_colors.dart';
|
||||||
import '../widgets/login_hero_panel.dart';
|
import '../widgets/login_hero_panel.dart';
|
||||||
|
|
||||||
@ -27,17 +30,40 @@ class LoginScreen extends ConsumerStatefulWidget {
|
|||||||
ConsumerState<LoginScreen> createState() => _LoginScreenState();
|
ConsumerState<LoginScreen> createState() => _LoginScreenState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _LoginScreenState extends ConsumerState<LoginScreen> {
|
class _LoginScreenState extends ConsumerState<LoginScreen>
|
||||||
final _formKey = GlobalKey<FormState>();
|
with SingleTickerProviderStateMixin {
|
||||||
|
final _loginFormKey = GlobalKey<FormState>();
|
||||||
|
final _forgotFormKey = GlobalKey<FormState>();
|
||||||
|
final _cardMeasureKey = GlobalKey();
|
||||||
final _emailController = TextEditingController();
|
final _emailController = TextEditingController();
|
||||||
final _passwordController = TextEditingController();
|
final _passwordController = TextEditingController();
|
||||||
|
final _forgotEmailController = TextEditingController();
|
||||||
|
|
||||||
|
late final AnimationController _flipController;
|
||||||
|
late final Animation<double> _flipAnimation;
|
||||||
|
|
||||||
bool _isLoading = false;
|
bool _isLoading = false;
|
||||||
|
bool _isForgotLoading = false;
|
||||||
bool _obscurePassword = true;
|
bool _obscurePassword = true;
|
||||||
bool _rememberMe = false;
|
bool _rememberMe = false;
|
||||||
|
String? _forgotSuccessMessage;
|
||||||
|
String? _forgotErrorMessage;
|
||||||
|
double? _cardHeight;
|
||||||
|
|
||||||
|
bool get _showingForgot => _flipAnimation.value > 0.5;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.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());
|
WidgetsBinding.instance.addPostFrameCallback((_) => _loadRememberedEmail());
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -70,13 +96,15 @@ class _LoginScreenState extends ConsumerState<LoginScreen> {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
|
_flipController.dispose();
|
||||||
_emailController.dispose();
|
_emailController.dispose();
|
||||||
_passwordController.dispose();
|
_passwordController.dispose();
|
||||||
|
_forgotEmailController.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _login() async {
|
Future<void> _login() async {
|
||||||
if (!_formKey.currentState!.validate()) return;
|
if (!_loginFormKey.currentState!.validate()) return;
|
||||||
|
|
||||||
setState(() => _isLoading = true);
|
setState(() => _isLoading = true);
|
||||||
await _persistRememberMe();
|
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({
|
InputDecoration _fieldDecoration({
|
||||||
required LoginColors colors,
|
required LoginColors colors,
|
||||||
required String label,
|
required String label,
|
||||||
@ -149,16 +235,11 @@ class _LoginScreenState extends ConsumerState<LoginScreen> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildLoginCard(LoginColors colors) {
|
Widget _cardShell({
|
||||||
final companyProfile = ref.watch(appSettingsProvider).companyProfile;
|
required LoginColors colors,
|
||||||
final branding = ref.watch(brandingProvider);
|
required Widget child,
|
||||||
final logoUrl = resolveSidebarLogoUrl(
|
}) {
|
||||||
companyProfileLogo: companyProfile.logoUrl,
|
|
||||||
brandingLogo: branding.logoUrl,
|
|
||||||
);
|
|
||||||
|
|
||||||
return Container(
|
return Container(
|
||||||
constraints: const BoxConstraints(maxWidth: 420),
|
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: colors.surface.withValues(alpha: 0.92),
|
color: colors.surface.withValues(alpha: 0.92),
|
||||||
borderRadius: BorderRadius.circular(28),
|
borderRadius: BorderRadius.circular(28),
|
||||||
@ -172,240 +253,492 @@ class _LoginScreenState extends ConsumerState<LoginScreen> {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
padding: const EdgeInsets.fromLTRB(36, 40, 36, 32),
|
padding: const EdgeInsets.fromLTRB(36, 40, 36, 32),
|
||||||
child: Form(
|
child: child,
|
||||||
key: _formKey,
|
);
|
||||||
child: Column(
|
}
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
Widget _buildLogo(String logoUrl) {
|
||||||
children: [
|
return Center(
|
||||||
Center(
|
child: SidebarLogo(
|
||||||
child: SidebarLogo(
|
logoUrl: logoUrl,
|
||||||
logoUrl: logoUrl,
|
height: 64,
|
||||||
height: 64,
|
width: 220,
|
||||||
width: 220,
|
fit: BoxFit.contain,
|
||||||
fit: BoxFit.contain,
|
showBackground: false,
|
||||||
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),
|
const SizedBox(height: 20),
|
||||||
|
Divider(color: colors.outlineSoft),
|
||||||
|
const SizedBox(height: 14),
|
||||||
Text(
|
Text(
|
||||||
'Welcome back',
|
'Login API unavailable? Browse all screens without signing in:',
|
||||||
style: GoogleFonts.manrope(
|
textAlign: TextAlign.center,
|
||||||
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(
|
style: GoogleFonts.inter(
|
||||||
fontSize: 13.5,
|
fontSize: 12,
|
||||||
color: colors.subtitleColor,
|
color: colors.subtitleColor,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 26),
|
const SizedBox(height: 12),
|
||||||
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),
|
|
||||||
Theme(
|
Theme(
|
||||||
data: Theme.of(context).copyWith(
|
data: Theme.of(context).copyWith(
|
||||||
elevatedButtonTheme: ElevatedButtonThemeData(
|
outlinedButtonTheme: OutlinedButtonThemeData(
|
||||||
style: ElevatedButton.styleFrom(
|
style: OutlinedButton.styleFrom(
|
||||||
backgroundColor: colors.primary,
|
foregroundColor: colors.primary,
|
||||||
foregroundColor: colors.onPrimary,
|
side: BorderSide(color: colors.outlineSoft),
|
||||||
disabledBackgroundColor:
|
minimumSize: const Size(double.infinity, 46),
|
||||||
colors.primary.withValues(alpha: 0.75),
|
|
||||||
minimumSize: const Size(double.infinity, 52),
|
|
||||||
elevation: 0,
|
|
||||||
shadowColor: colors.primary.withValues(alpha: 0.35),
|
|
||||||
shape: RoundedRectangleBorder(
|
shape: RoundedRectangleBorder(
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
),
|
),
|
||||||
textStyle: GoogleFonts.inter(
|
|
||||||
fontSize: 15,
|
|
||||||
fontWeight: FontWeight.w700,
|
|
||||||
letterSpacing: 0.2,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
child: AppButton(
|
child: AppButton(
|
||||||
label: _isLoading ? 'Signing in…' : 'Sign in',
|
label: 'Explore All Screens',
|
||||||
isLoading: _isLoading,
|
isOutlined: true,
|
||||||
onPressed: _login,
|
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(
|
Widget _buildForgotFace({
|
||||||
'SECURE ACCESS',
|
required LoginColors colors,
|
||||||
style: GoogleFonts.inter(
|
required String logoUrl,
|
||||||
fontSize: 11.5,
|
}) {
|
||||||
letterSpacing: 0.6,
|
final fillHeight = _cardHeight != null;
|
||||||
color: colors.onSurfaceVariant,
|
return Form(
|
||||||
),
|
key: _forgotFormKey,
|
||||||
),
|
child: Column(
|
||||||
),
|
mainAxisSize: fillHeight ? MainAxisSize.max : MainAxisSize.min,
|
||||||
Expanded(child: Divider(color: colors.outlineSoft, height: 1)),
|
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(
|
const SizedBox(height: 5),
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
Text(
|
||||||
children: [
|
'Enter your registered email address to receive password reset instructions.',
|
||||||
Icon(
|
style: GoogleFonts.inter(
|
||||||
Icons.verified_user_outlined,
|
fontSize: 13.5,
|
||||||
size: 14,
|
height: 1.45,
|
||||||
color: colors.success,
|
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(
|
style: GoogleFonts.inter(
|
||||||
fontSize: 12,
|
fontSize: 11.5,
|
||||||
|
letterSpacing: 0.6,
|
||||||
color: colors.onSurfaceVariant,
|
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,
|
|
||||||
),
|
),
|
||||||
),
|
Expanded(child: Divider(color: colors.outlineSoft, height: 1)),
|
||||||
if (DevConfig.screenPreviewEnabled) ...[
|
],
|
||||||
const SizedBox(height: 20),
|
),
|
||||||
Divider(color: colors.outlineSoft),
|
const SizedBox(height: 14),
|
||||||
const SizedBox(height: 14),
|
Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.verified_user_outlined,
|
||||||
|
size: 14,
|
||||||
|
color: colors.success,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 6),
|
||||||
Text(
|
Text(
|
||||||
'Login API unavailable? Browse all screens without signing in:',
|
'Your data is protected and encrypted',
|
||||||
textAlign: TextAlign.center,
|
|
||||||
style: GoogleFonts.inter(
|
style: GoogleFonts.inter(
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
color: colors.subtitleColor,
|
color: colors.onSurfaceVariant,
|
||||||
),
|
|
||||||
),
|
|
||||||
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);
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
],
|
),
|
||||||
),
|
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,
|
horizontal: 32,
|
||||||
vertical: 40,
|
vertical: 40,
|
||||||
),
|
),
|
||||||
child: _buildLoginCard(colors),
|
child: _buildAuthCard(colors),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@ -524,7 +857,7 @@ class _LoginScreenState extends ConsumerState<LoginScreen> {
|
|||||||
),
|
),
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.fromLTRB(16, 24, 16, 40),
|
padding: const EdgeInsets.fromLTRB(16, 24, 16, 40),
|
||||||
child: Center(child: _buildLoginCard(colors)),
|
child: Center(child: _buildAuthCard(colors)),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user