diff --git a/lib/authentication/bloc/authentication_bloc.dart b/lib/authentication/bloc/authentication_bloc.dart index 7b02095..b919aca 100644 --- a/lib/authentication/bloc/authentication_bloc.dart +++ b/lib/authentication/bloc/authentication_bloc.dart @@ -8,6 +8,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} @@ -31,6 +33,7 @@ class AuthenticationBloc _onAuthenticationAnonymousSignInRequested, ); on(_onAuthenticationSignOutRequested); + on(_onAuthenticationCooldownCompleted); } final AuthRepository _authenticationRepository; @@ -63,15 +66,27 @@ class AuthenticationBloc AuthenticationRequestSignInCodeRequested event, Emitter emit, ) async { + if (state.cooldownEndTime != null && + state.cooldownEndTime!.isAfter(DateTime.now())) { + return; + } + emit(state.copyWith(status: AuthenticationStatus.requestCodeInProgress)); try { await _authenticationRepository.requestSignInCode(event.email); + final cooldownEndTime = DateTime.now().add(_requestCodeCooldownDuration); emit( state.copyWith( status: AuthenticationStatus.requestCodeSuccess, email: event.email, + cooldownEndTime: cooldownEndTime, ), ); + + Timer( + _requestCodeCooldownDuration, + () => add(const AuthenticationCooldownCompleted()), + ); } on HttpException catch (e) { emit(state.copyWith(status: AuthenticationStatus.failure, exception: e)); } catch (e) { @@ -155,4 +170,16 @@ class AuthenticationBloc _userAuthSubscription.cancel(); return super.close(); } + + void _onAuthenticationCooldownCompleted( + AuthenticationCooldownCompleted event, + Emitter emit, + ) { + emit( + state.copyWith( + status: AuthenticationStatus.initial, + clearCooldownEndTime: true, + ), + ); + } } diff --git a/lib/authentication/bloc/authentication_event.dart b/lib/authentication/bloc/authentication_event.dart index ac8e760..a667174 100644 --- a/lib/authentication/bloc/authentication_event.dart +++ b/lib/authentication/bloc/authentication_event.dart @@ -76,3 +76,11 @@ final class _AuthenticationUserChanged 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 690dbb8..3f90f3f 100644 --- a/lib/authentication/bloc/authentication_state.dart +++ b/lib/authentication/bloc/authentication_state.dart @@ -39,6 +39,7 @@ class AuthenticationState extends Equatable { this.user, this.email, this.exception, + this.cooldownEndTime, }); /// The current status of the authentication process. @@ -53,21 +54,28 @@ class AuthenticationState extends Equatable { /// The exception that occurred, if any. final HttpException? exception; + /// The time when the cooldown for requesting a new code ends. + final DateTime? cooldownEndTime; + /// Creates a copy of the current [AuthenticationState] with updated values. AuthenticationState copyWith({ AuthenticationStatus? status, 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, ); } @override - List get props => [status, user, email, exception]; + List get props => [status, user, email, exception, cooldownEndTime]; } diff --git a/lib/authentication/view/email_code_verification_page.dart b/lib/authentication/view/email_code_verification_page.dart index b6f9215..a415a99 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_mobile_client_full_source_code/app/bloc/app_bloc.dart'; import 'package:flutter_news_app_mobile_client_full_source_code/app/config/config.dart'; import 'package:flutter_news_app_mobile_client_full_source_code/authentication/bloc/authentication_bloc.dart'; import 'package:flutter_news_app_mobile_client_full_source_code/l10n/l10n.dart'; +import 'package:pinput/pinput.dart'; import 'package:ui_kit/ui_kit.dart'; /// {@template email_code_verification_page} @@ -130,10 +130,12 @@ class _EmailCodeVerificationFormState extends State<_EmailCodeVerificationForm> { final _formKey = GlobalKey(); final _codeController = TextEditingController(); + final _focusNode = FocusNode(); @override void dispose() { _codeController.dispose(); + _focusNode.dispose(); super.dispose(); } @@ -152,39 +154,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 d4f43ca..024df3e 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_mobile_client_full_source_code/authentication/bloc/authentication_bloc.dart'; @@ -161,20 +163,58 @@ 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; + }); + context + .read() + .add(const AuthenticationCooldownCompleted()); + } + }); + } + } + void _submitForm() { if (_formKey.currentState!.validate()) { context.read().add( - AuthenticationRequestSignInCodeRequested( - email: _emailController.text.trim(), - ), - ); + AuthenticationRequestSignInCodeRequested( + email: _emailController.text.trim(), + ), + ); } } @@ -184,49 +224,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 b3620c1..81acc06 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -1412,6 +1412,12 @@ abstract class AppLocalizations { /// **'Send Code'** String get requestCodeSendCodeButton; + /// Button text shown during the cooldown period for resending a code + /// + /// In en, this message translates to: + /// **'Resend in {seconds}s'** + String requestCodeResendButtonCooldown(int seconds); + /// Title for category entity type /// /// In en, this message translates to: diff --git a/lib/l10n/app_localizations_ar.dart b/lib/l10n/app_localizations_ar.dart index bfd2cde..ad66db9 100644 --- a/lib/l10n/app_localizations_ar.dart +++ b/lib/l10n/app_localizations_ar.dart @@ -725,6 +725,11 @@ class AppLocalizationsAr extends AppLocalizations { @override String get requestCodeSendCodeButton => 'إرسال الرمز'; + @override + String requestCodeResendButtonCooldown(int seconds) { + return 'إعادة الإرسال في $seconds ثانية'; + } + @override String get entityDetailsCategoryTitle => 'الفئة'; diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index fc011bc..92e2d66 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -725,6 +725,11 @@ class AppLocalizationsEn extends AppLocalizations { @override String get requestCodeSendCodeButton => 'Send Code'; + @override + String requestCodeResendButtonCooldown(int seconds) { + return 'Resend in ${seconds}s'; + } + @override String get entityDetailsCategoryTitle => 'Category'; diff --git a/lib/l10n/arb/app_ar.arb b/lib/l10n/arb/app_ar.arb index da15cf3..94fd8b9 100644 --- a/lib/l10n/arb/app_ar.arb +++ b/lib/l10n/arb/app_ar.arb @@ -942,6 +942,16 @@ "@requestCodeSendCodeButton": { "description": "Button text to send the verification code" }, + "requestCodeResendButtonCooldown": "إعادة الإرسال في {seconds} ثانية", + "@requestCodeResendButtonCooldown": { + "description": "Button text shown during the cooldown period for resending a code", + "placeholders": { + "seconds": { + "type": "int", + "example": "60" + } + } + }, "entityDetailsCategoryTitle": "الفئة", "@entityDetailsCategoryTitle": { "description": "Title for category entity type" diff --git a/lib/l10n/arb/app_en.arb b/lib/l10n/arb/app_en.arb index a3ba200..9ccef5b 100644 --- a/lib/l10n/arb/app_en.arb +++ b/lib/l10n/arb/app_en.arb @@ -944,6 +944,16 @@ "@requestCodeSendCodeButton": { "description": "Button text to send the verification code" }, + "requestCodeResendButtonCooldown": "Resend in {seconds}s", + "@requestCodeResendButtonCooldown": { + "description": "Button text shown during the cooldown period for resending a code", + "placeholders": { + "seconds": { + "type": "int", + "example": "60" + } + } + }, "entityDetailsCategoryTitle": "Category", "@entityDetailsCategoryTitle": { "description": "Title for category entity type" diff --git a/pubspec.lock b/pubspec.lock index d85cc50..b4298f2 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -705,6 +705,14 @@ packages: url: "https://pub.dev" source: hosted version: "6.1.0" + pinput: + dependency: "direct main" + description: + name: pinput + sha256: "8a73be426a91fefec90a7f130763ca39772d547e92f19a827cf4aa02e323d35a" + url: "https://pub.dev" + source: hosted + version: "5.0.1" platform: dependency: transitive description: @@ -991,6 +999,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.2.2" + universal_platform: + dependency: transitive + description: + name: universal_platform + sha256: "64e16458a0ea9b99260ceb5467a214c1f298d647c659af1bff6d3bf82536b1ec" + url: "https://pub.dev" + source: hosted + version: "1.1.0" url_launcher: dependency: "direct main" description: diff --git a/pubspec.yaml b/pubspec.yaml index 7eae33a..32b0b57 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -62,6 +62,7 @@ dependencies: url: https://github.com/flutter-news-app-full-source-code/kv-storage-shared-preferences.git logging: ^1.3.0 meta: ^1.16.0 + pinput: ^5.0.1 share_plus: ^11.0.0 stream_transform: ^2.1.1 timeago: ^3.7.1