Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[#8] [UI] As a user, if I fail to log in, I see an error popup #55

Merged
merged 4 commits into from
Aug 17, 2023
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion lib/l10n/app_en.arb
Original file line number Diff line number Diff line change
@@ -1,7 +1,12 @@
{
"okText": "OK",
"emailInputHint": "Email",
"passwordInputHint": "Password",
"loginButton": "Log in",
"invalidEmailError": "Please enter the valid email format.",
"invalidPasswordError": "Password must be at least 8 characters long."
"invalidPasswordError": "Password must be at least 8 characters long.",
"unauthorizedError": "Your email or password is incorrect.\nPlease try again.",
"noInternetConnectionError": "You seem to be offline.\nPlease try again!",
"genericError": "Something went wrong.\nPlease try again!",
"loginFailAlertTitle": "Unable to log in"
}
30 changes: 21 additions & 9 deletions lib/screens/login/login_form.dart
Original file line number Diff line number Diff line change
Expand Up @@ -12,21 +12,25 @@ class LoginForm extends ConsumerStatefulWidget {
const LoginForm({Key? key}) : super(key: key);

@override
ConsumerState<ConsumerStatefulWidget> createState() => _LoginFormState();
ConsumerState<LoginForm> createState() => _LoginFormState();
}

class _LoginFormState extends ConsumerState<LoginForm> {
final _formKey = GlobalKey<FormState>();
final _emailController = TextEditingController();
final _passwordController = TextEditingController();

bool _isFormSubmitted = false;

TextFormField get _emailTextField => TextFormField(
keyboardType: TextInputType.emailAddress,
autocorrect: false,
decoration: PrimaryTextFieldDecoration(
hintText: context.localizations?.emailInputHint,
hintText: context.localizations.emailInputHint,
hintTextStyle: context.textTheme.bodyMedium,
),
style: context.textTheme.bodyMedium,
controller: _emailController,
validator: _validateEmail,
autovalidateMode: _isFormSubmitted
? AutovalidateMode.onUserInteraction
Expand All @@ -37,10 +41,11 @@ class _LoginFormState extends ConsumerState<LoginForm> {
autocorrect: false,
obscureText: true,
decoration: PrimaryTextFieldDecoration(
hintText: context.localizations?.passwordInputHint,
hintText: context.localizations.passwordInputHint,
hintTextStyle: context.textTheme.bodyMedium,
),
style: context.textTheme.bodyMedium,
controller: _passwordController,
validator: _validatePassword,
autovalidateMode: _isFormSubmitted
? AutovalidateMode.onUserInteraction
Expand All @@ -50,29 +55,36 @@ class _LoginFormState extends ConsumerState<LoginForm> {
ElevatedButton get _loginButton => ElevatedButton(
style: PrimaryButtonStyle(hintTextStyle: context.textTheme.labelMedium),
onPressed: _submit,
child: Text(context.localizations?.loginButton ?? ''),
child: Text(context.localizations.loginButton),
);

String? _validateEmail(String? email) {
if (!ref.read(loginViewModelProvider.notifier).isValidEmail(email)) {
return context.localizations?.invalidEmailError;
return context.localizations.invalidEmailError;
}
return null;
}

String? _validatePassword(String? password) {
if (!ref.read(loginViewModelProvider.notifier).isValidPassword(password)) {
return context.localizations?.invalidPasswordError;
return context.localizations.invalidPasswordError;
}
return null;
}

void _submit() {
setState(() => _isFormSubmitted = true);
if (_formKey.currentState?.validate() == true) {
context.dismissKeyboard();
// TODO: Integrate with API
bool isFormValidated = _formKey.currentState?.validate() ?? false;
Thieurom marked this conversation as resolved.
Show resolved Hide resolved

if (!isFormValidated) {
return;
}

context.dismissKeyboard();
ref.read(loginViewModelProvider.notifier).login(
email: _emailController.text,
password: _passwordController.text,
);
}

@override
Expand Down
35 changes: 32 additions & 3 deletions lib/screens/login/login_screen.dart
Original file line number Diff line number Diff line change
@@ -1,21 +1,25 @@
import 'dart:ui';

import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:survey_flutter/gen/assets.gen.dart';
import 'package:survey_flutter/screens/login/login_form.dart';
import 'package:survey_flutter/screens/login/login_view_model.dart';
import 'package:survey_flutter/theme/app_constants.dart';
import 'package:survey_flutter/uimodels/app_error.dart';
import 'package:survey_flutter/utils/build_context_ext.dart';
import 'package:survey_flutter/widgets/alert_dialog.dart';

const routePathLoginScreen = '/login';

class LoginScreen extends StatefulWidget {
class LoginScreen extends ConsumerStatefulWidget {
const LoginScreen({Key? key}) : super(key: key);

@override
State<StatefulWidget> createState() => _LoginScreenState();
ConsumerState<LoginScreen> createState() => _LoginScreenState();
}

class _LoginScreenState extends State<LoginScreen>
class _LoginScreenState extends ConsumerState<LoginScreen>
with TickerProviderStateMixin {
final _animationDuration = const Duration(milliseconds: 600);

Expand Down Expand Up @@ -110,6 +114,31 @@ class _LoginScreenState extends State<LoginScreen>

@override
Widget build(BuildContext context) {
ref.listen<AsyncValue<void>>(loginViewModelProvider, (_, next) {
Thieurom marked this conversation as resolved.
Show resolved Hide resolved
next.maybeWhen(
data: (_) {
// TODO: Navigate to the Home screen
},
error: (error, _) {
showAlertDialog(
context: context,
title: context.localizations.loginFailAlertTitle,
message: (error as AppError?)?.description(context) ?? '',
actions: [
TextButton(
style: ButtonStyle(
foregroundColor: MaterialStateProperty.all(Colors.black),
),
child: Text(context.localizations.okText),
onPressed: () => Navigator.pop(context),
)
],
);
},
orElse: () {},
);
});

return GestureDetector(
onTap: () => context.dismissKeyboard(),
child: Scaffold(
Expand Down
44 changes: 39 additions & 5 deletions lib/screens/login/login_view_model.dart
doannimble marked this conversation as resolved.
Show resolved Hide resolved
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
import 'dart:async';

import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:survey_flutter/uimodels/app_error.dart';
import 'package:survey_flutter/utils/internet_connection_manager.dart';

final loginViewModelProvider =
StateNotifierProvider.autoDispose<LoginViewModel, void>((_) {
return LoginViewModel();
});
AsyncNotifierProvider.autoDispose<LoginViewModel, void>(LoginViewModel.new);

class LoginViewModel extends StateNotifier<void> {
LoginViewModel() : super([]);
class LoginViewModel extends AutoDisposeAsyncNotifier<void> {
late InternetConnectionManager internetConnectionManager;

bool isValidEmail(String? email) {
// Just use a simple rule, no fancy Regex!
Expand All @@ -16,4 +18,36 @@
bool isValidPassword(String? password) {
return !(password == null || password.length < 8);
}

login({required String email, required String password}) async {
Thieurom marked this conversation as resolved.
Show resolved Hide resolved
state = const AsyncLoading();
// TODO: Integrate with API

// Handling error part:

// If it returns unauthorized error (401)
//state = const AsyncError(
// AppError.unauthorized,
// StackTrace.empty,
//);

// If it returns timeout error, then check Internet connection
internetConnectionManager = ref.read(internetConnectionManagerProvider);
final isConnected = await internetConnectionManager.hasConnection();

if (!isConnected) {
state = const AsyncError(

Check warning on line 39 in lib/screens/login/login_view_model.dart

View check run for this annotation

Codecov / codecov/patch

lib/screens/login/login_view_model.dart#L39

Added line #L39 was not covered by tests
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

May I ask if we need to write unit tests for these to increase the code coverage? as the Codevov just got failed 🙏
https://github.com/nimblehq/flutter-ic-khanh-thieu/pull/55/checks?check_run_id=15928008166

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I noticed this too. However, the login() method is not integrated with API yet. I think we can supplement unit tests for LoginViewModel in the integrating task 🙏

AppError.noInternetConnection,
StackTrace.empty,
);
} else {
state = const AsyncError(
AppError.generic,
StackTrace.empty,
);
}
}

@override
FutureOr<void> build() {}
}
5 changes: 5 additions & 0 deletions lib/theme/app_theme.dart
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,11 @@ class AppTheme {
fontSize: 17,
fontWeight: FontWeight.bold,
),
labelLarge: TextStyle(
color: Colors.white,
fontSize: 20,
fontWeight: FontWeight.bold,
),
),
textSelectionTheme: const TextSelectionThemeData(
cursorColor: Colors.white,
Expand Down
21 changes: 21 additions & 0 deletions lib/uimodels/app_error.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import 'package:flutter/material.dart';
import 'package:survey_flutter/utils/build_context_ext.dart';

enum AppError {
unauthorized,
noInternetConnection,
generic,
}

extension AppErrorExtension on AppError {
String description(BuildContext context) {

Check warning on line 11 in lib/uimodels/app_error.dart

View check run for this annotation

Codecov / codecov/patch

lib/uimodels/app_error.dart#L11

Added line #L11 was not covered by tests
switch (this) {
case AppError.unauthorized:
return context.localizations.unauthorizedError;
case AppError.noInternetConnection:
return context.localizations.noInternetConnectionError;
case AppError.generic:
return context.localizations.genericError;

Check warning on line 18 in lib/uimodels/app_error.dart

View check run for this annotation

Codecov / codecov/patch

lib/uimodels/app_error.dart#L13-L18

Added lines #L13 - L18 were not covered by tests
}
}
}
4 changes: 3 additions & 1 deletion lib/utils/build_context_ext.dart
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:flutter_gen/gen_l10n/app_localizations_en.dart';

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

AppLocalizations? get localizations => AppLocalizations.of(this);
AppLocalizations get localizations =>
AppLocalizations.of(this) ?? AppLocalizationsEn();

Check warning on line 10 in lib/utils/build_context_ext.dart

View check run for this annotation

Codecov / codecov/patch

lib/utils/build_context_ext.dart#L8-L10

Added lines #L8 - L10 were not covered by tests
dismissKeyboard() {
FocusNode currentFocusNode = FocusScope.of(this);
if (!currentFocusNode.hasPrimaryFocus) {
Expand Down
20 changes: 20 additions & 0 deletions lib/utils/internet_connection_manager.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import 'package:internet_connection_checker/internet_connection_checker.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';

abstract class InternetConnectionManager {
Future<bool> hasConnection();
}

final internetConnectionManagerProvider =
Provider<InternetConnectionManager>((_) {
return InternetConnectionManagerImpl();
});

class InternetConnectionManagerImpl extends InternetConnectionManager {
final InternetConnectionChecker _internetConnectionChecker =
InternetConnectionChecker();

@override

Check warning on line 17 in lib/utils/internet_connection_manager.dart

View check run for this annotation

Codecov / codecov/patch

lib/utils/internet_connection_manager.dart#L17

Added line #L17 was not covered by tests
Future<bool> hasConnection() async =>
_internetConnectionChecker.hasConnection;

Check warning on line 19 in lib/utils/internet_connection_manager.dart

View check run for this annotation

Codecov / codecov/patch

lib/utils/internet_connection_manager.dart#L19

Added line #L19 was not covered by tests
}
37 changes: 37 additions & 0 deletions lib/widgets/alert_dialog.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import 'dart:io';

import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:survey_flutter/utils/build_context_ext.dart';

Future<void> showAlertDialog({
required BuildContext context,
required String title,
required String message,
required List<Widget> actions,
}) async {
if (Platform.isIOS) {
await showCupertinoDialog(
context: context,
builder: (BuildContext context) => CupertinoAlertDialog(
Thieurom marked this conversation as resolved.
Show resolved Hide resolved
title: Text(title),
content: Text(message),
actions: actions,
),
);
} else if (Platform.isAndroid) {
await showDialog(
context: context,
barrierDismissible: false,
builder: (BuildContext context) => AlertDialog(
Thieurom marked this conversation as resolved.
Show resolved Hide resolved
title: Text(title),
titleTextStyle:
context.textTheme.labelLarge?.copyWith(color: Colors.black),
content: Text(message),
contentTextStyle:
context.textTheme.labelMedium?.copyWith(color: Colors.black),
actions: actions,
),
);
}
}
8 changes: 8 additions & 0 deletions pubspec.lock
Original file line number Diff line number Diff line change
Expand Up @@ -404,6 +404,14 @@ packages:
description: flutter
source: sdk
version: "0.0.0"
internet_connection_checker:
dependency: "direct main"
description:
name: internet_connection_checker
sha256: "1c683e63e89c9ac66a40748b1b20889fd9804980da732bf2b58d6d5456c8e876"
url: "https://pub.dev"
source: hosted
version: "1.0.0+1"
intl:
dependency: "direct main"
description:
Expand Down
1 change: 1 addition & 0 deletions pubspec.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ dependencies:
japx: ^2.0.5
equatable: ^2.0.0
injectable: ^1.5.0
internet_connection_checker: ^1.0.0+1

dev_dependencies:
build_runner: ^2.4.4
Expand Down
13 changes: 11 additions & 2 deletions test/mocks/generate_mocks.dart
Original file line number Diff line number Diff line change
@@ -1,9 +1,18 @@
import 'package:dio/dio.dart';
import 'package:survey_flutter/api/authentication_api_service.dart';
import 'package:mockito/annotations.dart';
import 'package:survey_flutter/api/authentication_api_service.dart';
import 'package:survey_flutter/repositories/authentication_repository.dart';
import 'package:survey_flutter/utils/internet_connection_manager.dart';

import '../utils/async_listener.dart';

@GenerateMocks([AuthenticationApiService, AuthenticationRepository, DioError])
@GenerateMocks([
AsyncListener,
AuthenticationApiService,
AuthenticationRepository,
DioError,
InternetConnectionManager,
])
main() {
// empty class to generate mock repository classes
}
Loading
Loading