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; } void _showPolicyDialog(String title, String content) { showDialog( context: context, builder: (context) { return AlertDialog( title: Text(title), content: Text(content), actions: [ TextButton( onPressed: () => Navigator.of(context).pop(), child: Text(AppLocalizations.of(context)!.dialogConfirm), ), ], ); }, ); } 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( body: GestureDetector( onTap: () => FocusScope.of(context).unfocus(), child: SafeArea( child: Padding( padding: const EdgeInsets.symmetric( horizontal: AppSpacing.xl, vertical: AppSpacing.lg, ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ const SizedBox(height: AppSpacing.xxl), Container( width: double.infinity, padding: const EdgeInsets.all(AppSpacing.xl), decoration: BoxDecoration( color: colors.surface, borderRadius: BorderRadius.circular(AppRadius.lg), ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( l10n.welcomeLogin, style: Theme.of(context).textTheme.headlineMedium, ), const SizedBox(height: AppSpacing.sm), Text( l10n.loginSubtitleEmail, style: Theme.of(context).textTheme.bodyLarge, ), ], ), ), const SizedBox(height: AppSpacing.xxl), Container( decoration: BoxDecoration( color: colors.surface, borderRadius: BorderRadius.circular(AppRadius.lg), ), child: TextField( controller: _emailController, keyboardType: TextInputType.emailAddress, onChanged: (_) => setState(() {}), decoration: InputDecoration( hintText: l10n.emailHint, border: OutlineInputBorder( borderRadius: BorderRadius.circular(AppRadius.lg), borderSide: BorderSide.none, ), contentPadding: const EdgeInsets.symmetric( horizontal: AppSpacing.lg, vertical: AppSpacing.lg, ), ), ), ), const SizedBox(height: AppSpacing.lg), Row( children: [ Expanded( child: Container( decoration: BoxDecoration( color: colors.surface, borderRadius: BorderRadius.circular(AppRadius.lg), ), child: TextField( controller: _codeController, keyboardType: TextInputType.number, maxLength: 6, onChanged: (_) => setState(() {}), decoration: InputDecoration( counterText: '', hintText: l10n.codeHint, border: OutlineInputBorder( borderRadius: BorderRadius.circular(AppRadius.lg), borderSide: BorderSide.none, ), contentPadding: const EdgeInsets.symmetric( horizontal: AppSpacing.lg, vertical: AppSpacing.lg, ), ), ), ), ), const SizedBox(width: AppSpacing.sm), SizedBox( width: 130, height: 48, child: FilledButton( style: FilledButton.styleFrom( backgroundColor: colors.surfaceContainerHighest, foregroundColor: colors.primary, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(AppRadius.full), ), ), onPressed: _sendCode, child: Text( _isSending ? l10n.sending : _countdown > 0 ? l10n.retryAfter(_countdown) : l10n.sendCode, ), ), ), ], ), 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), Center( child: Row( mainAxisSize: MainAxisSize.min, children: [ Checkbox( value: _agreementChecked, onChanged: (value) { setState(() { _agreementChecked = value ?? false; }); }, ), Flexible( child: RichText( text: TextSpan( style: Theme.of(context).textTheme.bodySmall, 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.agreementSeparator), TextSpan( text: l10n.termsOfService, style: TextStyle( color: colors.primary, decoration: TextDecoration.underline, ), recognizer: TapGestureRecognizer() ..onTap = () => _openLegalDocument( LegalDocumentType.termsOfService, ), ), TextSpan(text: l10n.agreementAnd), TextSpan( text: l10n.disclaimer, style: TextStyle( color: colors.primary, decoration: TextDecoration.underline, ), recognizer: TapGestureRecognizer() ..onTap = () => _showPolicyDialog( l10n.disclaimer, l10n.disclaimerContent, ), ), ], ), ), ), ], ), ), const Spacer(), Center( child: Text( l10n.icp, style: Theme.of(context).textTheme.bodySmall?.copyWith( color: colors.primary, fontWeight: FontWeight.w700, ), ), ), const SizedBox(height: AppSpacing.sm), ], ), ), ), ), ); } }