diff --git a/assets/images/dummy_background.png b/assets/images/dummy_background.png deleted file mode 100644 index 3203d8f..0000000 Binary files a/assets/images/dummy_background.png and /dev/null differ diff --git a/assets/images/placeholder.png b/assets/images/placeholder.png new file mode 100644 index 0000000..8f19989 Binary files /dev/null and b/assets/images/placeholder.png differ diff --git a/ios/Podfile.lock b/ios/Podfile.lock index 2cc4ac1..0dcbe32 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -8,6 +8,9 @@ PODS: - Flutter - package_info_plus (0.4.5): - Flutter + - path_provider_foundation (0.0.1): + - Flutter + - FlutterMacOS - permission_handler_apple (9.1.1): - Flutter @@ -17,6 +20,7 @@ DEPENDENCIES: - flutter_secure_storage (from `.symlinks/plugins/flutter_secure_storage/ios`) - integration_test (from `.symlinks/plugins/integration_test/ios`) - package_info_plus (from `.symlinks/plugins/package_info_plus/ios`) + - path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`) - permission_handler_apple (from `.symlinks/plugins/permission_handler_apple/ios`) EXTERNAL SOURCES: @@ -30,6 +34,8 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/integration_test/ios" package_info_plus: :path: ".symlinks/plugins/package_info_plus/ios" + path_provider_foundation: + :path: ".symlinks/plugins/path_provider_foundation/darwin" permission_handler_apple: :path: ".symlinks/plugins/permission_handler_apple/ios" @@ -39,6 +45,7 @@ SPEC CHECKSUMS: flutter_secure_storage: 23fc622d89d073675f2eaa109381aefbcf5a49be integration_test: 13825b8a9334a850581300559b8839134b124670 package_info_plus: fd030dabf36271f146f1f3beacd48f564b0f17f7 + path_provider_foundation: 29f094ae23ebbca9d3d0cec13889cd9060c0e943 permission_handler_apple: e76247795d700c14ea09e3a2d8855d41ee80a2e6 PODFILE CHECKSUM: 632d6ac0b577d6e268ff7a13a105bbc4f7941989 diff --git a/lib/api/authentication_api_service.dart b/lib/api/authentication_api_service.dart index 5c5683c..d74ac5a 100644 --- a/lib/api/authentication_api_service.dart +++ b/lib/api/authentication_api_service.dart @@ -1,7 +1,8 @@ import 'package:dio/dio.dart'; -import 'package:survey_flutter/model/request/login_request.dart'; -import 'package:survey_flutter/model/response/login_response.dart'; import 'package:retrofit/retrofit.dart'; +import 'package:survey_flutter/model/request/login_request.dart'; +import 'package:survey_flutter/model/request/refresh_token_request.dart'; +import 'package:survey_flutter/model/response/token_response.dart'; part 'authentication_api_service.g.dart'; @@ -11,7 +12,12 @@ abstract class AuthenticationApiService { _AuthenticationApiService; @POST('/oauth/token') - Future login( - @Body() LoginRequest body, + Future login( + @Body() LoginRequest loginRequest, + ); + + @POST('/oauth/token') + Future refreshToken( + @Body() RefreshTokenRequest refreshTokenRequest, ); } diff --git a/lib/api/data_sources/token_data_source.dart b/lib/api/data_sources/token_data_source.dart new file mode 100644 index 0000000..fca1174 --- /dev/null +++ b/lib/api/data_sources/token_data_source.dart @@ -0,0 +1,62 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:survey_flutter/api/authentication_api_service.dart'; +import 'package:survey_flutter/di/provider/dio_provider.dart'; +import 'package:survey_flutter/env.dart'; +import 'package:survey_flutter/model/api_token.dart'; +import 'package:survey_flutter/model/request/refresh_token_request.dart'; +import 'package:survey_flutter/storage/secure_storage.dart'; +import 'package:survey_flutter/storage/secure_storage_impl.dart'; +import 'package:survey_flutter/utils/serializer/api_token_serializer.dart'; + +final tokenDataSourceProvider = Provider((ref) { + return TokenDataSourceImpl(ref.watch(secureStorageProvider), + AuthenticationApiService(DioProvider().getDio())); +}); + +abstract class TokenDataSource { + Future getToken({bool forceRefresh}); + Future setToken(ApiToken token); +} + +class TokenDataSourceImpl extends TokenDataSource { + final SecureStorage _secureStorage; + final AuthenticationApiService _authenticationApiService; + final String _grantType = 'refresh_token'; + + TokenDataSourceImpl( + this._secureStorage, + this._authenticationApiService, + ); + + @override + Future getToken({bool forceRefresh = false}) async { + final currentToken = await _secureStorage.getValue( + key: SecureStorageKey.apiToken, + serializer: ApiTokenSerializer(), + ); + + if (!forceRefresh) { + return currentToken; + } + + final tokenResponse = await _authenticationApiService.refreshToken( + RefreshTokenRequest( + grantType: _grantType, + refreshToken: currentToken.refreshToken, + clientId: Env.clientId, + clientSecret: Env.clientSecret, + ), + ); + final newToken = tokenResponse.toApiToken(); + await _secureStorage.save( + value: newToken, + key: SecureStorageKey.apiToken, + ); + return newToken; + } + + @override + Future setToken(ApiToken token) async { + await _secureStorage.save(value: token, key: SecureStorageKey.apiToken); + } +} diff --git a/lib/api/interceptor/auth_interceptor.dart b/lib/api/interceptor/auth_interceptor.dart new file mode 100644 index 0000000..7b1bef4 --- /dev/null +++ b/lib/api/interceptor/auth_interceptor.dart @@ -0,0 +1,58 @@ +import 'dart:io'; + +import 'package:dio/dio.dart'; +import 'package:survey_flutter/api/data_sources/token_data_source.dart'; + +const String _headerAuthorization = 'Authorization'; +const String _retryCountOption = 'Retry-Count'; + +class AuthInterceptor extends Interceptor { + final Dio _dio; + final TokenDataSource _tokenDataSource; + + AuthInterceptor( + this._dio, + this._tokenDataSource, + ); + + @override + void onRequest( + RequestOptions options, + RequestInterceptorHandler handler, + ) async { + final token = await _tokenDataSource.getToken(); + options.headers.putIfAbsent( + _headerAuthorization, () => "${token.tokenType} ${token.accessToken}"); + super.onRequest(options, handler); + } + + @override + void onError( + DioError err, + ErrorInterceptorHandler handler, + ) { + final statusCode = err.response?.statusCode; + final requestOptions = err.requestOptions; + + if (statusCode != HttpStatus.forbidden && + statusCode != HttpStatus.unauthorized && + requestOptions.extra[_retryCountOption] != 1) { + handler.next(err); + return; + } + + requestOptions.extra[_retryCountOption] = 1; + _refreshTokenAndRetry(requestOptions, handler); + } + + Future _refreshTokenAndRetry( + RequestOptions options, + ErrorInterceptorHandler handler, + ) async { + final token = await _tokenDataSource.getToken(forceRefresh: true); + final headers = options.headers; + headers[_headerAuthorization] = "${token.tokenType} ${token.accessToken}"; + final newOptions = options.copyWith(headers: headers); + await _dio.fetch(newOptions).then((response) => handler.resolve(response)); + } +} diff --git a/lib/api/response_decoder.dart b/lib/api/response_decoder.dart index e59f078..275b29d 100644 --- a/lib/api/response_decoder.dart +++ b/lib/api/response_decoder.dart @@ -2,9 +2,10 @@ import 'package:japx/japx.dart'; class ResponseDecoder { static Map decodeData(Map json) { - return Japx.decode(json)['data']; + return json.containsKey('data') ? Japx.decode(json)['data'] : json; } - static Map decode(Map json) => - Japx.decode(json); + static Map decode(Map json) { + return Japx.decode(json); + } } diff --git a/lib/di/provider/dio_provider.dart b/lib/di/provider/dio_provider.dart index 62ff3b8..cfdc9dc 100644 --- a/lib/di/provider/dio_provider.dart +++ b/lib/di/provider/dio_provider.dart @@ -1,6 +1,7 @@ import 'package:dio/dio.dart'; import 'package:flutter/foundation.dart'; -import 'package:survey_flutter/di/interceptor/app_interceptor.dart'; +import 'package:survey_flutter/api/data_sources/token_data_source.dart'; +import 'package:survey_flutter/api/interceptor/auth_interceptor.dart'; import 'package:survey_flutter/env.dart'; const String _headerContentType = 'Content-Type'; @@ -8,20 +9,33 @@ const String _defaultContentType = 'application/json; charset=utf-8'; class DioProvider { Dio? _dio; + Dio? _authorizedDio; + TokenDataSource? _tokenDataSource; Dio getDio() { _dio ??= _createDio(); return _dio!; } - Dio _createDio({bool requireAuthenticate = false}) { + Dio getAuthorizedDio({ + required TokenDataSource tokenDataSource, + }) { + _tokenDataSource = tokenDataSource; + _authorizedDio ??= _createDio(requireAuthentication: true); + return _authorizedDio!; + } + + Dio _createDio({bool requireAuthentication = false}) { final dio = Dio(); - final appInterceptor = AppInterceptor( - requireAuthenticate, - dio, - ); + final interceptors = []; - interceptors.add(appInterceptor); + if (requireAuthentication) { + final authInterceptor = AuthInterceptor( + dio, + _tokenDataSource!, + ); + interceptors.add(authInterceptor); + } if (!kReleaseMode) { interceptors.add(LogInterceptor( request: true, diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 61429a6..e33a1ea 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -9,5 +9,6 @@ "noInternetConnectionError": "You seem to be offline.\nPlease try again!", "genericError": "Something went wrong.\nPlease try again!", "loginFailAlertTitle": "Unable to log in", - "today": "Today" + "today": "Today", + "errorText": "Error" } diff --git a/lib/main.dart b/lib/main.dart index 4eca220..ac6f45c 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -6,11 +6,13 @@ 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/storage/survey_storage.dart'; import 'package:survey_flutter/theme/app_theme.dart'; void main() async { WidgetsFlutterBinding.ensureInitialized(); await FlutterConfig.loadEnvVariables(); + await setupHive(); runApp( ProviderScope( child: App(), diff --git a/lib/model/api_token.dart b/lib/model/api_token.dart index 41d07f4..d43fd2c 100644 --- a/lib/model/api_token.dart +++ b/lib/model/api_token.dart @@ -1,10 +1,10 @@ import 'package:json_annotation/json_annotation.dart'; -import 'package:survey_flutter/storage/secure_storage.dart'; +import 'package:survey_flutter/utils/serializer/serializable.dart'; part 'api_token.g.dart'; @JsonSerializable() -class ApiToken extends SecureStorageModel { +class ApiToken extends Serializable { @JsonKey(name: 'access_token') final String accessToken; @JsonKey(name: 'refresh_token') diff --git a/lib/model/request/refresh_token_request.dart b/lib/model/request/refresh_token_request.dart new file mode 100644 index 0000000..b941955 --- /dev/null +++ b/lib/model/request/refresh_token_request.dart @@ -0,0 +1,24 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'refresh_token_request.g.dart'; + +@JsonSerializable() +class RefreshTokenRequest { + @JsonKey(name: 'grant_type') + final String grantType; + @JsonKey(name: 'refresh_token') + final String refreshToken; + @JsonKey(name: 'client_id') + final String clientId; + @JsonKey(name: 'client_secret') + final String clientSecret; + + RefreshTokenRequest({ + required this.grantType, + required this.refreshToken, + required this.clientId, + required this.clientSecret, + }); + + Map toJson() => _$RefreshTokenRequestToJson(this); +} diff --git a/lib/model/response/meta_response.dart b/lib/model/response/meta_response.dart index fd1d637..a2b4c16 100644 --- a/lib/model/response/meta_response.dart +++ b/lib/model/response/meta_response.dart @@ -19,7 +19,7 @@ class MetaResponse { }); factory MetaResponse.fromJson(Map json) => - _$MetaResponseFromJson(ResponseDecoder.decode(json)); + _$MetaResponseFromJson(ResponseDecoder.decodeData(json)); static MetaResponse dummy() { return MetaResponse( diff --git a/lib/model/response/survey_response.dart b/lib/model/response/survey_response.dart index d47ba7e..f336a16 100644 --- a/lib/model/response/survey_response.dart +++ b/lib/model/response/survey_response.dart @@ -26,6 +26,9 @@ class SurveyResponse { id: id ?? '', title: title ?? '', description: description ?? '', - coverImageUrl: coverImageUrl ?? '', + coverImageUrl: highResolutionCoverImageUrl, ); + + String get highResolutionCoverImageUrl => + (coverImageUrl != null) ? "${coverImageUrl}l" : ""; } diff --git a/lib/model/response/surveys_container_response.dart b/lib/model/response/surveys_container_response.dart index 6d02570..23cdf11 100644 --- a/lib/model/response/surveys_container_response.dart +++ b/lib/model/response/surveys_container_response.dart @@ -17,9 +17,8 @@ class SurveysContainerResponse { required this.meta, }); - factory SurveysContainerResponse.fromJson(Map json) { - return _$SurveysContainerResponseFromJson(ResponseDecoder.decode(json)); - } + factory SurveysContainerResponse.fromJson(Map json) => + _$SurveysContainerResponseFromJson(ResponseDecoder.decode(json)); SurveysContainerModel toSurveysContainerModel() => SurveysContainerModel( surveys: diff --git a/lib/model/response/login_response.dart b/lib/model/response/token_response.dart similarity index 80% rename from lib/model/response/login_response.dart rename to lib/model/response/token_response.dart index 0a02f44..a3229e5 100644 --- a/lib/model/response/login_response.dart +++ b/lib/model/response/token_response.dart @@ -3,10 +3,10 @@ import 'package:survey_flutter/api/response_decoder.dart'; import 'package:survey_flutter/model/api_token.dart'; import 'package:survey_flutter/model/login_model.dart'; -part 'login_response.g.dart'; +part 'token_response.g.dart'; @JsonSerializable() -class LoginResponse { +class TokenResponse { final String id; final String accessToken; final String tokenType; @@ -14,7 +14,7 @@ class LoginResponse { final String refreshToken; final int createdAt; - LoginResponse({ + TokenResponse({ required this.id, required this.accessToken, required this.tokenType, @@ -23,8 +23,8 @@ class LoginResponse { required this.createdAt, }); - factory LoginResponse.fromJson(Map json) => - _$LoginResponseFromJson(ResponseDecoder.decodeData(json)); + factory TokenResponse.fromJson(Map json) => + _$TokenResponseFromJson(ResponseDecoder.decodeData(json)); LoginModel toLoginModel() => LoginModel( id: id, @@ -39,8 +39,8 @@ class LoginResponse { tokenType: tokenType, ); - static LoginResponse dummy() { - return LoginResponse( + static TokenResponse dummy() { + return TokenResponse( id: "", accessToken: "", tokenType: "", diff --git a/lib/model/survey_model.dart b/lib/model/survey_model.dart index 25f686c..b5ffc92 100644 --- a/lib/model/survey_model.dart +++ b/lib/model/survey_model.dart @@ -1,9 +1,20 @@ import 'package:equatable/equatable.dart'; +import 'package:hive_flutter/hive_flutter.dart'; +part 'survey_model.g.dart'; + +@HiveType(typeId: 0) class SurveyModel extends Equatable { + @HiveField(0) final String id; + + @HiveField(1) final String title; + + @HiveField(2) final String description; + + @HiveField(3) final String coverImageUrl; const SurveyModel({ @@ -20,4 +31,19 @@ class SurveyModel extends Equatable { description, coverImageUrl, ]; + + SurveyModel toSurveyModel() => SurveyModel( + id: id, + title: title, + description: description, + coverImageUrl: coverImageUrl, + ); + + const SurveyModel.dummy() + : this( + id: "id", + title: "title", + description: "description", + coverImageUrl: "coverImageUrl", + ); } diff --git a/lib/repositories/authentication_repository.dart b/lib/repositories/authentication_repository.dart index 7f9a449..cf859f1 100644 --- a/lib/repositories/authentication_repository.dart +++ b/lib/repositories/authentication_repository.dart @@ -1,12 +1,11 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:survey_flutter/api/authentication_api_service.dart'; +import 'package:survey_flutter/api/data_sources/token_data_source.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:survey_flutter/storage/secure_storage.dart'; -import 'package:survey_flutter/storage/secure_storage_impl.dart'; const String _grantType = "password"; @@ -14,7 +13,7 @@ final authenticationRepositoryProvider = Provider((ref) { return AuthenticationRepositoryImpl( AuthenticationApiService(DioProvider().getDio()), - ref.watch(secureStorageProvider), + ref.watch(tokenDataSourceProvider), ); }); @@ -27,11 +26,11 @@ abstract class AuthenticationRepository { class AuthenticationRepositoryImpl extends AuthenticationRepository { final AuthenticationApiService _authenticationApiService; - final SecureStorage _secureStorage; + final TokenDataSource _tokenDataSource; AuthenticationRepositoryImpl( this._authenticationApiService, - this._secureStorage, + this._tokenDataSource, ); @override @@ -47,10 +46,7 @@ class AuthenticationRepositoryImpl extends AuthenticationRepository { clientSecret: Env.clientSecret, grantType: _grantType, )); - await _secureStorage.save( - value: response.toApiToken(), - key: SecureStorageKey.apiToken, - ); + await _tokenDataSource.setToken(response.toApiToken()); return response.toLoginModel(); } catch (exception) { throw NetworkExceptions.fromDioException(exception); diff --git a/lib/repositories/survey_repository.dart b/lib/repositories/survey_repository.dart index 437ed5d..4f7074e 100644 --- a/lib/repositories/survey_repository.dart +++ b/lib/repositories/survey_repository.dart @@ -1,9 +1,23 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:survey_flutter/api/data_sources/token_data_source.dart'; import 'package:survey_flutter/api/exception/network_exceptions.dart'; import 'package:survey_flutter/api/survey_api_service.dart'; -import 'package:survey_flutter/model/surveys_container_model.dart'; +import 'package:survey_flutter/di/provider/dio_provider.dart'; +import 'package:survey_flutter/model/survey_model.dart'; +import 'package:survey_flutter/storage/survey_storage.dart'; + +final surveyRepositoryProvider = Provider((ref) { + final surveyStorage = ref.watch(surveyStorageProvider); + return SurveyRepositoryImpl( + SurveyApiService(DioProvider().getAuthorizedDio( + tokenDataSource: ref.watch(tokenDataSourceProvider), + )), + surveyStorage, + ); +}); abstract class SurveyRepository { - Future getSurveys({ + Future> getSurveys({ required int pageNumber, required int pageSize, }); @@ -11,17 +25,22 @@ abstract class SurveyRepository { class SurveyRepositoryImpl extends SurveyRepository { final SurveyApiService _apiService; + final SurveyStorage _surveyStorage; - SurveyRepositoryImpl(this._apiService); + SurveyRepositoryImpl(this._apiService, this._surveyStorage); @override - Future getSurveys({ + Future> getSurveys({ required int pageNumber, required int pageSize, }) async { try { - final result = await _apiService.getSurveys(pageNumber, pageSize); - return result.toSurveysContainerModel(); + final response = await _apiService.getSurveys(pageNumber, pageSize); + final surveys = response.data ?? []; + final surveyModels = + surveys.map((item) => (item.toSurveyModel())).toList(); + _surveyStorage.saveSurveys(surveyModels); + return surveyModels; } catch (exception) { throw NetworkExceptions.fromDioException(exception); } diff --git a/lib/screens/home/home_footer_widget.dart b/lib/screens/home/home_footer_widget.dart index 6908362..27a76e8 100644 --- a/lib/screens/home/home_footer_widget.dart +++ b/lib/screens/home/home_footer_widget.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:survey_flutter/gen/assets.gen.dart'; +import 'package:survey_flutter/model/survey_model.dart'; import 'package:survey_flutter/theme/app_constants.dart'; import 'package:survey_flutter/utils/build_context_ext.dart'; @@ -7,9 +8,13 @@ const _descriptionOpacity = 0.7; const _buttonSize = 56.0; class HomeFooterWidget extends StatelessWidget { + final SurveyModel survey; + final VoidCallback onNextButtonPressed; + const HomeFooterWidget({ Key? key, - required VoidCallback onNextButtonPressed, + required this.survey, + required this.onNextButtonPressed, }) : super(key: key); @override @@ -28,21 +33,19 @@ class HomeFooterWidget extends StatelessWidget { } Widget _buildTitle(BuildContext context) { - // TODO: Replace with survey title return Text( - "Working from home Check-In", + survey.title, style: context.textTheme.titleMedium, maxLines: 2, ); } Widget _buildDescription(BuildContext context) { - // TODO: Replace with survey description return Row( children: [ Expanded( child: Text( - "We would like to know how you feel about our work from home...", + survey.description, style: context.textTheme.bodyMedium?.copyWith( color: Colors.white.withOpacity(_descriptionOpacity)), maxLines: 2, @@ -63,13 +66,11 @@ class HomeFooterWidget extends StatelessWidget { backgroundColor: Colors.white, foregroundColor: Colors.black12, ), + onPressed: onNextButtonPressed, child: Image.asset( Assets.images.next.path, color: Colors.black, ), - onPressed: () { - // TODO: Handle the next button pressed event - }, ), ); } diff --git a/lib/screens/home/home_page_indicator_widget.dart b/lib/screens/home/home_page_indicator_widget.dart index e075a1d..f50f090 100644 --- a/lib/screens/home/home_page_indicator_widget.dart +++ b/lib/screens/home/home_page_indicator_widget.dart @@ -9,19 +9,29 @@ const dotIndicatorSize = Size( ); class HomePageIndicatorWidget extends StatelessWidget { - const HomePageIndicatorWidget({Key? key}) : super(key: key); + final int surveysLength; + final ValueNotifier currentPage; + + const HomePageIndicatorWidget({ + Key? key, + required this.surveysLength, + required this.currentPage, + }) : super(key: key); @override Widget build(BuildContext context) { - // TODO: currentItem and count handled in Integrate - return PageViewDotIndicator( - currentItem: 1, - count: 3, - selectedColor: Colors.white, - unselectedColor: Colors.white.withOpacity(_opacityUnselectedColor), - size: dotIndicatorSize, - unselectedSize: dotIndicatorSize, - alignment: Alignment.bottomLeft, - ); + return ValueListenableBuilder( + valueListenable: currentPage, + builder: (_, __, ___) { + return PageViewDotIndicator( + currentItem: currentPage.value, + count: surveysLength, + selectedColor: Colors.white, + unselectedColor: Colors.white.withOpacity(_opacityUnselectedColor), + size: dotIndicatorSize, + unselectedSize: dotIndicatorSize, + alignment: Alignment.bottomLeft, + ); + }); } } diff --git a/lib/screens/home/home_pages_widget.dart b/lib/screens/home/home_pages_widget.dart index 1f7b3dd..8fbbb11 100644 --- a/lib/screens/home/home_pages_widget.dart +++ b/lib/screens/home/home_pages_widget.dart @@ -1,49 +1,73 @@ import 'package:flutter/material.dart'; -import 'package:survey_flutter/gen/assets.gen.dart'; +import 'package:survey_flutter/model/survey_model.dart'; import 'package:survey_flutter/screens/home/home_footer_widget.dart'; import 'package:survey_flutter/theme/app_constants.dart'; +import '../../gen/assets.gen.dart'; + +const _imageOpacity = 0.6; + class HomePagesWidget extends StatelessWidget { + final List surveys; + final ValueNotifier currentPage; final PageController _pageController = PageController(); - HomePagesWidget({Key? key}) : super(key: key); + HomePagesWidget({ + Key? key, + required this.surveys, + required this.currentPage, + }) : super(key: key); @override Widget build(BuildContext context) { - // TODO: itemCount and background image handled in integrate return PageView.builder( - itemCount: 4, + itemCount: surveys.length, controller: _pageController, itemBuilder: (_, int index) { return Container( - decoration: BoxDecoration( - image: DecorationImage( - image: AssetImage(Assets.images.dummyBackground.path), - fit: BoxFit.cover, - ), - ), - child: Column( + color: Colors.black, + child: Stack( children: [ - const Spacer(), // Space above footer - SafeArea( - bottom: true, - child: Padding( - padding: const EdgeInsets.only( - left: 0, - bottom: Metrics.spacing20, - right: 0, - ), - child: HomeFooterWidget( - onNextButtonPressed: () { - // Handle the next button pressed event - }, - ), + Opacity( + opacity: _imageOpacity, + child: FadeInImage.assetNetwork( + placeholder: Assets.images.placeholder.path, + image: surveys[index].coverImageUrl, + fit: BoxFit.cover, + width: double.infinity, + height: double.infinity, ), ), + Column( + children: [ + const Spacer(), + SafeArea( + child: Padding( + padding: const EdgeInsets.only( + left: 0, + bottom: Metrics.spacing20, + right: 0, + ), + child: HomeFooterWidget( + survey: surveys[index], + onNextButtonPressed: () { + if (currentPage.value < surveys.length - 1) { + currentPage.value = index + 1; + _pageController.jumpToPage(currentPage.value); + } + }, + ), + ), + ), + ], + ), ], ), ); }, + onPageChanged: (int index) { + currentPage.value = index; + }, ); } } diff --git a/lib/screens/home/home_screen.dart b/lib/screens/home/home_screen.dart index 33c613c..88ffa5f 100644 --- a/lib/screens/home/home_screen.dart +++ b/lib/screens/home/home_screen.dart @@ -1,53 +1,109 @@ import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:survey_flutter/model/survey_model.dart'; 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'; import 'package:survey_flutter/screens/home/home_shimmer_loading.dart'; +import 'package:survey_flutter/screens/home/home_view_model.dart'; +import 'package:survey_flutter/utils/build_context_ext.dart'; +import 'package:survey_flutter/widgets/alert_dialog.dart'; const routePathHomeScreen = '/home'; -class HomeScreen extends StatefulWidget { +class HomeScreen extends ConsumerStatefulWidget { const HomeScreen({Key? key}) : super(key: key); @override - State createState() => _HomeScreenState(); + ConsumerState createState() => _HomeScreenState(); } -class _HomeScreenState extends State { - bool _isLoading = true; +final _surveysStreamProvider = StreamProvider.autoDispose>( + (ref) => ref.watch(homeViewModelProvider.notifier).surveys); + +final _errorStreamProvider = StreamProvider.autoDispose( + (ref) => ref.watch(homeViewModelProvider.notifier).error); + +class _HomeScreenState extends ConsumerState { + final _currentPage = ValueNotifier(0); @override void initState() { super.initState(); - _openHomeWithShimmerLoading(); + _initData(); } - Future _openHomeWithShimmerLoading() async { - await Future.delayed(const Duration(seconds: 1)); - setState(() { - _isLoading = false; - }); + Future _initData() async { + ref.read(homeViewModelProvider.notifier).loadSurveys(isRefreshing: false); } @override Widget build(BuildContext context) { + return ref.watch(homeViewModelProvider).when( + loading: () => _buildHomeScreen(isLoading: true), + error: () => _buildHomeScreen(), + loadCachedSurveysSuccess: () => _buildHomeScreen(), + loadSurveysSuccess: () => _buildHomeScreen(), + ); + } + + Widget _buildHomeScreen({bool isLoading = false}) { + final surveys = ref.watch(_surveysStreamProvider).value ?? []; + final errorMessage = ref.watch(_errorStreamProvider).value ?? ""; + + if (errorMessage.isNotEmpty) { + showAlertDialog( + context: context, + title: context.localizations.errorText, + message: errorMessage, + actions: [ + TextButton( + style: ButtonStyle( + foregroundColor: MaterialStateProperty.all(Colors.black), + ), + child: Text(context.localizations.okText), + onPressed: () => Navigator.pop(context), + ) + ], + ); + } return Scaffold( - body: Stack( - children: [ - HomePagesWidget(), - const HomeHeaderWidget(), - const Align( - alignment: Alignment.bottomCenter, - child: Padding( - padding: EdgeInsets.only(bottom: 220), - child: HomePageIndicatorWidget(), + backgroundColor: Colors.black, + body: RefreshIndicator( + color: Colors.white, + backgroundColor: Colors.black, + onRefresh: () => ref + .read(homeViewModelProvider.notifier) + .loadSurveys(isRefreshing: true), + child: SingleChildScrollView( + physics: const AlwaysScrollableScrollPhysics(), + child: SizedBox( + height: MediaQuery.of(context).size.height, + child: Stack( + children: [ + if (surveys.isNotEmpty) ...[ + HomePagesWidget( + surveys: surveys, + currentPage: _currentPage, + ), + const HomeHeaderWidget(), + Align( + alignment: Alignment.bottomCenter, + child: Padding( + padding: const EdgeInsets.only(bottom: 220), + child: HomePageIndicatorWidget( + surveysLength: surveys.length, + currentPage: _currentPage, + ), + ), + ) + ], + if (surveys.isEmpty || isLoading) _buildShimmerLoading(), + ], + ), ), ), - // TODO: Handle only show shimmer loading after user login - if (_isLoading) _buildShimmerLoading(), - ], - ), - ); + )); } Widget _buildShimmerLoading() { @@ -61,4 +117,10 @@ class _HomeScreenState extends State { ), ); } + + @override + void dispose() { + _currentPage.dispose(); + super.dispose(); + } } diff --git a/lib/screens/home/home_state.dart b/lib/screens/home/home_state.dart new file mode 100644 index 0000000..7d666de --- /dev/null +++ b/lib/screens/home/home_state.dart @@ -0,0 +1,15 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'home_state.freezed.dart'; + +@freezed +class HomeState with _$HomeState { + const factory HomeState.loading() = _Loading; + + const factory HomeState.loadCachedSurveysSuccess() = + _LoadCachedSurveysSuccess; + + const factory HomeState.loadSurveysSuccess() = _LoadSurveysSuccess; + + const factory HomeState.error() = _error; +} diff --git a/lib/screens/home/home_view_model.dart b/lib/screens/home/home_view_model.dart new file mode 100644 index 0000000..6c9faac --- /dev/null +++ b/lib/screens/home/home_view_model.dart @@ -0,0 +1,73 @@ +import 'dart:async'; + +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:survey_flutter/model/survey_model.dart'; +import 'package:survey_flutter/screens/home/home_state.dart'; +import 'package:survey_flutter/usecases/base/base_use_case.dart'; +import 'package:survey_flutter/usecases/get_cached_surveys_use_case.dart'; +import 'package:survey_flutter/usecases/get_surveys_use_case.dart'; + +// TODO: Integrate load more +const _pageNumber = 1; +const _pageSize = 10; + +final homeViewModelProvider = + StateNotifierProvider.autoDispose((ref) { + final getSurveysUseCase = ref.read(getSurveysUseCaseProvider); + final getCachedSurveysUseCase = ref.read(getCachedSurveysUseCaseProvider); + + return HomeViewModel( + getSurveysUseCase: getSurveysUseCase, + getCachedSurveysUseCase: getCachedSurveysUseCase, + ); +}); + +class HomeViewModel extends StateNotifier { + final GetCachedSurveysUseCase _getCachedSurveysUseCase; + final GetSurveysUseCase _getSurveysUseCase; + + HomeViewModel({ + required GetSurveysUseCase getSurveysUseCase, + required GetCachedSurveysUseCase getCachedSurveysUseCase, + }) : _getSurveysUseCase = getSurveysUseCase, + _getCachedSurveysUseCase = getCachedSurveysUseCase, + super(const HomeState.loading()); + + final _surveys = StreamController>(); + Stream> get surveys => _surveys.stream; + + final _error = StreamController(); + Stream get error => _error.stream; + + Future loadSurveys({required bool isRefreshing}) async { + if (!isRefreshing) { + _loadSurveysFromCache(); + } + _loadSurveysFromRemote(); + } + + void _loadSurveysFromRemote() async { + final result = await _getSurveysUseCase.call(SurveysParams( + pageNumber: _pageNumber, + pageSize: _pageSize, + )); + if (result is Success>) { + final newSurveys = result.value; + _surveys.add(newSurveys); + state = const HomeState.loadSurveysSuccess(); + } else if (result is Failed) { + _error.add((result as Failed).getErrorMessage()); + state = const HomeState.error(); + } + } + + void _loadSurveysFromCache() async { + final result = await _getCachedSurveysUseCase.call(); + if (result is Success>) { + final cachedSurveys = + result.value.map((survey) => survey.toSurveyModel()).toList(); + _surveys.add(cachedSurveys); + state = const HomeState.loadCachedSurveysSuccess(); + } + } +} diff --git a/lib/screens/splash/splash_screen.dart b/lib/screens/splash/splash_screen.dart index 7e33539..cd3ec5c 100644 --- a/lib/screens/splash/splash_screen.dart +++ b/lib/screens/splash/splash_screen.dart @@ -1,17 +1,21 @@ 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_screen.dart'; +import 'package:survey_flutter/screens/splash/splash_view_model.dart'; -class SplashScreen extends StatefulWidget { +class SplashScreen extends ConsumerStatefulWidget { const SplashScreen({Key? key}) : super(key: key); @override - State createState() => _SplashScreenState(); + ConsumerState createState() => _SplashScreenState(); } -class _SplashScreenState extends State { +class _SplashScreenState extends ConsumerState { double _logoOpacity = 0; + bool? _isLoggedIn; @override void initState() { @@ -26,6 +30,9 @@ class _SplashScreenState extends State { @override Widget build(BuildContext context) { + ref.listen>(splashViewModelProvider, (_, next) { + next.whenData((result) => _isLoggedIn = result); + }); return Scaffold( body: LayoutBuilder(builder: (_, __) { return Stack( @@ -49,7 +56,11 @@ class _SplashScreenState extends State { duration: const Duration(seconds: 1), child: Assets.images.splashLogoWhite.image(), onEnd: () { - context.go(routePathLoginScreen); + if (_isLoggedIn == true) { + context.go(routePathHomeScreen); + } else { + context.go(routePathLoginScreen); + } }, ); } diff --git a/lib/screens/splash/splash_view_model.dart b/lib/screens/splash/splash_view_model.dart new file mode 100644 index 0000000..35a8771 --- /dev/null +++ b/lib/screens/splash/splash_view_model.dart @@ -0,0 +1,22 @@ +import 'dart:async'; + +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:survey_flutter/usecases/base/base_use_case.dart'; +import 'package:survey_flutter/usecases/check_user_logged_in_use_case.dart'; + +final splashViewModelProvider = + AsyncNotifierProvider.autoDispose( + SplashViewModel.new); + +class SplashViewModel extends AutoDisposeAsyncNotifier { + @override + FutureOr build() async { + return _checkUserLoggedIn(); + } + + Future _checkUserLoggedIn() async { + final checkUserLoggedInUseCase = ref.read(checkUserLoggedInUseCaseProvider); + final result = await checkUserLoggedInUseCase(); + return result is Success; + } +} diff --git a/lib/storage/secure_storage.dart b/lib/storage/secure_storage.dart index d441c3d..bbc9416 100644 --- a/lib/storage/secure_storage.dart +++ b/lib/storage/secure_storage.dart @@ -1,3 +1,5 @@ +import 'package:survey_flutter/utils/serializer/serializable.dart'; + enum SecureStorageKey { apiToken, } @@ -11,15 +13,13 @@ extension SecureStorageKeyExt on SecureStorageKey { } } -abstract class SecureStorageModel {} - enum SecureStorageError { failToGetValue, } abstract class SecureStorage { - Future save( + Future save( {required M value, required SecureStorageKey key}); - Future getValue( - {required SecureStorageKey key}); + Future getValue( + {required SecureStorageKey key, required Serializer serializer}); } diff --git a/lib/storage/secure_storage_impl.dart b/lib/storage/secure_storage_impl.dart index 00f4741..6b388ac 100644 --- a/lib/storage/secure_storage_impl.dart +++ b/lib/storage/secure_storage_impl.dart @@ -3,6 +3,7 @@ import 'dart:convert'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:survey_flutter/storage/secure_storage.dart'; +import 'package:survey_flutter/utils/serializer/serializable.dart'; import '../di/provider/flutter_secure_storage.dart'; @@ -15,19 +16,23 @@ class SecureStorageImpl extends SecureStorage { SecureStorageImpl(this._storage); @override - Future getValue( - {required SecureStorageKey key}) async { + Future getValue({ + required SecureStorageKey key, + required Serializer serializer, + }) async { final rawValue = await _storage.read(key: key.string); if (rawValue == null) { throw SecureStorageError.failToGetValue; } - - return await jsonDecode(rawValue); + final jsonValue = await jsonDecode(rawValue); + return serializer.serialize(jsonValue); } @override - Future save( - {required M value, required SecureStorageKey key}) async { + Future save({ + required M value, + required SecureStorageKey key, + }) async { final encodedValue = jsonEncode(value); await _storage.write(key: key.string, value: encodedValue); } diff --git a/lib/storage/survey_storage.dart b/lib/storage/survey_storage.dart new file mode 100644 index 0000000..d359d96 --- /dev/null +++ b/lib/storage/survey_storage.dart @@ -0,0 +1,49 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:hive_flutter/hive_flutter.dart'; +import 'package:survey_flutter/model/survey_model.dart'; + +const String surveysBoxKey = 'surveys'; +Box? surveysBox; + +Future setupHive() async { + await Hive.initFlutter(); + Hive.registerAdapter(SurveyModelAdapter()); +} + +final surveyStorageProvider = Provider((ref) { + _setupBox(); + ref.onDispose(() { + surveysBox?.close(); + }); + return SurveyStorageImpl(); +}); + +void _setupBox() async { + surveysBox = await Hive.openBox(surveysBoxKey); +} + +abstract class SurveyStorage { + Future saveSurveys(List surveys); + + Future> getSurveys(); + + Future clear(); +} + +class SurveyStorageImpl extends SurveyStorage { + @override + Future saveSurveys(List surveys) async { + await surveysBox?.addAll(surveys); + } + + @override + Future> getSurveys() async { + final surveys = surveysBox?.values.toList(); + return surveys ?? List.empty(); + } + + @override + Future clear() async { + return await surveysBox?.clear() ?? 0; + } +} diff --git a/lib/usecases/base/base_use_case.dart b/lib/usecases/base/base_use_case.dart index 3ec78dc..d5227d9 100644 --- a/lib/usecases/base/base_use_case.dart +++ b/lib/usecases/base/base_use_case.dart @@ -1,3 +1,5 @@ +import 'package:survey_flutter/api/exception/network_exceptions.dart'; + part 'use_case_result.dart'; abstract class BaseUseCase { diff --git a/lib/usecases/base/use_case_result.dart b/lib/usecases/base/use_case_result.dart index 456111c..976794f 100644 --- a/lib/usecases/base/use_case_result.dart +++ b/lib/usecases/base/use_case_result.dart @@ -20,4 +20,7 @@ class Failed extends Result { final UseCaseException exception; Failed(this.exception) : super._(); + + String getErrorMessage() => + NetworkExceptions.getErrorMessage(exception.actualException); } diff --git a/lib/usecases/check_user_logged_in_use_case.dart b/lib/usecases/check_user_logged_in_use_case.dart new file mode 100644 index 0000000..3f2143c --- /dev/null +++ b/lib/usecases/check_user_logged_in_use_case.dart @@ -0,0 +1,23 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:survey_flutter/api/data_sources/token_data_source.dart'; +import 'package:survey_flutter/usecases/base/base_use_case.dart'; + +final checkUserLoggedInUseCaseProvider = + Provider((ref) { + return CheckUserLoggedInUseCase(ref.watch(tokenDataSourceProvider)); +}); + +class CheckUserLoggedInUseCase implements NoParamsUseCase { + final TokenDataSource _tokenDataSource; + + CheckUserLoggedInUseCase(this._tokenDataSource); + @override + Future> call() async { + try { + final _ = await _tokenDataSource.getToken(); + return Success(true); + } catch (error) { + return Failed(UseCaseException(error)); + } + } +} diff --git a/lib/usecases/get_cached_surveys_use_case.dart b/lib/usecases/get_cached_surveys_use_case.dart new file mode 100644 index 0000000..40f2b55 --- /dev/null +++ b/lib/usecases/get_cached_surveys_use_case.dart @@ -0,0 +1,19 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:survey_flutter/model/survey_model.dart'; +import 'package:survey_flutter/storage/survey_storage.dart'; +import 'package:survey_flutter/usecases/base/base_use_case.dart'; + +final getCachedSurveysUseCaseProvider = Provider( + (ref) => GetCachedSurveysUseCase(ref.watch(surveyStorageProvider))); + +class GetCachedSurveysUseCase extends NoParamsUseCase> { + final SurveyStorage _surveyStorage; + + GetCachedSurveysUseCase(this._surveyStorage); + + @override + Future>> call() async { + final surveys = await _surveyStorage.getSurveys(); + return Success(surveys); + } +} diff --git a/lib/usecases/get_surveys_use_case.dart b/lib/usecases/get_surveys_use_case.dart index 764f68a..0004304 100644 --- a/lib/usecases/get_surveys_use_case.dart +++ b/lib/usecases/get_surveys_use_case.dart @@ -1,7 +1,7 @@ -import 'package:survey_flutter/model/surveys_container_model.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:survey_flutter/model/survey_model.dart'; import 'package:survey_flutter/repositories/survey_repository.dart'; - -import 'base/base_use_case.dart'; +import 'package:survey_flutter/usecases/base/base_use_case.dart'; class SurveysParams { final int pageNumber; @@ -13,13 +13,16 @@ class SurveysParams { }); } -class GetSurveysUseCase extends UseCase { +final getSurveysUseCaseProvider = + Provider((ref) => GetSurveysUseCase(ref.watch(surveyRepositoryProvider))); + +class GetSurveysUseCase extends UseCase, SurveysParams> { final SurveyRepository _repository; const GetSurveysUseCase(this._repository); @override - Future> call(SurveysParams params) async { + Future>> call(SurveysParams params) async { try { final result = await _repository.getSurveys( pageNumber: params.pageNumber, pageSize: params.pageSize); diff --git a/lib/utils/serializer/api_token_serializer.dart b/lib/utils/serializer/api_token_serializer.dart new file mode 100644 index 0000000..0c9b50d --- /dev/null +++ b/lib/utils/serializer/api_token_serializer.dart @@ -0,0 +1,9 @@ +import 'package:survey_flutter/model/api_token.dart'; +import 'package:survey_flutter/utils/serializer/serializable.dart'; + +class ApiTokenSerializer extends Serializer { + @override + ApiToken serialize(Map json) { + return ApiToken.fromJson(json); + } +} diff --git a/lib/utils/serializer/serializable.dart b/lib/utils/serializer/serializable.dart new file mode 100644 index 0000000..84b3c28 --- /dev/null +++ b/lib/utils/serializer/serializable.dart @@ -0,0 +1,5 @@ +abstract class Serializable {} + +abstract class Serializer { + T serialize(Map json); +} diff --git a/pubspec.lock b/pubspec.lock index d576ca4..19f9987 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -407,6 +407,30 @@ packages: url: "https://pub.dev" source: hosted version: "2.3.0" + hive: + dependency: "direct main" + description: + name: hive + sha256: "8dcf6db979d7933da8217edcec84e9df1bdb4e4edc7fc77dbd5aa74356d6d941" + url: "https://pub.dev" + source: hosted + version: "2.2.3" + hive_flutter: + dependency: "direct main" + description: + name: hive_flutter + sha256: dca1da446b1d808a51689fb5d0c6c9510c0a2ba01e22805d492c73b68e33eecc + url: "https://pub.dev" + source: hosted + version: "1.1.0" + hive_generator: + dependency: "direct dev" + description: + name: hive_generator + sha256: "06cb8f58ace74de61f63500564931f9505368f45f98958bd7a6c35ba24159db4" + url: "https://pub.dev" + source: hosted + version: "2.0.1" http: dependency: transitive description: @@ -596,6 +620,54 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.1" + path_provider: + dependency: transitive + description: + name: path_provider + sha256: "909b84830485dbcd0308edf6f7368bc8fd76afa26a270420f34cabea2a6467a0" + url: "https://pub.dev" + source: hosted + version: "2.1.0" + path_provider_android: + dependency: transitive + description: + name: path_provider_android + sha256: "5d44fc3314d969b84816b569070d7ace0f1dea04bd94a83f74c4829615d22ad8" + url: "https://pub.dev" + source: hosted + version: "2.1.0" + path_provider_foundation: + dependency: transitive + description: + name: path_provider_foundation + sha256: "1b744d3d774e5a879bb76d6cd1ecee2ba2c6960c03b1020cd35212f6aa267ac5" + url: "https://pub.dev" + source: hosted + version: "2.3.0" + path_provider_linux: + dependency: transitive + description: + name: path_provider_linux + sha256: ba2b77f0c52a33db09fc8caf85b12df691bf28d983e84cf87ff6d693cfa007b3 + url: "https://pub.dev" + source: hosted + version: "2.2.0" + path_provider_platform_interface: + dependency: transitive + description: + name: path_provider_platform_interface + sha256: bced5679c7df11190e1ddc35f3222c858f328fff85c3942e46e7f5589bf9eb84 + url: "https://pub.dev" + source: hosted + version: "2.1.0" + path_provider_windows: + dependency: transitive + description: + name: path_provider_windows + sha256: ee0e0d164516b90ae1f970bdf29f726f1aa730d7cfc449ecc74c495378b705da + url: "https://pub.dev" + source: hosted + version: "2.2.0" permission_handler: dependency: "direct main" description: @@ -937,6 +1009,14 @@ packages: url: "https://pub.dev" source: hosted version: "4.1.4" + xdg_directories: + dependency: transitive + description: + name: xdg_directories + sha256: f0c26453a2d47aa4c2570c6a033246a3fc62da2fe23c7ffdd0a7495086dc0247 + url: "https://pub.dev" + source: hosted + version: "1.0.2" xml: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 74e9efb..129f864 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # In Windows, build-name is used as the major, minor, and patch parts # of the product and file versions while build-number is used as the build suffix. -version: 0.3.0+1 +version: 0.4.0+1 environment: sdk: '>=3.0.0 <4.0.0' @@ -49,6 +49,9 @@ dependencies: page_view_dot_indicator: ^2.1.0 flutter_secure_storage: ^8.0.0 shimmer: ^3.0.0 + hive: ^2.2.3 + hive_flutter: ^1.1.0 + dev_dependencies: build_runner: ^2.4.4 @@ -62,6 +65,7 @@ dev_dependencies: json_serializable: ^6.6.2 mockito: ^5.4.0 retrofit_generator: ^7.0.0 + hive_generator: ^2.0.0 # For information on the generic Dart part of this file, see the # following page: https://dart.dev/tools/pub/pubspec diff --git a/test/api/data_sources/token_data_source_test.dart b/test/api/data_sources/token_data_source_test.dart new file mode 100644 index 0000000..06163c1 --- /dev/null +++ b/test/api/data_sources/token_data_source_test.dart @@ -0,0 +1,103 @@ +import 'package:flutter_config/flutter_config.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mockito/mockito.dart'; +import 'package:survey_flutter/api/data_sources/token_data_source.dart'; +import 'package:survey_flutter/model/response/token_response.dart'; +import 'package:survey_flutter/storage/secure_storage.dart'; + +import '../../mocks/generate_mocks.mocks.dart'; + +void main() { + group('TokenDataSource', () { + late MockAuthenticationApiService mockAuthenticationApiService; + late MockSecureStorage mockSecureStorage; + late TokenDataSource tokenDataSource; + final tokenResponse = TokenResponse.dummy(); + final apiToken = tokenResponse.toApiToken(); + + setUp(() { + FlutterConfig.loadValueForTesting({ + 'CLIENT_ID': 'CLIENT_ID', + 'CLIENT_SECRET': 'CLIENT_SECRET', + }); + + mockSecureStorage = MockSecureStorage(); + mockAuthenticationApiService = MockAuthenticationApiService(); + + tokenDataSource = TokenDataSourceImpl( + mockSecureStorage, + mockAuthenticationApiService, + ); + }); + + // TODO: Update + //group('Get token without refreshing', () { + // test('When secureStorage returns value, it returns corresponding value', + // () async { + // final tokenResponse = TokenResponse.dummy(); + // final apiToken = tokenResponse.toApiToken(); + + // when(mockSecureStorage.getValue( + // key: SecureStorageKey.apiToken, + // serializer: ApiTokenSerializer(), + // )).thenAnswer((_) async => apiToken); + // final result = await tokenDataSource.getToken(); + // expect(result, apiToken); + // }); + + // test('When secureStorage returns error, it returns corresponding error', + // () async { + // when(mockSecureStorage.getValue( + // key: SecureStorageKey.apiToken, + // serializer: ApiTokenSerializer(), + // )).thenThrow(SecureStorageError.failToGetValue); + // expect(tokenDataSource.getToken(), throwsA(isA())); + // }); + //}); + + //group('Get token with refreshing', () { + // test( + // 'When authenticationApiService returns value, it returns corresponding value', + // () async { + // when(mockAuthenticationApiService.refreshToken(any)) + // .thenAnswer((_) async => tokenResponse); + // when(mockSecureStorage.getValue( + // key: SecureStorageKey.apiToken, + // serializer: ApiTokenSerializer(), + // )).thenAnswer((_) async => apiToken); + + // final result = await tokenDataSource.getToken(forceRefresh: true); + // expect(result, apiToken); + // verify( + // mockSecureStorage.save( + // value: apiToken, key: SecureStorageKey.apiToken), + // ).called(1); + // }); + + // test( + // 'When authenticationApiService returns error, it returns corresponding error', + // () async { + // when(mockAuthenticationApiService.refreshToken(any)) + // .thenThrow(MockDioError()); + // when(mockSecureStorage.getValue( + // key: SecureStorageKey.apiToken, + // serializer: ApiTokenSerializer(), + // )).thenAnswer((_) async => apiToken); + // expect(tokenDataSource.getToken(forceRefresh: true), + // throwsA(isA())); + // }); + //}); + + group('Set token', () { + test( + 'When calling setToken, it calls secureStorage to save the same token', + () async { + await tokenDataSource.setToken(apiToken); + verify( + mockSecureStorage.save( + value: apiToken, key: SecureStorageKey.apiToken), + ).called(1); + }); + }); + }); +} diff --git a/test/api/repositories/authentication_repository_test.dart b/test/api/repositories/authentication_repository_test.dart index 28ed4fd..6aed47e 100644 --- a/test/api/repositories/authentication_repository_test.dart +++ b/test/api/repositories/authentication_repository_test.dart @@ -2,16 +2,15 @@ import 'package:flutter_config/flutter_config.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/model/response/login_response.dart'; +import 'package:survey_flutter/model/response/token_response.dart'; import 'package:survey_flutter/repositories/authentication_repository.dart'; -import 'package:survey_flutter/storage/secure_storage.dart'; import '../../mocks/generate_mocks.mocks.dart'; void main() { group('AuthenticationRepositoryTest', () { late MockAuthenticationApiService mockAuthApiService; - late MockSecureStorage mockSecureStorage; + late MockTokenDataSource mockTokenDataSource; late AuthenticationRepositoryImpl authRepository; const email = "email"; @@ -26,15 +25,15 @@ void main() { setUp(() { mockAuthApiService = MockAuthenticationApiService(); - mockSecureStorage = MockSecureStorage(); + mockTokenDataSource = MockTokenDataSource(); authRepository = AuthenticationRepositoryImpl( mockAuthApiService, - mockSecureStorage, + mockTokenDataSource, ); }); test('When login successfully, it returns correct model', () async { - final loginResponse = LoginResponse.dummy(); + final loginResponse = TokenResponse.dummy(); when(mockAuthApiService.login(any)) .thenAnswer((_) async => loginResponse); @@ -44,9 +43,8 @@ void main() { expect(result, loginResponse.toLoginModel()); verify( - mockSecureStorage.save( - value: loginResponse.toApiToken(), - key: SecureStorageKey.apiToken, + mockTokenDataSource.setToken( + loginResponse.toApiToken(), ), ).called(1); }); diff --git a/test/api/repositories/survey_repository_test.dart b/test/api/repositories/survey_repository_test.dart index 6dd9467..e744faa 100644 --- a/test/api/repositories/survey_repository_test.dart +++ b/test/api/repositories/survey_repository_test.dart @@ -10,11 +10,14 @@ import '../../mocks/generate_mocks.mocks.dart'; void main() { group('SurveyRepository', () { late MockSurveyApiService mockSurveyApiService; + late MockSurveyStorage mockSurveyStorage; late SurveyRepositoryImpl surveyRepository; setUp(() { mockSurveyApiService = MockSurveyApiService(); - surveyRepository = SurveyRepositoryImpl(mockSurveyApiService); + mockSurveyStorage = MockSurveyStorage(); + surveyRepository = + SurveyRepositoryImpl(mockSurveyApiService, mockSurveyStorage); }); test('when getting surveys is successful returns SurveysContainerModel', @@ -28,7 +31,7 @@ void main() { final result = await surveyRepository.getSurveys(pageSize: 0, pageNumber: 0); - expect(result, surveysResponse.toSurveysContainerModel()); + expect(result, surveysResponse.toSurveysContainerModel().surveys); }); test('when getting surveys fails throws NetworkExceptions', () async { diff --git a/test/api/usecases/check_user_logged_in_use_case_test.dart b/test/api/usecases/check_user_logged_in_use_case_test.dart new file mode 100644 index 0000000..aaa44b9 --- /dev/null +++ b/test/api/usecases/check_user_logged_in_use_case_test.dart @@ -0,0 +1,34 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:mockito/mockito.dart'; +import 'package:survey_flutter/model/response/token_response.dart'; +import 'package:survey_flutter/usecases/base/base_use_case.dart'; +import 'package:survey_flutter/usecases/check_user_logged_in_use_case.dart'; + +import '../../mocks/generate_mocks.mocks.dart'; + +void main() { + group('CheckUserLoggedInUseCase', () { + late MockTokenDataSource mockTokenDataSource; + late CheckUserLoggedInUseCase useCase; + + setUp(() { + mockTokenDataSource = MockTokenDataSource(); + useCase = CheckUserLoggedInUseCase(mockTokenDataSource); + }); + + test('When tokenDataSource could return a token, it returns success', + () async { + final token = TokenResponse.dummy().toApiToken(); + when(mockTokenDataSource.getToken()).thenAnswer((_) async => token); + final result = await useCase(); + expect((result as Success).value, true); + }); + + test('When tokenDataSource couldn\'t return a token, it returns failed', + () async { + when(mockTokenDataSource.getToken()).thenThrow((_) => Exception()); + final result = await useCase(); + expect(result, isA()); + }); + }); +} diff --git a/test/api/usecases/get_cached_surveys_use_case_test.dart b/test/api/usecases/get_cached_surveys_use_case_test.dart new file mode 100644 index 0000000..ffccb14 --- /dev/null +++ b/test/api/usecases/get_cached_surveys_use_case_test.dart @@ -0,0 +1,32 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:mockito/mockito.dart'; +import 'package:survey_flutter/model/survey_model.dart'; +import 'package:survey_flutter/usecases/base/base_use_case.dart'; +import 'package:survey_flutter/usecases/get_cached_surveys_use_case.dart'; + +import '../../mocks/generate_mocks.mocks.dart'; + +void main() { + group('GetCachedSurveysUseCaseTest', () { + late MockSurveyStorage mockSurveyStorage; + late GetCachedSurveysUseCase getCachedSurveysUseCase; + + setUp(() async { + mockSurveyStorage = MockSurveyStorage(); + getCachedSurveysUseCase = GetCachedSurveysUseCase(mockSurveyStorage); + }); + + test( + 'When fetching cached surveys returns cached surveys', + () async { + final surveys = []; + final successResult = Success>(surveys); + when(mockSurveyStorage.getSurveys()) + .thenAnswer((_) async => successResult.value); + final result = await getCachedSurveysUseCase.call(); + expect(result, isA>>()); + expect((result as Success>).value, surveys); + }, + ); + }); +} diff --git a/test/api/usecases/get_surveys_user_case_test.dart b/test/api/usecases/get_surveys_user_case_test.dart index bf680c8..e3e0260 100644 --- a/test/api/usecases/get_surveys_user_case_test.dart +++ b/test/api/usecases/get_surveys_user_case_test.dart @@ -1,5 +1,6 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:mockito/mockito.dart'; +import 'package:survey_flutter/model/survey_model.dart'; import 'package:survey_flutter/model/surveys_container_model.dart'; import 'package:survey_flutter/usecases/base/base_use_case.dart'; import 'package:survey_flutter/usecases/get_surveys_use_case.dart'; @@ -17,24 +18,20 @@ void main() { }); test('When getting surveys is successful returns Success ', () async { - final surveysContainerModel = SurveysContainerModel.dummy(); + final surveysModel = SurveysContainerModel.dummy().surveys; final surveysParams = SurveysParams(pageNumber: 0, pageSize: 10); when(mockRepository.getSurveys(pageNumber: 0, pageSize: 10)) - .thenAnswer((_) async => surveysContainerModel); - + .thenAnswer((_) async => surveysModel); final result = await getSurveysUseCase(surveysParams); - - expect(result, isA>()); + expect(result, isA>>()); }); test('returns Failed when getting surveys fails', () async { final surveysParams = SurveysParams(pageNumber: 0, pageSize: 10); - when(mockRepository.getSurveys(pageNumber: 0, pageSize: 10)) .thenThrow(Exception()); final result = await getSurveysUseCase(surveysParams); - - expect(result, isA>()); + expect(result, isA>>()); }); }); } diff --git a/test/mocks/generate_mocks.dart b/test/mocks/generate_mocks.dart index f130b4b..1d61736 100644 --- a/test/mocks/generate_mocks.dart +++ b/test/mocks/generate_mocks.dart @@ -1,12 +1,17 @@ import 'package:dio/dio.dart'; import 'package:mockito/annotations.dart'; import 'package:survey_flutter/api/authentication_api_service.dart'; +import 'package:survey_flutter/api/data_sources/token_data_source.dart'; import 'package:survey_flutter/api/survey_api_service.dart'; import 'package:survey_flutter/repositories/authentication_repository.dart'; +import 'package:survey_flutter/repositories/survey_repository.dart'; import 'package:survey_flutter/storage/secure_storage.dart'; +import 'package:survey_flutter/storage/survey_storage.dart'; +import 'package:survey_flutter/usecases/check_user_logged_in_use_case.dart'; +import 'package:survey_flutter/usecases/get_cached_surveys_use_case.dart'; +import 'package:survey_flutter/usecases/get_surveys_use_case.dart'; import 'package:survey_flutter/usecases/login_use_case.dart'; import 'package:survey_flutter/utils/internet_connection_manager.dart'; -import 'package:survey_flutter/repositories/survey_repository.dart'; import '../utils/async_listener.dart'; @@ -14,12 +19,17 @@ import '../utils/async_listener.dart'; AsyncListener, AuthenticationApiService, AuthenticationRepository, + CheckUserLoggedInUseCase, DioError, + GetCachedSurveysUseCase, + GetSurveysUseCase, InternetConnectionManager, LoginUseCase, SecureStorage, - SurveyRepository, SurveyApiService, + SurveyStorage, + SurveyRepository, + TokenDataSource, ]) main() { // empty class to generate mock repository classes diff --git a/test/screens/home/home_view_model_test.dart b/test/screens/home/home_view_model_test.dart new file mode 100644 index 0000000..c486072 --- /dev/null +++ b/test/screens/home/home_view_model_test.dart @@ -0,0 +1,99 @@ +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/model/survey_model.dart'; +import 'package:survey_flutter/screens/home/home_state.dart'; +import 'package:survey_flutter/screens/home/home_view_model.dart'; +import 'package:survey_flutter/usecases/base/base_use_case.dart'; +import 'package:survey_flutter/usecases/get_cached_surveys_use_case.dart'; +import 'package:survey_flutter/usecases/get_surveys_use_case.dart'; + +import '../../mocks/generate_mocks.mocks.dart'; // Import your generated mocks + +void main() { + group('HomeViewModel', () { + late MockGetCachedSurveysUseCase mockGetCachedSurveysUseCase; + late MockGetSurveysUseCase mockGetSurveysUseCase; + late HomeViewModel homeViewModel; + late ProviderContainer providerContainer; + + final List surveys = [ + const SurveyModel.dummy(), + const SurveyModel.dummy(), + ]; + + final UseCaseException exception = + UseCaseException(const NetworkExceptions.unauthorisedRequest()); + + setUp(() { + mockGetCachedSurveysUseCase = MockGetCachedSurveysUseCase(); + mockGetSurveysUseCase = MockGetSurveysUseCase(); + + providerContainer = ProviderContainer( + overrides: [ + getCachedSurveysUseCaseProvider + .overrideWithValue(mockGetCachedSurveysUseCase), + getSurveysUseCaseProvider.overrideWithValue(mockGetSurveysUseCase), + ], + ); + homeViewModel = providerContainer.read(homeViewModelProvider.notifier); + }); + + test( + 'loads surveys successfully and emits LoadSurveysSuccess and LoadCacheSurveysSuccess states', + () async { + when(mockGetSurveysUseCase.call(any)).thenAnswer( + (_) async => Success(surveys), + ); + when(mockGetCachedSurveysUseCase.call()) + .thenAnswer((_) => Future.value(Success(surveys))); + final surveysStream = homeViewModel.surveys; + final stateStream = homeViewModel.stream; + homeViewModel.loadSurveys(isRefreshing: false); + expect(surveysStream, emitsInOrder([surveys])); + expect( + stateStream, + emitsInOrder([ + const HomeState.loadCachedSurveysSuccess(), + const HomeState.loadSurveysSuccess() + ])); + }, + ); + + test( + 'When refreshing surveys successfully and emits a list of surveys with state LoadSurveysSuccess', + () { + when(mockGetSurveysUseCase.call(any)).thenAnswer( + (_) async => Success(surveys), + ); + final surveysStream = homeViewModel.surveys; + homeViewModel.loadSurveys(isRefreshing: true); + expect(surveysStream, emitsInOrder([surveys])); + }); + + test( + 'loads surveys with error and emits error state', + () async { + when(mockGetSurveysUseCase.call(any)).thenAnswer( + (_) async => Failed(exception), + ); + when(mockGetCachedSurveysUseCase.call()).thenAnswer( + (_) async => Failed(exception), + ); + final errorStream = homeViewModel.error; + homeViewModel.loadSurveys(isRefreshing: false); + expect( + errorStream, + emitsInOrder( + [NetworkExceptions.getErrorMessage(exception.actualException)], + ), + ); + }, + ); + + tearDown(() { + providerContainer.dispose(); + }); + }); +} diff --git a/test/screens/splash/splash_view_model_test.dart b/test/screens/splash/splash_view_model_test.dart new file mode 100644 index 0000000..b3a650e --- /dev/null +++ b/test/screens/splash/splash_view_model_test.dart @@ -0,0 +1,68 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mockito/mockito.dart'; +import 'package:survey_flutter/screens/splash/splash_view_model.dart'; +import 'package:survey_flutter/usecases/base/base_use_case.dart'; +import 'package:survey_flutter/usecases/check_user_logged_in_use_case.dart'; + +import '../../mocks/generate_mocks.mocks.dart'; + +void main() { + group('SplashViewModel', () { + late ProviderContainer container; + late MockCheckUserLoggedInUseCase mockCheckUserLoggedInUseCase; + + setUp(() { + mockCheckUserLoggedInUseCase = MockCheckUserLoggedInUseCase(); + container = ProviderContainer( + overrides: [ + checkUserLoggedInUseCaseProvider + .overrideWithValue(mockCheckUserLoggedInUseCase), + ], + ); + addTearDown(container.dispose); + }); + + test('When hasUserLoggedInUseCase returns success, it returns true', + () async { + when(mockCheckUserLoggedInUseCase()) + .thenAnswer((_) async => Success(true)); + + // The first read if the loading state + expect( + container.read(splashViewModelProvider), + const AsyncValue.loading(), + ); + + /// Wait for the request to finish + await container.read(splashViewModelProvider.future); + + // Exposes the data + expect( + container.read(splashViewModelProvider).value, + isA().having((result) => result, '', true), + ); + }); + + test('When hasUserLoggedInUseCase returns failed, it returns false', + () async { + when(mockCheckUserLoggedInUseCase()) + .thenAnswer((_) async => Failed(UseCaseException(Exception()))); + + // The first read if the loading state + expect( + container.read(splashViewModelProvider), + const AsyncValue.loading(), + ); + + /// Wait for the request to finish + await container.read(splashViewModelProvider.future); + + // Exposes the data + expect( + container.read(splashViewModelProvider).value, + isA().having((result) => result, '', false), + ); + }); + }); +}