Skip to content

Commit 2f6fdc4

Browse files
authored
Merge pull request #63 from flutter-news-app-full-source-code/enhance_auth_feature
Enhance auth feature
2 parents 821b928 + 6c34bd0 commit 2f6fdc4

12 files changed

+238
-73
lines changed

lib/authentication/bloc/authentication_bloc.dart

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ import 'package:equatable/equatable.dart';
88
part 'authentication_event.dart';
99
part 'authentication_state.dart';
1010

11+
const _requestCodeCooldownDuration = Duration(seconds: 60);
12+
1113
/// {@template authentication_bloc}
1214
/// Bloc responsible for managing the authentication state of the application.
1315
/// {@endtemplate}
@@ -31,6 +33,7 @@ class AuthenticationBloc
3133
_onAuthenticationAnonymousSignInRequested,
3234
);
3335
on<AuthenticationSignOutRequested>(_onAuthenticationSignOutRequested);
36+
on<AuthenticationCooldownCompleted>(_onAuthenticationCooldownCompleted);
3437
}
3538

3639
final AuthRepository _authenticationRepository;
@@ -63,15 +66,27 @@ class AuthenticationBloc
6366
AuthenticationRequestSignInCodeRequested event,
6467
Emitter<AuthenticationState> emit,
6568
) async {
69+
if (state.cooldownEndTime != null &&
70+
state.cooldownEndTime!.isAfter(DateTime.now())) {
71+
return;
72+
}
73+
6674
emit(state.copyWith(status: AuthenticationStatus.requestCodeInProgress));
6775
try {
6876
await _authenticationRepository.requestSignInCode(event.email);
77+
final cooldownEndTime = DateTime.now().add(_requestCodeCooldownDuration);
6978
emit(
7079
state.copyWith(
7180
status: AuthenticationStatus.requestCodeSuccess,
7281
email: event.email,
82+
cooldownEndTime: cooldownEndTime,
7383
),
7484
);
85+
86+
Timer(
87+
_requestCodeCooldownDuration,
88+
() => add(const AuthenticationCooldownCompleted()),
89+
);
7590
} on HttpException catch (e) {
7691
emit(state.copyWith(status: AuthenticationStatus.failure, exception: e));
7792
} catch (e) {
@@ -155,4 +170,16 @@ class AuthenticationBloc
155170
_userAuthSubscription.cancel();
156171
return super.close();
157172
}
173+
174+
void _onAuthenticationCooldownCompleted(
175+
AuthenticationCooldownCompleted event,
176+
Emitter<AuthenticationState> emit,
177+
) {
178+
emit(
179+
state.copyWith(
180+
status: AuthenticationStatus.initial,
181+
clearCooldownEndTime: true,
182+
),
183+
);
184+
}
158185
}

lib/authentication/bloc/authentication_event.dart

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,3 +76,11 @@ final class _AuthenticationUserChanged extends AuthenticationEvent {
7676
@override
7777
List<Object?> get props => [user];
7878
}
79+
80+
/// {@template authentication_cooldown_completed}
81+
/// Event triggered when the sign-in code request cooldown has completed.
82+
/// {@endtemplate}
83+
final class AuthenticationCooldownCompleted extends AuthenticationEvent {
84+
/// {@macro authentication_cooldown_completed}
85+
const AuthenticationCooldownCompleted();
86+
}

lib/authentication/bloc/authentication_state.dart

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ class AuthenticationState extends Equatable {
3939
this.user,
4040
this.email,
4141
this.exception,
42+
this.cooldownEndTime,
4243
});
4344

4445
/// The current status of the authentication process.
@@ -53,21 +54,28 @@ class AuthenticationState extends Equatable {
5354
/// The exception that occurred, if any.
5455
final HttpException? exception;
5556

57+
/// The time when the cooldown for requesting a new code ends.
58+
final DateTime? cooldownEndTime;
59+
5660
/// Creates a copy of the current [AuthenticationState] with updated values.
5761
AuthenticationState copyWith({
5862
AuthenticationStatus? status,
5963
User? user,
6064
String? email,
6165
HttpException? exception,
66+
DateTime? cooldownEndTime,
67+
bool clearCooldownEndTime = false,
6268
}) {
6369
return AuthenticationState(
6470
status: status ?? this.status,
6571
user: user ?? this.user,
6672
email: email ?? this.email,
6773
exception: exception ?? this.exception,
74+
cooldownEndTime:
75+
clearCooldownEndTime ? null : cooldownEndTime ?? this.cooldownEndTime,
6876
);
6977
}
7078

7179
@override
72-
List<Object?> get props => [status, user, email, exception];
80+
List<Object?> get props => [status, user, email, exception, cooldownEndTime];
7381
}

lib/authentication/view/email_code_verification_page.dart

Lines changed: 38 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
11
import 'package:flutter/material.dart';
2-
import 'package:flutter/services.dart';
32
import 'package:flutter_bloc/flutter_bloc.dart';
43
import 'package:flutter_news_app_mobile_client_full_source_code/app/bloc/app_bloc.dart';
54
import 'package:flutter_news_app_mobile_client_full_source_code/app/config/config.dart';
65
import 'package:flutter_news_app_mobile_client_full_source_code/authentication/bloc/authentication_bloc.dart';
76
import 'package:flutter_news_app_mobile_client_full_source_code/l10n/l10n.dart';
7+
import 'package:pinput/pinput.dart';
88
import 'package:ui_kit/ui_kit.dart';
99

1010
/// {@template email_code_verification_page}
@@ -130,10 +130,12 @@ class _EmailCodeVerificationFormState
130130
extends State<_EmailCodeVerificationForm> {
131131
final _formKey = GlobalKey<FormState>();
132132
final _codeController = TextEditingController();
133+
final _focusNode = FocusNode();
133134

134135
@override
135136
void dispose() {
136137
_codeController.dispose();
138+
_focusNode.dispose();
137139
super.dispose();
138140
}
139141

@@ -152,39 +154,48 @@ class _EmailCodeVerificationFormState
152154
Widget build(BuildContext context) {
153155
final l10n = AppLocalizationsX(context).l10n;
154156
final textTheme = Theme.of(context).textTheme;
157+
final colorScheme = Theme.of(context).colorScheme;
158+
159+
final defaultPinTheme = PinTheme(
160+
width: 56,
161+
height: 60,
162+
textStyle: textTheme.headlineSmall,
163+
decoration: BoxDecoration(
164+
color: colorScheme.surface,
165+
borderRadius: BorderRadius.circular(8),
166+
border: Border.all(color: colorScheme.onSurface.withOpacity(0.12)),
167+
),
168+
);
155169

156170
return Form(
157171
key: _formKey,
158172
child: Column(
159173
mainAxisSize: MainAxisSize.min,
160174
children: [
161-
Padding(
162-
// No horizontal padding needed if column is stretched
163-
// padding: const EdgeInsets.symmetric(horizontal: AppSpacing.md),
164-
padding: EdgeInsets.zero,
165-
child: TextFormField(
166-
controller: _codeController,
167-
decoration: InputDecoration(
168-
labelText: l10n.emailCodeVerificationHint,
169-
// border: const OutlineInputBorder(),
170-
counterText: '',
175+
Pinput(
176+
length: 6,
177+
controller: _codeController,
178+
focusNode: _focusNode,
179+
defaultPinTheme: defaultPinTheme,
180+
onCompleted: (pin) => _submitForm(),
181+
validator: (value) {
182+
if (value == null || value.isEmpty) {
183+
return l10n.emailCodeValidationEmptyError;
184+
}
185+
if (value.length != 6) {
186+
return l10n.emailCodeValidationLengthError;
187+
}
188+
return null;
189+
},
190+
focusedPinTheme: defaultPinTheme.copyWith(
191+
decoration: defaultPinTheme.decoration!.copyWith(
192+
border: Border.all(color: colorScheme.primary),
193+
),
194+
),
195+
errorPinTheme: defaultPinTheme.copyWith(
196+
decoration: defaultPinTheme.decoration!.copyWith(
197+
border: Border.all(color: colorScheme.error),
171198
),
172-
keyboardType: TextInputType.number,
173-
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
174-
maxLength: 6,
175-
textAlign: TextAlign.center,
176-
style: textTheme.headlineSmall,
177-
enabled: !widget.isLoading,
178-
validator: (value) {
179-
if (value == null || value.isEmpty) {
180-
return l10n.emailCodeValidationEmptyError;
181-
}
182-
if (value.length != 6) {
183-
return l10n.emailCodeValidationLengthError;
184-
}
185-
return null;
186-
},
187-
onFieldSubmitted: widget.isLoading ? null : (_) => _submitForm(),
188199
),
189200
),
190201
const SizedBox(height: AppSpacing.xxl),

lib/authentication/view/request_code_page.dart

Lines changed: 103 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
//
22
// ignore_for_file: lines_longer_than_80_chars
33

4+
import 'dart:async';
5+
46
import 'package:flutter/material.dart';
57
import 'package:flutter_bloc/flutter_bloc.dart';
68
import 'package:flutter_news_app_mobile_client_full_source_code/authentication/bloc/authentication_bloc.dart';
@@ -161,20 +163,58 @@ class _EmailLinkForm extends StatefulWidget {
161163
class _EmailLinkFormState extends State<_EmailLinkForm> {
162164
final _emailController = TextEditingController();
163165
final _formKey = GlobalKey<FormState>();
166+
Timer? _cooldownTimer;
167+
int _cooldownSeconds = 0;
168+
169+
@override
170+
void initState() {
171+
super.initState();
172+
final authState = context.read<AuthenticationBloc>().state;
173+
if (authState.cooldownEndTime != null &&
174+
authState.cooldownEndTime!.isAfter(DateTime.now())) {
175+
_startCooldownTimer(authState.cooldownEndTime!);
176+
}
177+
}
164178

165179
@override
166180
void dispose() {
167181
_emailController.dispose();
182+
_cooldownTimer?.cancel();
168183
super.dispose();
169184
}
170185

186+
void _startCooldownTimer(DateTime endTime) {
187+
final now = DateTime.now();
188+
if (now.isBefore(endTime)) {
189+
setState(() {
190+
_cooldownSeconds = endTime.difference(now).inSeconds;
191+
});
192+
_cooldownTimer = Timer.periodic(const Duration(seconds: 1), (timer) {
193+
final remaining = endTime.difference(DateTime.now()).inSeconds;
194+
if (remaining > 0) {
195+
setState(() {
196+
_cooldownSeconds = remaining;
197+
});
198+
} else {
199+
timer.cancel();
200+
setState(() {
201+
_cooldownSeconds = 0;
202+
});
203+
context
204+
.read<AuthenticationBloc>()
205+
.add(const AuthenticationCooldownCompleted());
206+
}
207+
});
208+
}
209+
}
210+
171211
void _submitForm() {
172212
if (_formKey.currentState!.validate()) {
173213
context.read<AuthenticationBloc>().add(
174-
AuthenticationRequestSignInCodeRequested(
175-
email: _emailController.text.trim(),
176-
),
177-
);
214+
AuthenticationRequestSignInCodeRequested(
215+
email: _emailController.text.trim(),
216+
),
217+
);
178218
}
179219
}
180220

@@ -184,49 +224,67 @@ class _EmailLinkFormState extends State<_EmailLinkForm> {
184224
final textTheme = Theme.of(context).textTheme;
185225
final colorScheme = Theme.of(context).colorScheme;
186226

187-
return Form(
188-
key: _formKey,
189-
child: Column(
190-
crossAxisAlignment: CrossAxisAlignment.stretch,
191-
children: [
192-
TextFormField(
193-
controller: _emailController,
194-
decoration: InputDecoration(
195-
labelText: l10n.requestCodeEmailLabel,
196-
hintText: l10n.requestCodeEmailHint,
197-
// border: const OutlineInputBorder(),
227+
return BlocListener<AuthenticationBloc, AuthenticationState>(
228+
listenWhen: (previous, current) =>
229+
previous.cooldownEndTime != current.cooldownEndTime,
230+
listener: (context, state) {
231+
if (state.cooldownEndTime != null &&
232+
state.cooldownEndTime!.isAfter(DateTime.now())) {
233+
_cooldownTimer?.cancel();
234+
_startCooldownTimer(state.cooldownEndTime!);
235+
}
236+
},
237+
child: Form(
238+
key: _formKey,
239+
child: Column(
240+
crossAxisAlignment: CrossAxisAlignment.stretch,
241+
children: [
242+
TextFormField(
243+
controller: _emailController,
244+
decoration: InputDecoration(
245+
labelText: l10n.requestCodeEmailLabel,
246+
hintText: l10n.requestCodeEmailHint,
247+
),
248+
keyboardType: TextInputType.emailAddress,
249+
autocorrect: false,
250+
textInputAction: TextInputAction.done,
251+
enabled: !widget.isLoading && _cooldownSeconds == 0,
252+
validator: (value) {
253+
if (value == null || value.isEmpty || !value.contains('@')) {
254+
return l10n.accountLinkingEmailValidationError;
255+
}
256+
return null;
257+
},
258+
onFieldSubmitted:
259+
widget.isLoading || _cooldownSeconds > 0 ? null : (_) => _submitForm(),
198260
),
199-
keyboardType: TextInputType.emailAddress,
200-
autocorrect: false,
201-
textInputAction: TextInputAction.done,
202-
enabled: !widget.isLoading,
203-
validator: (value) {
204-
if (value == null || value.isEmpty || !value.contains('@')) {
205-
return l10n.accountLinkingEmailValidationError;
206-
}
207-
return null;
208-
},
209-
onFieldSubmitted: (_) => _submitForm(),
210-
),
211-
const SizedBox(height: AppSpacing.lg),
212-
ElevatedButton(
213-
onPressed: widget.isLoading ? null : _submitForm,
214-
style: ElevatedButton.styleFrom(
215-
padding: const EdgeInsets.symmetric(vertical: AppSpacing.md),
216-
textStyle: textTheme.labelLarge,
261+
const SizedBox(height: AppSpacing.lg),
262+
ElevatedButton(
263+
onPressed:
264+
widget.isLoading || _cooldownSeconds > 0 ? null : _submitForm,
265+
style: ElevatedButton.styleFrom(
266+
padding: const EdgeInsets.symmetric(vertical: AppSpacing.md),
267+
textStyle: textTheme.labelLarge,
268+
),
269+
child: widget.isLoading
270+
? SizedBox(
271+
height: AppSpacing.xl,
272+
width: AppSpacing.xl,
273+
child: CircularProgressIndicator(
274+
strokeWidth: 2,
275+
color: colorScheme.onPrimary,
276+
),
277+
)
278+
: _cooldownSeconds > 0
279+
? Text(
280+
l10n.requestCodeResendButtonCooldown(
281+
_cooldownSeconds,
282+
),
283+
)
284+
: Text(l10n.requestCodeSendCodeButton),
217285
),
218-
child: widget.isLoading
219-
? SizedBox(
220-
height: AppSpacing.xl,
221-
width: AppSpacing.xl,
222-
child: CircularProgressIndicator(
223-
strokeWidth: 2,
224-
color: colorScheme.onPrimary,
225-
),
226-
)
227-
: Text(l10n.requestCodeSendCodeButton),
228-
),
229-
],
286+
],
287+
),
230288
),
231289
);
232290
}

0 commit comments

Comments
 (0)