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/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/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/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_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) { diff --git a/lib/screens/login/login_screen.dart b/lib/screens/login/login_screen.dart index 35b489b..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,9 +117,7 @@ class _LoginScreenState extends ConsumerState _setUpListener(BuildContext context) { ref.listen>(loginViewModelProvider, (_, next) { next.maybeWhen( - data: (_) { - // TODO: Navigate to the Home screen - }, + data: (_) => context.go(routePathHomeScreen), error: (error, _) { showAlertDialog( context: context, diff --git a/lib/screens/login/login_view_model.dart b/lib/screens/login/login_view_model.dart index 781e14e..ee38f37 100644 --- a/lib/screens/login/login_view_model.dart +++ b/lib/screens/login/login_view_model.dart @@ -1,15 +1,16 @@ 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 InternetConnectionManager internetConnectionManager; - bool isValidEmail(String? email) { // Just use a simple rule, no fancy Regex! return !(email == null || !email.contains('@')); @@ -24,31 +25,49 @@ 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 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, ); + return; } + + state = const AsyncData(null); + } + + Future _hasInternetConnection() async { + final internetConnectionManager = + ref.read(internetConnectionManagerProvider); + return await internetConnectionManager.hasConnection(); } @override 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); + }); }); }); }