bharat_erp/lib/modules/auth/presentation/screens/login_screen.dart
2026-07-24 14:01:50 +05:30

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),
),
),
);
}
}