From 69da2431a61dd07664feaba46811df68ab15c4b7 Mon Sep 17 00:00:00 2001 From: Doan Thieu Date: Wed, 16 Aug 2023 12:18:14 +0700 Subject: [PATCH 1/4] [#10] Integrate log-in --- lib/di/provider/dio_provider.dart | 14 ++- .../authentication_repository.dart | 11 +- lib/screens/login/login_screen.dart | 14 +++ lib/screens/login/login_view_model.dart | 53 +++++--- lib/usecases/login_use_case.dart | 7 +- pubspec.lock | 16 --- pubspec.yaml | 1 - test/mocks/dummy_models.dart | 10 ++ test/mocks/generate_mocks.dart | 2 + test/screens/login/login_view_model_test.dart | 119 ++++++++++++++---- 10 files changed, 181 insertions(+), 66 deletions(-) create mode 100644 test/mocks/dummy_models.dart diff --git a/lib/di/provider/dio_provider.dart b/lib/di/provider/dio_provider.dart index 311db6c..62ff3b8 100644 --- a/lib/di/provider/dio_provider.dart +++ b/lib/di/provider/dio_provider.dart @@ -1,9 +1,10 @@ import 'package:dio/dio.dart'; import 'package:flutter/foundation.dart'; import 'package:survey_flutter/di/interceptor/app_interceptor.dart'; +import 'package:survey_flutter/env.dart'; -const String headerContentType = 'Content-Type'; -const String defaultContentType = 'application/json; charset=utf-8'; +const String _headerContentType = 'Content-Type'; +const String _defaultContentType = 'application/json; charset=utf-8'; class DioProvider { Dio? _dio; @@ -31,9 +32,10 @@ class DioProvider { } return dio - ..options.connectTimeout = const Duration(seconds: 3000) - ..options.receiveTimeout = const Duration(seconds: 5000) - ..options.headers = {headerContentType: defaultContentType} - ..interceptors.addAll(interceptors); + ..options.connectTimeout = const Duration(seconds: 3) + ..options.receiveTimeout = const Duration(seconds: 5) + ..options.headers = {_headerContentType: _defaultContentType} + ..interceptors.addAll(interceptors) + ..options.baseUrl = Env.restApiEndpoint; } } diff --git a/lib/repositories/authentication_repository.dart b/lib/repositories/authentication_repository.dart index 31cccc8..4ff6a17 100644 --- a/lib/repositories/authentication_repository.dart +++ b/lib/repositories/authentication_repository.dart @@ -1,12 +1,20 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:survey_flutter/api/authentication_api_service.dart'; import 'package:survey_flutter/api/exception/network_exceptions.dart'; +import 'package:survey_flutter/di/provider/dio_provider.dart'; import 'package:survey_flutter/env.dart'; import 'package:survey_flutter/model/login_model.dart'; import 'package:survey_flutter/model/request/login_request.dart'; -import 'package:injectable/injectable.dart'; const String _grantType = "password"; +final authenticationRepositoryProvider = + Provider((_) { + return AuthenticationRepositoryImpl( + AuthenticationApiService(DioProvider().getDio()), + ); +}); + abstract class AuthenticationRepository { Future login({ required String email, @@ -14,7 +22,6 @@ abstract class AuthenticationRepository { }); } -@Singleton(as: AuthenticationRepository) class AuthenticationRepositoryImpl extends AuthenticationRepository { final AuthenticationApiService _authenticationApiService; diff --git a/lib/screens/login/login_screen.dart b/lib/screens/login/login_screen.dart index 35b489b..4ecb859 100644 --- a/lib/screens/login/login_screen.dart +++ b/lib/screens/login/login_screen.dart @@ -117,6 +117,20 @@ class _LoginScreenState extends ConsumerState next.maybeWhen( data: (_) { // TODO: Navigate to the Home screen + showAlertDialog( + context: context, + title: 'Login Successfully', + message: 'You are now logged in!', + actions: [ + TextButton( + style: ButtonStyle( + foregroundColor: MaterialStateProperty.all(Colors.black), + ), + child: Text(context.localizations.okText), + onPressed: () => Navigator.pop(context), + ) + ], + ); }, error: (error, _) { showAlertDialog( diff --git a/lib/screens/login/login_view_model.dart b/lib/screens/login/login_view_model.dart index 781e14e..a2644c8 100644 --- a/lib/screens/login/login_view_model.dart +++ b/lib/screens/login/login_view_model.dart @@ -1,13 +1,17 @@ import 'dart:async'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:survey_flutter/api/exception/network_exceptions.dart'; import 'package:survey_flutter/uimodels/app_error.dart'; +import 'package:survey_flutter/usecases/base/base_use_case.dart'; +import 'package:survey_flutter/usecases/login_use_case.dart'; import 'package:survey_flutter/utils/internet_connection_manager.dart'; final loginViewModelProvider = AsyncNotifierProvider.autoDispose(LoginViewModel.new); class LoginViewModel extends AutoDisposeAsyncNotifier { + late LoginUseCase loginUseCase; late InternetConnectionManager internetConnectionManager; bool isValidEmail(String? email) { @@ -24,26 +28,38 @@ class LoginViewModel extends AutoDisposeAsyncNotifier { required String password, }) async { state = const AsyncLoading(); - // TODO: Integrate with API - // Handling error part: + final loginUseCase = ref.read(loginUseCaseProvider); + final result = await loginUseCase( + LoginParams( + email: email, + password: password, + ), + ); - // If it returns unauthorized error (401) - //state = const AsyncError( - // AppError.unauthorized, - // StackTrace.empty, - //); + if (result is Success) { + state = const AsyncData(null); + } else if (result is Failed) { + final error = result as Failed; + final exception = error.exception.actualException as NetworkExceptions; - // If it returns timeout error, then check Internet connection - internetConnectionManager = ref.read(internetConnectionManagerProvider); - final isConnected = await internetConnectionManager.hasConnection(); + if (exception is BadRequest || exception is UnauthorisedRequest) { + state = const AsyncError( + AppError.unauthorized, + StackTrace.empty, + ); + return; + } else if (exception is RequestTimeout) { + final isConnected = await _hasInternetConnection(); + if (!isConnected) { + state = const AsyncError( + AppError.noInternetConnection, + StackTrace.empty, + ); + return; + } + } - if (!isConnected) { - state = const AsyncError( - AppError.noInternetConnection, - StackTrace.empty, - ); - } else { state = const AsyncError( AppError.generic, StackTrace.empty, @@ -51,6 +67,11 @@ class LoginViewModel extends AutoDisposeAsyncNotifier { } } + Future _hasInternetConnection() async { + internetConnectionManager = ref.read(internetConnectionManagerProvider); + return await internetConnectionManager.hasConnection(); + } + @override FutureOr build() {} } diff --git a/lib/usecases/login_use_case.dart b/lib/usecases/login_use_case.dart index a069383..de288cd 100644 --- a/lib/usecases/login_use_case.dart +++ b/lib/usecases/login_use_case.dart @@ -1,8 +1,12 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'dart:async'; import 'package:survey_flutter/model/login_model.dart'; import 'package:survey_flutter/repositories/authentication_repository.dart'; import 'package:survey_flutter/usecases/base/base_use_case.dart'; -import 'package:injectable/injectable.dart'; + +final loginUseCaseProvider = Provider((ref) { + return LoginUseCase(ref.watch(authenticationRepositoryProvider)); +}); class LoginParams { final String email; @@ -14,7 +18,6 @@ class LoginParams { }); } -@Injectable() class LoginUseCase extends UseCase { final AuthenticationRepository _repository; diff --git a/pubspec.lock b/pubspec.lock index c5e88a9..2b226e7 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -335,14 +335,6 @@ packages: description: flutter source: sdk version: "0.0.0" - get_it: - dependency: transitive - description: - name: get_it - sha256: "529de303c739fca98cd7ece5fca500d8ff89649f1bb4b4e94fb20954abcd7468" - url: "https://pub.dev" - source: hosted - version: "7.6.0" glob: dependency: transitive description: @@ -391,14 +383,6 @@ packages: url: "https://pub.dev" source: hosted version: "4.0.2" - injectable: - dependency: "direct main" - description: - name: injectable - sha256: f71eb879124ed286cbd2210337b91ff5f345f146187c1f1891c172e0ac06443a - url: "https://pub.dev" - source: hosted - version: "1.5.4" integration_test: dependency: "direct dev" description: flutter diff --git a/pubspec.yaml b/pubspec.yaml index b4d1df5..cdbe306 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -45,7 +45,6 @@ dependencies: retrofit: ^4.0.1 japx: ^2.0.5 equatable: ^2.0.0 - injectable: ^1.5.0 internet_connection_checker: ^1.0.0+1 page_view_dot_indicator: ^2.1.0 diff --git a/test/mocks/dummy_models.dart b/test/mocks/dummy_models.dart new file mode 100644 index 0000000..9d1e40d --- /dev/null +++ b/test/mocks/dummy_models.dart @@ -0,0 +1,10 @@ +import 'package:survey_flutter/model/login_model.dart'; + +extension LoginModelDummy on LoginModel { + static LoginModel instance = const LoginModel( + id: '', + accessToken: '', + expiresIn: 0, + refreshToken: '', + ); +} diff --git a/test/mocks/generate_mocks.dart b/test/mocks/generate_mocks.dart index 37ce273..21be8a1 100644 --- a/test/mocks/generate_mocks.dart +++ b/test/mocks/generate_mocks.dart @@ -2,6 +2,7 @@ import 'package:dio/dio.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/usecases/login_use_case.dart'; import 'package:survey_flutter/utils/internet_connection_manager.dart'; import '../utils/async_listener.dart'; @@ -12,6 +13,7 @@ import '../utils/async_listener.dart'; AuthenticationRepository, DioError, InternetConnectionManager, + LoginUseCase, ]) main() { // empty class to generate mock repository classes diff --git a/test/screens/login/login_view_model_test.dart b/test/screens/login/login_view_model_test.dart index d094996..3cbcb8b 100644 --- a/test/screens/login/login_view_model_test.dart +++ b/test/screens/login/login_view_model_test.dart @@ -1,23 +1,33 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:mockito/mockito.dart'; +import 'package:survey_flutter/api/exception/network_exceptions.dart'; import 'package:survey_flutter/screens/login/login_view_model.dart'; +import 'package:survey_flutter/usecases/base/base_use_case.dart'; +import 'package:survey_flutter/usecases/login_use_case.dart'; import 'package:survey_flutter/utils/internet_connection_manager.dart'; +import '../../mocks/dummy_models.dart'; import '../../mocks/generate_mocks.mocks.dart'; void main() { group('LoginViewModel', () { late ProviderContainer container; late MockInternetConnectionManager mockInternetConnectionManager; + late MockLoginUseCase mockLoginUseCase; late MockAsyncListener listener; setUp(() { + mockLoginUseCase = MockLoginUseCase(); mockInternetConnectionManager = MockInternetConnectionManager(); - container = ProviderContainer(overrides: [ - internetConnectionManagerProvider - .overrideWithValue(mockInternetConnectionManager), - ]); + + container = ProviderContainer( + overrides: [ + loginUseCaseProvider.overrideWithValue(mockLoginUseCase), + internetConnectionManagerProvider + .overrideWithValue(mockInternetConnectionManager), + ], + ); listener = MockAsyncListener(); container.listen( @@ -83,30 +93,42 @@ void main() { }); }); - // TODO: Update when integrating with API group('login', () { - // test('When logging in unsuccessfully, it emits error correspondingly', - // () async { - // const data = AsyncData(null); - // // verify initial value from build method - // verify(listener(null, data)); - - // final loginViewModel = container.read(loginViewModelProvider.notifier); - // await loginViewModel.login( - // email: 'user@test.com', password: '12345678'); - - // verifyInOrder([ - // listener(data, isA()), - // listener(isA>(), isA>()), - // ]); - // verifyNoMoreInteractions(listener); - // }); + test( + 'When logging in with a bad request, it emits loading and error correspondingly', + () async { + final exception = + UseCaseException(const NetworkExceptions.badRequest()); + when(mockLoginUseCase.call(any)) + .thenAnswer((_) async => Failed(exception)); + when(mockInternetConnectionManager.hasConnection()) + .thenAnswer((_) async => true); + + const data = AsyncData(null); + // verify initial value from build method + verify(listener(null, data)); + + final loginViewModel = container.read(loginViewModelProvider.notifier); + await loginViewModel.login( + email: 'user@test.com', password: '12345678'); + + verifyInOrder([ + listener(data, isA()), + listener(isA>(), isA>()), + ]); + verifyNoMoreInteractions(listener); + }); test( - 'When logging in timeout with Internet connection, it emits error correspondingly', + 'When logging in with a unauthorized request, it emits loading and error correspondingly', () async { + final exception = + UseCaseException(const NetworkExceptions.unauthorisedRequest()); + when(mockLoginUseCase.call(any)) + .thenAnswer((_) async => Failed(exception)); when(mockInternetConnectionManager.hasConnection()) .thenAnswer((_) async => true); + const data = AsyncData(null); // verify initial value from build method verify(listener(null, data)); @@ -123,10 +145,15 @@ void main() { }); test( - 'When logging in timeout without Internet connection, it emits error correspondingly', + 'When logging in timeout with Internet connection, it emits loading and error correspondingly', () async { + final exception = + UseCaseException(const NetworkExceptions.requestTimeout()); + when(mockLoginUseCase.call(any)) + .thenAnswer((_) async => Failed(exception)); when(mockInternetConnectionManager.hasConnection()) .thenAnswer((_) async => true); + const data = AsyncData(null); // verify initial value from build method verify(listener(null, data)); @@ -141,6 +168,52 @@ void main() { ]); verifyNoMoreInteractions(listener); }); + + test( + 'When logging in timeout without Internet connection, it emits loading and error correspondingly', + () async { + final exception = + UseCaseException(const NetworkExceptions.requestTimeout()); + when(mockLoginUseCase.call(any)) + .thenAnswer((_) async => Failed(exception)); + when(mockInternetConnectionManager.hasConnection()) + .thenAnswer((_) async => false); + + const data = AsyncData(null); + // verify initial value from build method + verify(listener(null, data)); + + final loginViewModel = container.read(loginViewModelProvider.notifier); + await loginViewModel.login( + email: 'user@test.com', password: '12345678'); + + verifyInOrder([ + listener(data, isA()), + listener(isA>(), isA>()), + ]); + verifyNoMoreInteractions(listener); + }); + + test( + 'When logging in successfully, it emits loading and data correspondingly', + () async { + when(mockLoginUseCase.call(any)) + .thenAnswer((_) async => Success(LoginModelDummy.instance)); + + const data = AsyncData(null); + // verify initial value from build method + verify(listener(null, data)); + + final loginViewModel = container.read(loginViewModelProvider.notifier); + await loginViewModel.login( + email: 'user@test.com', password: '12345678'); + + verifyInOrder([ + listener(data, isA()), + listener(isA>(), data), + ]); + verifyNoMoreInteractions(listener); + }); }); }); } From 52b3dcaeb0e6303e13ae8c4e84280ef70127efc6 Mon Sep 17 00:00:00 2001 From: Doan Thieu Date: Wed, 16 Aug 2023 16:44:31 +0700 Subject: [PATCH 2/4] [#10] Show loading indicator while logging in --- lib/screens/login/login_form.dart | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/lib/screens/login/login_form.dart b/lib/screens/login/login_form.dart index 31dc2ca..f33198e 100644 --- a/lib/screens/login/login_form.dart +++ b/lib/screens/login/login_form.dart @@ -7,6 +7,7 @@ import 'package:survey_flutter/theme/primary_text_field_decoration.dart'; import 'package:survey_flutter/utils/build_context_ext.dart'; const _fieldSpacing = 20.0; +const _loadingIndicatorSize = 28.0; class LoginForm extends ConsumerStatefulWidget { const LoginForm({Key? key}) : super(key: key); @@ -55,7 +56,20 @@ class _LoginFormState extends ConsumerState { ElevatedButton get _loginButton => ElevatedButton( style: PrimaryButtonStyle(hintTextStyle: context.textTheme.labelMedium), onPressed: _submit, - child: Text(context.localizations.loginButton), + child: Consumer( + builder: (_, widgetRef, __) { + final loginVievModel = widgetRef.watch(loginViewModelProvider); + return (loginVievModel.isLoading) + ? const SizedBox( + width: _loadingIndicatorSize, + height: _loadingIndicatorSize, + child: CircularProgressIndicator( + color: Colors.black45, + ), + ) + : Text(context.localizations.loginButton); + }, + ), ); String? _validateEmail(String? email) { From 032bd7e8eb15c2a892caf6284e74c150b7e0f047 Mon Sep 17 00:00:00 2001 From: Doan Thieu Date: Thu, 17 Aug 2023 15:46:39 +0700 Subject: [PATCH 3/4] [#10] Update accessing dependencies on LoginViewModel --- lib/screens/login/login_view_model.dart | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/lib/screens/login/login_view_model.dart b/lib/screens/login/login_view_model.dart index a2644c8..ee38f37 100644 --- a/lib/screens/login/login_view_model.dart +++ b/lib/screens/login/login_view_model.dart @@ -11,9 +11,6 @@ final loginViewModelProvider = AsyncNotifierProvider.autoDispose(LoginViewModel.new); class LoginViewModel extends AutoDisposeAsyncNotifier { - late LoginUseCase loginUseCase; - late InternetConnectionManager internetConnectionManager; - bool isValidEmail(String? email) { // Just use a simple rule, no fancy Regex! return !(email == null || !email.contains('@')); @@ -28,7 +25,6 @@ class LoginViewModel extends AutoDisposeAsyncNotifier { required String password, }) async { state = const AsyncLoading(); - final loginUseCase = ref.read(loginUseCaseProvider); final result = await loginUseCase( LoginParams( @@ -37,9 +33,7 @@ class LoginViewModel extends AutoDisposeAsyncNotifier { ), ); - if (result is Success) { - state = const AsyncData(null); - } else if (result is Failed) { + if (result is Failed) { final error = result as Failed; final exception = error.exception.actualException as NetworkExceptions; @@ -64,11 +58,15 @@ class LoginViewModel extends AutoDisposeAsyncNotifier { AppError.generic, StackTrace.empty, ); + return; } + + state = const AsyncData(null); } Future _hasInternetConnection() async { - internetConnectionManager = ref.read(internetConnectionManagerProvider); + final internetConnectionManager = + ref.read(internetConnectionManagerProvider); return await internetConnectionManager.hasConnection(); } From 8343044d7ededb0bc84a3cae88187427344788f4 Mon Sep 17 00:00:00 2001 From: Doan Thieu Date: Thu, 17 Aug 2023 15:56:41 +0700 Subject: [PATCH 4/4] [#10] Navigate to Home after logging in successfully --- lib/main.dart | 5 +++++ lib/screens/home/home_screen.dart | 2 ++ lib/screens/login/login_screen.dart | 20 +++----------------- 3 files changed, 10 insertions(+), 17 deletions(-) diff --git a/lib/main.dart b/lib/main.dart index 2bf8042..4eca220 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -3,6 +3,7 @@ import 'package:flutter_config/flutter_config.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; +import 'package:survey_flutter/screens/home/home_screen.dart'; import 'package:survey_flutter/screens/login/login_screen.dart'; import 'package:survey_flutter/screens/splash/splash_screen.dart'; import 'package:survey_flutter/theme/app_theme.dart'; @@ -34,6 +35,10 @@ class App extends StatelessWidget { child: LoginScreen(), ), ), + GoRoute( + path: routePathHomeScreen, + builder: (_, __) => const HomeScreen(), + ), ], ); diff --git a/lib/screens/home/home_screen.dart b/lib/screens/home/home_screen.dart index 114ae3d..f00f013 100644 --- a/lib/screens/home/home_screen.dart +++ b/lib/screens/home/home_screen.dart @@ -3,6 +3,8 @@ import 'package:survey_flutter/screens/home/home_header_widget.dart'; import 'package:survey_flutter/screens/home/home_pages_widget.dart'; import 'package:survey_flutter/screens/home/home_page_indicator_widget.dart'; +const routePathHomeScreen = '/home'; + class HomeScreen extends StatelessWidget { const HomeScreen({Key? key}) : super(key: key); diff --git a/lib/screens/login/login_screen.dart b/lib/screens/login/login_screen.dart index 4ecb859..1a0a473 100644 --- a/lib/screens/login/login_screen.dart +++ b/lib/screens/login/login_screen.dart @@ -2,7 +2,9 @@ import 'dart:ui'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; import 'package:survey_flutter/gen/assets.gen.dart'; +import 'package:survey_flutter/screens/home/home_screen.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'; @@ -115,23 +117,7 @@ class _LoginScreenState extends ConsumerState _setUpListener(BuildContext context) { ref.listen>(loginViewModelProvider, (_, next) { next.maybeWhen( - data: (_) { - // TODO: Navigate to the Home screen - showAlertDialog( - context: context, - title: 'Login Successfully', - message: 'You are now logged in!', - actions: [ - TextButton( - style: ButtonStyle( - foregroundColor: MaterialStateProperty.all(Colors.black), - ), - child: Text(context.localizations.okText), - onPressed: () => Navigator.pop(context), - ) - ], - ); - }, + data: (_) => context.go(routePathHomeScreen), error: (error, _) { showAlertDialog( context: context,