diff --git a/lib/authentication/bloc/authentication_bloc.dart b/lib/authentication/bloc/authentication_bloc.dart index aeb92bc..62e197e 100644 --- a/lib/authentication/bloc/authentication_bloc.dart +++ b/lib/authentication/bloc/authentication_bloc.dart @@ -20,6 +20,8 @@ import 'package:equatable/equatable.dart'; part 'authentication_event.dart'; part 'authentication_state.dart'; +const _requestCodeCooldownDuration = Duration(seconds: 60); + /// {@template authentication_bloc} /// Bloc responsible for managing the authentication state of the application. /// {@endtemplate} @@ -40,6 +42,7 @@ class AuthenticationBloc ); on(_onAuthenticationVerifyCodeRequested); on(_onAuthenticationSignOutRequested); + on(_onAuthenticationCooldownCompleted); } final AuthRepository _authenticationRepository; @@ -72,18 +75,32 @@ class AuthenticationBloc AuthenticationRequestSignInCodeRequested event, Emitter emit, ) async { + // Prevent request if already in cooldown + if (state.cooldownEndTime != null && + state.cooldownEndTime!.isAfter(DateTime.now())) { + return; + } + emit(state.copyWith(status: AuthenticationStatus.requestCodeLoading)); try { await _authenticationRepository.requestSignInCode( event.email, isDashboardLogin: true, ); + final cooldownEndTime = DateTime.now().add(_requestCodeCooldownDuration); emit( state.copyWith( status: AuthenticationStatus.codeSentSuccess, email: event.email, + cooldownEndTime: cooldownEndTime, ), ); + + // Start a timer to transition out of cooldown + Timer( + _requestCodeCooldownDuration, + () => add(const AuthenticationCooldownCompleted()), + ); } on InvalidInputException catch (e) { emit(state.copyWith(status: AuthenticationStatus.failure, exception: e)); } on UnauthorizedException catch (e) { @@ -181,6 +198,18 @@ class AuthenticationBloc } } + void _onAuthenticationCooldownCompleted( + AuthenticationCooldownCompleted event, + Emitter emit, + ) { + emit( + state.copyWith( + status: AuthenticationStatus.initial, + clearCooldownEndTime: true, + ), + ); + } + @override Future close() { _userAuthSubscription.cancel(); diff --git a/lib/authentication/bloc/authentication_event.dart b/lib/authentication/bloc/authentication_event.dart index c600111..b356c42 100644 --- a/lib/authentication/bloc/authentication_event.dart +++ b/lib/authentication/bloc/authentication_event.dart @@ -68,3 +68,11 @@ final class _AuthenticationStatusChanged extends AuthenticationEvent { @override List get props => [user]; } + +/// {@template authentication_cooldown_completed} +/// Event triggered when the sign-in code request cooldown has completed. +/// {@endtemplate} +final class AuthenticationCooldownCompleted extends AuthenticationEvent { + /// {@macro authentication_cooldown_completed} + const AuthenticationCooldownCompleted(); +} diff --git a/lib/authentication/bloc/authentication_state.dart b/lib/authentication/bloc/authentication_state.dart index 14449f1..cdcfe09 100644 --- a/lib/authentication/bloc/authentication_state.dart +++ b/lib/authentication/bloc/authentication_state.dart @@ -36,6 +36,7 @@ final class AuthenticationState extends Equatable { this.user, this.email, this.exception, + this.cooldownEndTime, }); /// The current status of the authentication process. @@ -50,8 +51,11 @@ final class AuthenticationState extends Equatable { /// The error describing an authentication failure, if any. final HttpException? exception; + /// The time when the cooldown for requesting a new code ends. + final DateTime? cooldownEndTime; + @override - List get props => [status, user, email, exception]; + List get props => [status, user, email, exception, cooldownEndTime]; /// Creates a copy of this [AuthenticationState] with the given fields /// replaced with the new values. @@ -60,12 +64,16 @@ final class AuthenticationState extends Equatable { User? user, String? email, HttpException? exception, + DateTime? cooldownEndTime, + bool clearCooldownEndTime = false, }) { return AuthenticationState( status: status ?? this.status, user: user ?? this.user, email: email ?? this.email, exception: exception ?? this.exception, + cooldownEndTime: + clearCooldownEndTime ? null : cooldownEndTime ?? this.cooldownEndTime, ); } } diff --git a/lib/authentication/view/email_code_verification_page.dart b/lib/authentication/view/email_code_verification_page.dart index 1dcf3c2..c8348ac 100644 --- a/lib/authentication/view/email_code_verification_page.dart +++ b/lib/authentication/view/email_code_verification_page.dart @@ -1,10 +1,10 @@ import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_news_app_web_dashboard_full_source_code/app/bloc/app_bloc.dart'; import 'package:flutter_news_app_web_dashboard_full_source_code/app/config/config.dart'; import 'package:flutter_news_app_web_dashboard_full_source_code/authentication/bloc/authentication_bloc.dart'; import 'package:flutter_news_app_web_dashboard_full_source_code/l10n/l10n.dart'; +import 'package:pinput/pinput.dart'; import 'package:ui_kit/ui_kit.dart'; /// {@template email_code_verification_page} @@ -131,10 +131,12 @@ class _EmailCodeVerificationFormState extends State<_EmailCodeVerificationForm> { final _formKey = GlobalKey(); final _codeController = TextEditingController(); + final _focusNode = FocusNode(); @override void dispose() { _codeController.dispose(); + _focusNode.dispose(); super.dispose(); } @@ -153,39 +155,48 @@ class _EmailCodeVerificationFormState Widget build(BuildContext context) { final l10n = AppLocalizationsX(context).l10n; final textTheme = Theme.of(context).textTheme; + final colorScheme = Theme.of(context).colorScheme; + + final defaultPinTheme = PinTheme( + width: 56, + height: 60, + textStyle: textTheme.headlineSmall, + decoration: BoxDecoration( + color: colorScheme.surface, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: colorScheme.onSurface.withOpacity(0.12)), + ), + ); return Form( key: _formKey, child: Column( mainAxisSize: MainAxisSize.min, children: [ - Padding( - // No horizontal padding needed if column is stretched - // padding: const EdgeInsets.symmetric(horizontal: AppSpacing.md), - padding: EdgeInsets.zero, - child: TextFormField( - controller: _codeController, - decoration: InputDecoration( - labelText: l10n.emailCodeVerificationHint, - // border: const OutlineInputBorder(), - counterText: '', + Pinput( + length: 6, + controller: _codeController, + focusNode: _focusNode, + defaultPinTheme: defaultPinTheme, + onCompleted: (pin) => _submitForm(), + validator: (value) { + if (value == null || value.isEmpty) { + return l10n.emailCodeValidationEmptyError; + } + if (value.length != 6) { + return l10n.emailCodeValidationLengthError; + } + return null; + }, + focusedPinTheme: defaultPinTheme.copyWith( + decoration: defaultPinTheme.decoration!.copyWith( + border: Border.all(color: colorScheme.primary), + ), + ), + errorPinTheme: defaultPinTheme.copyWith( + decoration: defaultPinTheme.decoration!.copyWith( + border: Border.all(color: colorScheme.error), ), - keyboardType: TextInputType.number, - inputFormatters: [FilteringTextInputFormatter.digitsOnly], - maxLength: 6, - textAlign: TextAlign.center, - style: textTheme.headlineSmall, - enabled: !widget.isLoading, - validator: (value) { - if (value == null || value.isEmpty) { - return l10n.emailCodeValidationEmptyError; - } - if (value.length != 6) { - return l10n.emailCodeValidationLengthError; - } - return null; - }, - onFieldSubmitted: widget.isLoading ? null : (_) => _submitForm(), ), ), const SizedBox(height: AppSpacing.xxl), diff --git a/lib/authentication/view/request_code_page.dart b/lib/authentication/view/request_code_page.dart index 84ed468..f5bf9f6 100644 --- a/lib/authentication/view/request_code_page.dart +++ b/lib/authentication/view/request_code_page.dart @@ -1,6 +1,8 @@ // // ignore_for_file: lines_longer_than_80_chars +import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_news_app_web_dashboard_full_source_code/app/bloc/app_bloc.dart'; @@ -183,20 +185,59 @@ class _EmailLinkForm extends StatefulWidget { class _EmailLinkFormState extends State<_EmailLinkForm> { final _emailController = TextEditingController(); final _formKey = GlobalKey(); + Timer? _cooldownTimer; + int _cooldownSeconds = 0; + + @override + void initState() { + super.initState(); + final authState = context.read().state; + if (authState.cooldownEndTime != null && + authState.cooldownEndTime!.isAfter(DateTime.now())) { + _startCooldownTimer(authState.cooldownEndTime!); + } + } @override void dispose() { _emailController.dispose(); + _cooldownTimer?.cancel(); super.dispose(); } + void _startCooldownTimer(DateTime endTime) { + final now = DateTime.now(); + if (now.isBefore(endTime)) { + setState(() { + _cooldownSeconds = endTime.difference(now).inSeconds; + }); + _cooldownTimer = Timer.periodic(const Duration(seconds: 1), (timer) { + final remaining = endTime.difference(DateTime.now()).inSeconds; + if (remaining > 0) { + setState(() { + _cooldownSeconds = remaining; + }); + } else { + timer.cancel(); + setState(() { + _cooldownSeconds = 0; + }); + // Optionally, trigger an event to reset the bloc state if needed + context + .read() + .add(const AuthenticationCooldownCompleted()); + } + }); + } + } + void _submitForm() { if (_formKey.currentState!.validate()) { context.read().add( - AuthenticationRequestSignInCodeRequested( - email: _emailController.text.trim(), - ), - ); + AuthenticationRequestSignInCodeRequested( + email: _emailController.text.trim(), + ), + ); } } @@ -206,49 +247,67 @@ class _EmailLinkFormState extends State<_EmailLinkForm> { final textTheme = Theme.of(context).textTheme; final colorScheme = Theme.of(context).colorScheme; - return Form( - key: _formKey, - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - TextFormField( - controller: _emailController, - decoration: InputDecoration( - labelText: l10n.requestCodeEmailLabel, - hintText: l10n.requestCodeEmailHint, - // border: const OutlineInputBorder(), + return BlocListener( + listenWhen: (previous, current) => + previous.cooldownEndTime != current.cooldownEndTime, + listener: (context, state) { + if (state.cooldownEndTime != null && + state.cooldownEndTime!.isAfter(DateTime.now())) { + _cooldownTimer?.cancel(); + _startCooldownTimer(state.cooldownEndTime!); + } + }, + child: Form( + key: _formKey, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + TextFormField( + controller: _emailController, + decoration: InputDecoration( + labelText: l10n.requestCodeEmailLabel, + hintText: l10n.requestCodeEmailHint, + ), + keyboardType: TextInputType.emailAddress, + autocorrect: false, + textInputAction: TextInputAction.done, + enabled: !widget.isLoading && _cooldownSeconds == 0, + validator: (value) { + if (value == null || value.isEmpty || !value.contains('@')) { + return l10n.accountLinkingEmailValidationError; + } + return null; + }, + onFieldSubmitted: + widget.isLoading || _cooldownSeconds > 0 ? null : (_) => _submitForm(), ), - keyboardType: TextInputType.emailAddress, - autocorrect: false, - textInputAction: TextInputAction.done, - enabled: !widget.isLoading, - validator: (value) { - if (value == null || value.isEmpty || !value.contains('@')) { - return l10n.accountLinkingEmailValidationError; - } - return null; - }, - onFieldSubmitted: (_) => _submitForm(), - ), - const SizedBox(height: AppSpacing.lg), - ElevatedButton( - onPressed: widget.isLoading ? null : _submitForm, - style: ElevatedButton.styleFrom( - padding: const EdgeInsets.symmetric(vertical: AppSpacing.md), - textStyle: textTheme.labelLarge, + const SizedBox(height: AppSpacing.lg), + ElevatedButton( + onPressed: + widget.isLoading || _cooldownSeconds > 0 ? null : _submitForm, + style: ElevatedButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: AppSpacing.md), + textStyle: textTheme.labelLarge, + ), + child: widget.isLoading + ? SizedBox( + height: AppSpacing.xl, + width: AppSpacing.xl, + child: CircularProgressIndicator( + strokeWidth: 2, + color: colorScheme.onPrimary, + ), + ) + : _cooldownSeconds > 0 + ? Text( + l10n.requestCodeResendButtonCooldown( + _cooldownSeconds, + ), + ) + : Text(l10n.requestCodeSendCodeButton), ), - child: widget.isLoading - ? SizedBox( - height: AppSpacing.xl, - width: AppSpacing.xl, - child: CircularProgressIndicator( - strokeWidth: 2, - color: colorScheme.onPrimary, - ), - ) - : Text(l10n.requestCodeSendCodeButton), - ), - ], + ], + ), ), ); } diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 92e7518..70f412d 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -158,6 +158,12 @@ abstract class AppLocalizations { /// **'Send Code'** String get requestCodeSendCodeButton; + /// Button label for resending the verification code after a cooldown + /// + /// In en, this message translates to: + /// **'Resend in {seconds}s'** + String requestCodeResendButtonCooldown(int seconds); + /// Title for the email code verification page /// /// In en, this message translates to: diff --git a/lib/l10n/app_localizations_ar.dart b/lib/l10n/app_localizations_ar.dart index e20f55a..3c56a72 100644 --- a/lib/l10n/app_localizations_ar.dart +++ b/lib/l10n/app_localizations_ar.dart @@ -42,6 +42,11 @@ class AppLocalizationsAr extends AppLocalizations { @override String get requestCodeSendCodeButton => 'إرسال الرمز'; + @override + String requestCodeResendButtonCooldown(int seconds) { + return 'إعادة الإرسال في $seconds ثانية'; + } + @override String get emailCodeSentPageTitle => 'التحقق من الرمز'; diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index bf977e7..cec717e 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -41,6 +41,11 @@ class AppLocalizationsEn extends AppLocalizations { @override String get requestCodeSendCodeButton => 'Send Code'; + @override + String requestCodeResendButtonCooldown(int seconds) { + return 'Resend in ${seconds}s'; + } + @override String get emailCodeSentPageTitle => 'Verify Code'; diff --git a/lib/l10n/arb/app_ar.arb b/lib/l10n/arb/app_ar.arb index 3a43f8a..d4e0482 100644 --- a/lib/l10n/arb/app_ar.arb +++ b/lib/l10n/arb/app_ar.arb @@ -1,4 +1,5 @@ { + "@@locale": "ar", "authenticationPageHeadline": "الوصول إلى لوحة التحكم", "@authenticationPageHeadline": { "description": "عنوان صفحة المصادقة الرئيسية" @@ -39,6 +40,15 @@ "@requestCodeSendCodeButton": { "description": "تسمية زر إرسال رمز التحقق" }, + "requestCodeResendButtonCooldown": "إعادة الإرسال في {seconds} ثانية", + "@requestCodeResendButtonCooldown": { + "description": "تسمية زر إعادة إرسال رمز التحقق بعد فترة تهدئة", + "placeholders": { + "seconds": { + "type": "int" + } + } + }, "emailCodeSentPageTitle": "التحقق من الرمز", "@emailCodeSentPageTitle": { "description": "عنوان صفحة التحقق من رمز البريد الإلكتروني" @@ -1067,4 +1077,4 @@ "@feedActionTypeEnableNotifications": { "description": "نوع إجراء الموجز لتفعيل الإشعارات" } -} +} \ No newline at end of file diff --git a/lib/l10n/arb/app_en.arb b/lib/l10n/arb/app_en.arb index bbe6290..7ce83bb 100644 --- a/lib/l10n/arb/app_en.arb +++ b/lib/l10n/arb/app_en.arb @@ -1,4 +1,5 @@ { + "@@locale": "en", "authenticationPageHeadline": "Dashboard Access", "@authenticationPageHeadline": { "description": "Headline for the main authentication page" @@ -39,6 +40,15 @@ "@requestCodeSendCodeButton": { "description": "Button label for sending the verification code" }, + "requestCodeResendButtonCooldown": "Resend in {seconds}s", + "@requestCodeResendButtonCooldown": { + "description": "Button label for resending the verification code after a cooldown", + "placeholders": { + "seconds": { + "type": "int" + } + } + }, "emailCodeSentPageTitle": "Verify Code", "@emailCodeSentPageTitle": { "description": "Title for the email code verification page" diff --git a/lib/main.dart b/lib/main.dart index 340698d..d6ca694 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -6,7 +6,7 @@ import 'package:flutter_news_app_web_dashboard_full_source_code/app/config/confi import 'package:flutter_news_app_web_dashboard_full_source_code/bootstrap.dart'; // Define the current application environment (production/development/demo). -const AppEnvironment appEnvironment = AppEnvironment.demo; +const AppEnvironment appEnvironment = AppEnvironment.development; @JS('removeSplashFromWeb') external void removeSplashFromWeb(); diff --git a/pubspec.lock b/pubspec.lock index 64fc3a6..01f37a5 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -428,6 +428,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.3.0" + pinput: + dependency: "direct main" + description: + name: pinput + sha256: "8a73be426a91fefec90a7f130763ca39772d547e92f19a827cf4aa02e323d35a" + url: "https://pub.dev" + source: hosted + version: "5.0.1" platform: dependency: transitive description: @@ -578,6 +586,14 @@ packages: url: "https://github.com/flutter-news-app-full-source-code/ui-kit.git" source: git version: "0.0.0" + universal_platform: + dependency: transitive + description: + name: universal_platform + sha256: "64e16458a0ea9b99260ceb5467a214c1f298d647c659af1bff6d3bf82536b1ec" + url: "https://pub.dev" + source: hosted + version: "1.1.0" uuid: dependency: "direct main" description: diff --git a/pubspec.yaml b/pubspec.yaml index e1bccbe..173d062 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -59,6 +59,7 @@ dependencies: git: url: https://github.com/flutter-news-app-full-source-code/kv-storage-shared-preferences.git logging: ^1.3.0 + pinput: ^5.0.1 timeago: ^3.7.1 ui_kit: git: