Skip to content

Commit d0b4046

Browse files
authored
Merge pull request #55 from nimblehq/feature/8-ui-login-fail-alert
[#8] [UI] As a user, if I fail to log in, I see an error popup
2 parents 1a2fee7 + 79981c7 commit d0b4046

File tree

14 files changed

+307
-38
lines changed

14 files changed

+307
-38
lines changed

lib/l10n/app_en.arb

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,12 @@
11
{
2+
"okText": "OK",
23
"emailInputHint": "Email",
34
"passwordInputHint": "Password",
45
"loginButton": "Log in",
56
"invalidEmailError": "Please enter the valid email format.",
6-
"invalidPasswordError": "Password must be at least 8 characters long."
7+
"invalidPasswordError": "Password must be at least 8 characters long.",
8+
"unauthorizedError": "Your email or password is incorrect.\nPlease try again.",
9+
"noInternetConnectionError": "You seem to be offline.\nPlease try again!",
10+
"genericError": "Something went wrong.\nPlease try again!",
11+
"loginFailAlertTitle": "Unable to log in"
712
}

lib/screens/login/login_form.dart

Lines changed: 21 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -12,21 +12,25 @@ class LoginForm extends ConsumerStatefulWidget {
1212
const LoginForm({Key? key}) : super(key: key);
1313

1414
@override
15-
ConsumerState<ConsumerStatefulWidget> createState() => _LoginFormState();
15+
ConsumerState<LoginForm> createState() => _LoginFormState();
1616
}
1717

1818
class _LoginFormState extends ConsumerState<LoginForm> {
1919
final _formKey = GlobalKey<FormState>();
20+
final _emailController = TextEditingController();
21+
final _passwordController = TextEditingController();
22+
2023
bool _isFormSubmitted = false;
2124

2225
TextFormField get _emailTextField => TextFormField(
2326
keyboardType: TextInputType.emailAddress,
2427
autocorrect: false,
2528
decoration: PrimaryTextFieldDecoration(
26-
hintText: context.localizations?.emailInputHint,
29+
hintText: context.localizations.emailInputHint,
2730
hintTextStyle: context.textTheme.bodyMedium,
2831
),
2932
style: context.textTheme.bodyMedium,
33+
controller: _emailController,
3034
validator: _validateEmail,
3135
autovalidateMode: _isFormSubmitted
3236
? AutovalidateMode.onUserInteraction
@@ -37,10 +41,11 @@ class _LoginFormState extends ConsumerState<LoginForm> {
3741
autocorrect: false,
3842
obscureText: true,
3943
decoration: PrimaryTextFieldDecoration(
40-
hintText: context.localizations?.passwordInputHint,
44+
hintText: context.localizations.passwordInputHint,
4145
hintTextStyle: context.textTheme.bodyMedium,
4246
),
4347
style: context.textTheme.bodyMedium,
48+
controller: _passwordController,
4449
validator: _validatePassword,
4550
autovalidateMode: _isFormSubmitted
4651
? AutovalidateMode.onUserInteraction
@@ -50,29 +55,36 @@ class _LoginFormState extends ConsumerState<LoginForm> {
5055
ElevatedButton get _loginButton => ElevatedButton(
5156
style: PrimaryButtonStyle(hintTextStyle: context.textTheme.labelMedium),
5257
onPressed: _submit,
53-
child: Text(context.localizations?.loginButton ?? ''),
58+
child: Text(context.localizations.loginButton),
5459
);
5560

5661
String? _validateEmail(String? email) {
5762
if (!ref.read(loginViewModelProvider.notifier).isValidEmail(email)) {
58-
return context.localizations?.invalidEmailError;
63+
return context.localizations.invalidEmailError;
5964
}
6065
return null;
6166
}
6267

6368
String? _validatePassword(String? password) {
6469
if (!ref.read(loginViewModelProvider.notifier).isValidPassword(password)) {
65-
return context.localizations?.invalidPasswordError;
70+
return context.localizations.invalidPasswordError;
6671
}
6772
return null;
6873
}
6974

7075
void _submit() {
7176
setState(() => _isFormSubmitted = true);
72-
if (_formKey.currentState?.validate() == true) {
73-
context.dismissKeyboard();
74-
// TODO: Integrate with API
77+
final isFormValidated = _formKey.currentState?.validate() ?? false;
78+
79+
if (!isFormValidated) {
80+
return;
7581
}
82+
83+
context.dismissKeyboard();
84+
ref.read(loginViewModelProvider.notifier).login(
85+
email: _emailController.text,
86+
password: _passwordController.text,
87+
);
7688
}
7789

7890
@override

lib/screens/login/login_screen.dart

Lines changed: 35 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,25 @@
11
import 'dart:ui';
22

33
import 'package:flutter/material.dart';
4+
import 'package:flutter_riverpod/flutter_riverpod.dart';
45
import 'package:survey_flutter/gen/assets.gen.dart';
56
import 'package:survey_flutter/screens/login/login_form.dart';
7+
import 'package:survey_flutter/screens/login/login_view_model.dart';
68
import 'package:survey_flutter/theme/app_constants.dart';
9+
import 'package:survey_flutter/uimodels/app_error.dart';
710
import 'package:survey_flutter/utils/build_context_ext.dart';
11+
import 'package:survey_flutter/widgets/alert_dialog.dart';
812

913
const routePathLoginScreen = '/login';
1014

11-
class LoginScreen extends StatefulWidget {
15+
class LoginScreen extends ConsumerStatefulWidget {
1216
const LoginScreen({Key? key}) : super(key: key);
1317

1418
@override
15-
State<StatefulWidget> createState() => _LoginScreenState();
19+
ConsumerState<LoginScreen> createState() => _LoginScreenState();
1620
}
1721

18-
class _LoginScreenState extends State<LoginScreen>
22+
class _LoginScreenState extends ConsumerState<LoginScreen>
1923
with TickerProviderStateMixin {
2024
final _animationDuration = const Duration(milliseconds: 600);
2125

@@ -108,8 +112,36 @@ class _LoginScreenState extends State<LoginScreen>
108112
),
109113
);
110114

115+
_setUpListener(BuildContext context) {
116+
ref.listen<AsyncValue<void>>(loginViewModelProvider, (_, next) {
117+
next.maybeWhen(
118+
data: (_) {
119+
// TODO: Navigate to the Home screen
120+
},
121+
error: (error, _) {
122+
showAlertDialog(
123+
context: context,
124+
title: context.localizations.loginFailAlertTitle,
125+
message: (error as AppError?)?.description(context) ?? '',
126+
actions: [
127+
TextButton(
128+
style: ButtonStyle(
129+
foregroundColor: MaterialStateProperty.all(Colors.black),
130+
),
131+
child: Text(context.localizations.okText),
132+
onPressed: () => Navigator.pop(context),
133+
)
134+
],
135+
);
136+
},
137+
orElse: () {},
138+
);
139+
});
140+
}
141+
111142
@override
112143
Widget build(BuildContext context) {
144+
_setUpListener(context);
113145
return GestureDetector(
114146
onTap: () => context.dismissKeyboard(),
115147
child: Scaffold(
Lines changed: 42 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,14 @@
1+
import 'dart:async';
2+
13
import 'package:flutter_riverpod/flutter_riverpod.dart';
4+
import 'package:survey_flutter/uimodels/app_error.dart';
5+
import 'package:survey_flutter/utils/internet_connection_manager.dart';
26

37
final loginViewModelProvider =
4-
StateNotifierProvider.autoDispose<LoginViewModel, void>((_) {
5-
return LoginViewModel();
6-
});
8+
AsyncNotifierProvider.autoDispose<LoginViewModel, void>(LoginViewModel.new);
79

8-
class LoginViewModel extends StateNotifier<void> {
9-
LoginViewModel() : super([]);
10+
class LoginViewModel extends AutoDisposeAsyncNotifier<void> {
11+
late InternetConnectionManager internetConnectionManager;
1012

1113
bool isValidEmail(String? email) {
1214
// Just use a simple rule, no fancy Regex!
@@ -16,4 +18,39 @@ class LoginViewModel extends StateNotifier<void> {
1618
bool isValidPassword(String? password) {
1719
return !(password == null || password.length < 8);
1820
}
21+
22+
Future<void> login({
23+
required String email,
24+
required String password,
25+
}) async {
26+
state = const AsyncLoading();
27+
// TODO: Integrate with API
28+
29+
// Handling error part:
30+
31+
// If it returns unauthorized error (401)
32+
//state = const AsyncError(
33+
// AppError.unauthorized,
34+
// StackTrace.empty,
35+
//);
36+
37+
// If it returns timeout error, then check Internet connection
38+
internetConnectionManager = ref.read(internetConnectionManagerProvider);
39+
final isConnected = await internetConnectionManager.hasConnection();
40+
41+
if (!isConnected) {
42+
state = const AsyncError(
43+
AppError.noInternetConnection,
44+
StackTrace.empty,
45+
);
46+
} else {
47+
state = const AsyncError(
48+
AppError.generic,
49+
StackTrace.empty,
50+
);
51+
}
52+
}
53+
54+
@override
55+
FutureOr<void> build() {}
1956
}

lib/theme/app_theme.dart

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,11 @@ class AppTheme {
1515
fontSize: 17,
1616
fontWeight: FontWeight.bold,
1717
),
18+
labelLarge: TextStyle(
19+
color: Colors.white,
20+
fontSize: 20,
21+
fontWeight: FontWeight.bold,
22+
),
1823
),
1924
textSelectionTheme: const TextSelectionThemeData(
2025
cursorColor: Colors.white,

lib/uimodels/app_error.dart

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import 'package:flutter/material.dart';
2+
import 'package:survey_flutter/utils/build_context_ext.dart';
3+
4+
enum AppError {
5+
unauthorized,
6+
noInternetConnection,
7+
generic,
8+
}
9+
10+
extension AppErrorExtension on AppError {
11+
String description(BuildContext context) {
12+
switch (this) {
13+
case AppError.unauthorized:
14+
return context.localizations.unauthorizedError;
15+
case AppError.noInternetConnection:
16+
return context.localizations.noInternetConnectionError;
17+
case AppError.generic:
18+
return context.localizations.genericError;
19+
}
20+
}
21+
}

lib/utils/build_context_ext.dart

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
import 'package:flutter/material.dart';
22
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
3+
import 'package:flutter_gen/gen_l10n/app_localizations_en.dart';
34

45
extension BuildContextExtension on BuildContext {
56
TextTheme get textTheme => Theme.of(this).textTheme;
67

7-
AppLocalizations? get localizations => AppLocalizations.of(this);
8+
AppLocalizations get localizations =>
9+
AppLocalizations.of(this) ?? AppLocalizationsEn();
810

911
dismissKeyboard() {
1012
FocusNode currentFocusNode = FocusScope.of(this);
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import 'package:internet_connection_checker/internet_connection_checker.dart';
2+
import 'package:flutter_riverpod/flutter_riverpod.dart';
3+
4+
abstract class InternetConnectionManager {
5+
Future<bool> hasConnection();
6+
}
7+
8+
final internetConnectionManagerProvider =
9+
Provider<InternetConnectionManager>((_) {
10+
return InternetConnectionManagerImpl();
11+
});
12+
13+
class InternetConnectionManagerImpl extends InternetConnectionManager {
14+
final InternetConnectionChecker _internetConnectionChecker =
15+
InternetConnectionChecker();
16+
17+
@override
18+
Future<bool> hasConnection() async =>
19+
_internetConnectionChecker.hasConnection;
20+
}

lib/widgets/alert_dialog.dart

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import 'dart:io';
2+
3+
import 'package:flutter/cupertino.dart';
4+
import 'package:flutter/material.dart';
5+
import 'package:survey_flutter/utils/build_context_ext.dart';
6+
7+
Future<void> showAlertDialog({
8+
required BuildContext context,
9+
required String title,
10+
required String message,
11+
required List<Widget> actions,
12+
}) async {
13+
if (Platform.isIOS) {
14+
await showCupertinoDialog(
15+
context: context,
16+
builder: (_) => CupertinoAlertDialog(
17+
title: Text(title),
18+
content: Text(message),
19+
actions: actions,
20+
),
21+
);
22+
} else if (Platform.isAndroid) {
23+
await showDialog(
24+
context: context,
25+
barrierDismissible: false,
26+
builder: (_) => AlertDialog(
27+
title: Text(title),
28+
titleTextStyle:
29+
context.textTheme.labelLarge?.copyWith(color: Colors.black),
30+
content: Text(message),
31+
contentTextStyle:
32+
context.textTheme.labelMedium?.copyWith(color: Colors.black),
33+
actions: actions,
34+
),
35+
);
36+
}
37+
}

pubspec.lock

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -404,6 +404,14 @@ packages:
404404
description: flutter
405405
source: sdk
406406
version: "0.0.0"
407+
internet_connection_checker:
408+
dependency: "direct main"
409+
description:
410+
name: internet_connection_checker
411+
sha256: "1c683e63e89c9ac66a40748b1b20889fd9804980da732bf2b58d6d5456c8e876"
412+
url: "https://pub.dev"
413+
source: hosted
414+
version: "1.0.0+1"
407415
intl:
408416
dependency: "direct main"
409417
description:

0 commit comments

Comments
 (0)