import 'dart:async'; import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import '../../../../core/logging/logger.dart'; import '../../../../core/network/api_problem.dart'; import '../../../../core/network/api_problem_mapper.dart'; import '../../../settings/presentation/models/legal_document_type.dart'; import '../../../settings/presentation/screens/legal_document_screen.dart'; import '../../../settings/presentation/utils/legal_document_assets.dart'; import '../../../../l10n/app_localizations.dart'; import '../../../../shared/theme/design_tokens.dart'; import '../../../../shared/widgets/toast/toast.dart'; import '../../../../shared/widgets/toast/toast_type.dart'; class LoginScreen extends StatefulWidget { const LoginScreen({ super.key, required this.onRequestOtp, required this.onLoginWithOtp, required this.onLocaleChanged, required this.currentLocale, }); final Future Function(String email) onRequestOtp; final Future Function(String email, String otp) onLoginWithOtp; final ValueChanged onLocaleChanged; final Locale currentLocale; @override State createState() => _LoginScreenState(); } class _LoginScreenState extends State { final Logger _logger = getLogger('features.auth.login'); final TextEditingController _emailController = TextEditingController(); final TextEditingController _codeController = TextEditingController(); Timer? _timer; int _countdown = 0; bool _isSending = false; bool _agreementChecked = false; bool get _isValidEmail { return RegExp( r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$', ).hasMatch(_emailController.text.trim()); } @override void dispose() { _timer?.cancel(); _emailController.dispose(); _codeController.dispose(); super.dispose(); } void _showMessage(String message) { Toast.show(context, message, type: ToastType.info); } Future _sendCode() async { final l10n = AppLocalizations.of(context)!; if (!_isValidEmail) { _showMessage(l10n.invalidEmail); return; } if (_countdown > 0 || _isSending) { return; } setState(() { _isSending = true; }); try { await widget.onRequestOtp(_emailController.text.trim()); if (!mounted) { return; } setState(() { _isSending = false; _countdown = 60; }); _timer = Timer.periodic(const Duration(seconds: 1), (timer) { if (!mounted) { timer.cancel(); return; } if (_countdown <= 1) { timer.cancel(); setState(() { _countdown = 0; }); } else { setState(() { _countdown -= 1; }); } }); } catch (error, stackTrace) { _logger.error( message: 'Send OTP failed', error: error, stackTrace: stackTrace, ); if (!mounted) { return; } setState(() { _isSending = false; }); _showMessage(_safeErrorMessage(error)); } } Future _login() async { final l10n = AppLocalizations.of(context)!; if (!_isValidEmail) { _showMessage(l10n.invalidEmail); return; } if (_codeController.text.length != 6) { _showMessage(l10n.invalidCode); return; } if (!_agreementChecked) { _showMessage(l10n.agreementRequired); return; } try { await widget.onLoginWithOtp( _emailController.text.trim(), _codeController.text, ); if (!mounted) { return; } } catch (error, stackTrace) { _logger.error( message: 'Login with OTP failed', error: error, stackTrace: stackTrace, ); _showMessage(_safeErrorMessage(error)); } } String _safeErrorMessage(Object error) { final l10n = AppLocalizations.of(context)!; if (error is ApiProblem) { return mapApiProblemToMessage(error, l10n); } return l10n.errorRequestGeneric; } InputDecoration _inputDecoration({ required String hintText, required IconData icon, }) { final colors = Theme.of(context).colorScheme; return InputDecoration( hintText: hintText, filled: true, fillColor: colors.surface.withValues(alpha: 0.92), prefixIcon: Icon(icon, color: colors.primary), border: OutlineInputBorder( borderRadius: BorderRadius.circular(AppRadius.lg), borderSide: BorderSide(color: colors.outlineVariant), ), enabledBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(AppRadius.lg), borderSide: BorderSide(color: colors.outlineVariant), ), focusedBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(AppRadius.lg), borderSide: BorderSide(color: colors.primary, width: 1.6), ), contentPadding: const EdgeInsets.symmetric( horizontal: AppSpacing.lg, vertical: AppSpacing.lg, ), ); } Future _openLegalDocument(LegalDocumentType type) async { final l10n = AppLocalizations.of(context)!; await Navigator.of(context).push( MaterialPageRoute( builder: (_) => LegalDocumentScreen( title: legalDocumentTitle(l10n, type), assetPath: legalDocumentAssetPath( Localizations.localeOf(context), type, ), ), ), ); } @override Widget build(BuildContext context) { final l10n = AppLocalizations.of(context)!; final colors = Theme.of(context).colorScheme; final canLogin = _isValidEmail && _codeController.text.length == 6 && _agreementChecked; return Scaffold( resizeToAvoidBottomInset: true, body: Container( decoration: BoxDecoration( gradient: LinearGradient( begin: Alignment.topCenter, end: Alignment.bottomCenter, colors: [ colors.secondaryContainer.withValues(alpha: 0.55), colors.primaryContainer.withValues(alpha: 0.42), colors.surfaceContainerLow, ], ), ), child: Stack( children: [ Positioned( top: -86, right: -42, child: Container( width: 180, height: 180, decoration: BoxDecoration( shape: BoxShape.circle, color: colors.primary.withValues(alpha: 0.1), ), ), ), Positioned( bottom: -110, left: -34, child: Container( width: 210, height: 210, decoration: BoxDecoration( shape: BoxShape.circle, color: colors.secondary.withValues(alpha: 0.08), ), ), ), GestureDetector( onTap: () => FocusScope.of(context).unfocus(), child: SafeArea( child: LayoutBuilder( builder: (context, constraints) { final bottomInset = MediaQuery.of( context, ).viewInsets.bottom; return SingleChildScrollView( keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag, padding: EdgeInsets.fromLTRB( AppSpacing.xl, AppSpacing.lg, AppSpacing.xl, AppSpacing.lg + bottomInset, ), child: ConstrainedBox( constraints: BoxConstraints( minHeight: constraints.maxHeight, ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ const SizedBox(height: AppSpacing.xxxl), Center( child: Column( children: [ Container( width: 88, height: 88, decoration: BoxDecoration( color: colors.surface.withValues( alpha: 0.9, ), borderRadius: BorderRadius.circular( AppRadius.full, ), border: Border.all( color: colors.primary.withValues( alpha: 0.2, ), ), ), padding: const EdgeInsets.all( AppSpacing.md, ), child: Image.asset( 'assets/images/logo.png', ), ), const SizedBox(height: AppSpacing.md), Text( l10n.appTitle, style: Theme.of(context) .textTheme .titleLarge ?.copyWith(fontWeight: FontWeight.w700), ), ], ), ), const SizedBox(height: AppSpacing.xxxl), TextField( controller: _emailController, keyboardType: TextInputType.emailAddress, textInputAction: TextInputAction.next, onChanged: (_) => setState(() {}), decoration: _inputDecoration( hintText: l10n.emailHint, icon: Icons.alternate_email, ), ), const SizedBox(height: AppSpacing.lg), Row( children: [ Expanded( child: TextField( controller: _codeController, keyboardType: TextInputType.number, textInputAction: TextInputAction.done, maxLength: 6, onChanged: (_) => setState(() {}), decoration: _inputDecoration( hintText: l10n.codeHint, icon: Icons.lock_outline, ).copyWith(counterText: ''), ), ), const SizedBox(width: AppSpacing.sm), SizedBox( width: 128, height: 52, child: FilledButton( style: FilledButton.styleFrom( backgroundColor: colors.primary, foregroundColor: colors.onPrimary, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular( AppRadius.full, ), ), ), onPressed: _sendCode, child: Text( _isSending ? l10n.sending : _countdown > 0 ? l10n.retryAfter(_countdown) : l10n.sendCode, textAlign: TextAlign.center, ), ), ), ], ), const SizedBox(height: AppSpacing.xl), SizedBox( width: double.infinity, child: FilledButton( style: FilledButton.styleFrom( backgroundColor: colors.primary, foregroundColor: colors.onPrimary, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular( AppRadius.full, ), ), padding: const EdgeInsets.symmetric( vertical: AppSpacing.md, ), ), onPressed: canLogin ? _login : null, child: Text( l10n.login, style: const TextStyle(fontSize: 16), ), ), ), const SizedBox(height: AppSpacing.md), Row( crossAxisAlignment: CrossAxisAlignment.center, children: [ Checkbox( value: _agreementChecked, onChanged: (value) { setState(() { _agreementChecked = value ?? false; }); }, ), Expanded( child: RichText( text: TextSpan( style: Theme.of(context) .textTheme .bodySmall ?.copyWith(color: colors.onSurface), children: [ TextSpan(text: l10n.agreementPrefix), TextSpan( text: l10n.privacyPolicy, style: TextStyle( color: colors.primary, decoration: TextDecoration.underline, ), recognizer: TapGestureRecognizer() ..onTap = () => _openLegalDocument( LegalDocumentType .privacyPolicy, ), ), TextSpan(text: l10n.agreementAnd), TextSpan( text: l10n.termsOfService, style: TextStyle( color: colors.primary, decoration: TextDecoration.underline, ), recognizer: TapGestureRecognizer() ..onTap = () => _openLegalDocument( LegalDocumentType .termsOfService, ), ), ], ), ), ), ], ), ], ), ), ); }, ), ), ), ], ), ), ); } }