diff --git a/assets/color/colors.xml b/assets/color/colors.xml index be0ad47..3eccf06 100644 --- a/assets/color/colors.xml +++ b/assets/color/colors.xml @@ -1,4 +1,5 @@ #15151A + #252525 diff --git a/assets/images/ic_back_arrow.svg b/assets/images/ic_arrow_back.svg similarity index 100% rename from assets/images/ic_back_arrow.svg rename to assets/images/ic_arrow_back.svg diff --git a/assets/images/ic_arrow_right.svg b/assets/images/ic_arrow_next.svg similarity index 100% rename from assets/images/ic_arrow_right.svg rename to assets/images/ic_arrow_next.svg diff --git a/assets/images/ic_notification.svg b/assets/images/ic_notification.svg new file mode 100644 index 0000000..c3310e4 --- /dev/null +++ b/assets/images/ic_notification.svg @@ -0,0 +1,5 @@ + + + diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index e3a78a8..9440670 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -6,5 +6,8 @@ "loginInvalidEmailPassword": "Invalid email or password", "resetPasswordDescription": "Enter your email to receive instructions for resetting your password.", "resetPasswordReset": "Reset", + "resetPasswordSuccessTitle": "Check your email.", + "resetPasswordSuccessDescription": "We’ve email you instructions to reset your password.", + "resetPasswordInvalidEmail": "Invalid email", "homeToday": "Today" } diff --git a/lib/l10n/app_th.arb b/lib/l10n/app_th.arb index 361223a..98ba9c0 100644 --- a/lib/l10n/app_th.arb +++ b/lib/l10n/app_th.arb @@ -6,5 +6,8 @@ "loginInvalidEmailPassword": "อีเมลล์หรือรหัสผ่านไม่ถูกต้อง", "resetPasswordDescription": "กรุณากรอกอีเมลล์เพื่อรับคำแนะนำในการรีเซ็ตรหัสผ่าน", "resetPasswordReset": "รีเซ็ต", + "resetPasswordSuccessTitle": "ตรวจสอบอีเมลล์ของคุณ", + "resetPasswordSuccessDescription": "เราได้ส่งขั้นตอนการรีเซ็ตรหัสผ่านให้คุณทางอีเมลล์แล้ว", + "resetPasswordInvalidEmail": "อีเมลล์ไม่ถูกต้อง", "homeToday": "วันนี้" } diff --git a/lib/page/home/home_page.dart b/lib/page/home/home_page.dart index 5bcdce3..f6b4392 100644 --- a/lib/page/home/home_page.dart +++ b/lib/page/home/home_page.dart @@ -13,6 +13,7 @@ import 'package:survey/page/home/widget/home_surveys_page_view_widget.dart'; import 'package:survey/usecase/get_cached_surveys_use_case.dart'; import 'package:survey/usecase/get_surveys_use_case.dart'; import 'package:survey/usecase/get_user_use_case.dart'; +import 'package:survey/widget/loading_indicator_widget.dart'; final homeViewModelProvider = StateNotifierProvider.autoDispose((ref) { @@ -116,16 +117,20 @@ class _HomePageState extends ConsumerState { child: ListView(), ), SafeArea( - child: HomeHeaderWidget( - currentDate: - ref.read(homeViewModelProvider.notifier).getCurrentDate(), - userAvatarUrl: ref.watch(_userStreamProvider).value?.avatarUrl, + child: Column( + children: [ + HomeHeaderWidget( + currentDate: ref + .read(homeViewModelProvider.notifier) + .getCurrentDate(), + userAvatarUrl: + ref.watch(_userStreamProvider).value?.avatarUrl, + ), + if (shouldShowLoading) + LoadingIndicatorWidget(shouldIgnoreOtherGestures: false) + ], ), ), - if (shouldShowLoading) - const Center( - child: CircularProgressIndicator(color: Colors.white), - ) ], ), ), diff --git a/lib/page/home/widget/home_surveys_item_widget.dart b/lib/page/home/widget/home_surveys_item_widget.dart index 142222f..c411239 100644 --- a/lib/page/home/widget/home_surveys_item_widget.dart +++ b/lib/page/home/widget/home_surveys_item_widget.dart @@ -60,7 +60,7 @@ class HomeSurveysItemWidget extends StatelessWidget { Padding( padding: const EdgeInsets.only(left: Dimens.space20), child: ElevatedButton( - child: Assets.images.icArrowRight.svg(), + child: Assets.images.icArrowNext.svg(), style: ElevatedButton.styleFrom( elevation: 0, shape: const CircleBorder(), diff --git a/lib/page/login/login_page.dart b/lib/page/login/login_page.dart index f3699e0..f23276b 100644 --- a/lib/page/login/login_page.dart +++ b/lib/page/login/login_page.dart @@ -10,7 +10,7 @@ import 'package:survey/page/login/login_view_model.dart'; import 'package:survey/page/login/widget/login_text_input_forgot_password_widget.dart'; import 'package:survey/resource/dimens.dart'; import 'package:survey/usecase/login_use_case.dart'; -import 'package:survey/widget/circular_progress_bar_widget.dart'; +import 'package:survey/widget/loading_indicator_widget.dart'; import 'package:survey/widget/onboarding_background_widget.dart'; import 'package:survey/widget/rounded_button_widget.dart'; import 'package:survey/widget/text_input_widget.dart'; @@ -96,7 +96,7 @@ class _LoginPageState extends ConsumerState { ), ), ref.watch(loginViewModelProvider).maybeWhen( - loading: () => const CircularProgressBarWidget(), + loading: () => const LoadingIndicatorWidget(), orElse: () => const SizedBox(), ) ], diff --git a/lib/page/login/login_state.dart b/lib/page/login/login_state.dart index d9f1996..7701a8a 100644 --- a/lib/page/login/login_state.dart +++ b/lib/page/login/login_state.dart @@ -12,5 +12,5 @@ class LoginState with _$LoginState { const factory LoginState.apiError(String errorMessage) = _Error; - const factory LoginState.invalidInputsError() = _InvalidInputError; + const factory LoginState.invalidInputsError() = _InvalidInputsError; } diff --git a/lib/page/resetpassword/reset_password_page.dart b/lib/page/resetpassword/reset_password_page.dart index 1fce7c5..d9a9b40 100644 --- a/lib/page/resetpassword/reset_password_page.dart +++ b/lib/page/resetpassword/reset_password_page.dart @@ -1,17 +1,51 @@ +import 'package:another_flushbar/flushbar.dart'; import 'package:flutter/material.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:survey/constants.dart'; +import 'package:survey/di/di.dart'; import 'package:survey/gen/assets.gen.dart'; +import 'package:survey/gen/colors.gen.dart'; +import 'package:survey/page/resetpassword/reset_password_state.dart'; +import 'package:survey/page/resetpassword/reset_password_view_model.dart'; import 'package:survey/resource/dimens.dart'; +import 'package:survey/usecase/reset_password_use_case.dart'; import 'package:survey/widget/app_bar_back_button_widget.dart'; +import 'package:survey/widget/loading_indicator_widget.dart'; import 'package:survey/widget/onboarding_background_widget.dart'; import 'package:survey/widget/rounded_button_widget.dart'; import 'package:survey/widget/text_input_widget.dart'; -class ResetPasswordPage extends StatelessWidget { - const ResetPasswordPage({Key? key}) : super(key: key); +final resetPasswordViewModelProvider = StateNotifierProvider.autoDispose< + ResetPasswordViewModel, ResetPasswordState>((ref) { + return ResetPasswordViewModel(getIt.get()); +}); + +class ResetPasswordPage extends ConsumerStatefulWidget { + const ResetPasswordPage({super.key}); + + @override + _ResetPasswordPageState createState() => _ResetPasswordPageState(); +} + +class _ResetPasswordPageState extends ConsumerState { + final _emailController = TextEditingController(); @override Widget build(BuildContext context) { + ref.listen(resetPasswordViewModelProvider, ( + ResetPasswordState? previousState, + ResetPasswordState newState, + ) { + newState.maybeWhen( + success: () => _showResetPasswordSuccessFlushbar(), + apiError: (errorMessage) => _showError(errorMessage), + invalidInputError: () => + _showError(AppLocalizations.of(context)!.resetPasswordInvalidEmail), + orElse: () {}, + ); + }); + return Scaffold( resizeToAvoidBottomInset: false, body: Stack( @@ -39,13 +73,17 @@ class ResetPasswordPage extends StatelessWidget { const SizedBox(height: Dimens.space96), TextInputWidget( hintText: AppLocalizations.of(context)!.email, + controller: _emailController, ), const SizedBox(height: Dimens.space20), RoundedButtonWidget( buttonText: AppLocalizations.of(context)!.resetPasswordReset, onPressed: () { - // TODO: Call reset password use case + _hideKeyboard(); + ref + .read(resetPasswordViewModelProvider.notifier) + .resetPassword(_emailController.text); }, ), ], @@ -56,8 +94,54 @@ class ResetPasswordPage extends StatelessWidget { padding: EdgeInsets.only(top: Dimens.space24, left: Dimens.space22), child: AppBarBackButtonWidget(), ), + ref.watch(resetPasswordViewModelProvider).maybeWhen( + loading: () => const LoadingIndicatorWidget(), + orElse: () => const SizedBox(), + ) ], ), ); } + + void _showResetPasswordSuccessFlushbar() { + Flushbar( + flushbarPosition: FlushbarPosition.TOP, + flushbarStyle: FlushbarStyle.GROUNDED, + duration: Duration(seconds: Constants.snackBarDurationInSecond), + backgroundColor: ColorName.raisinBlack, + titleText: Text( + AppLocalizations.of(context)!.resetPasswordSuccessTitle, + style: Theme.of(context).textTheme.bodyText2?.copyWith( + fontWeight: FontWeight.w800, + ), + ), + messageText: Text( + AppLocalizations.of(context)!.resetPasswordSuccessDescription, + style: Theme.of(context).textTheme.subtitle1?.copyWith( + fontWeight: FontWeight.w400, + ), + ), + icon: Assets.images.icNotification.svg(), + ).show(context); + } + + void _showError(String errorMessage) { + ScaffoldMessenger.of(context).showSnackBar(SnackBar( + duration: const Duration(seconds: Constants.snackBarDurationInSecond), + content: Text(errorMessage), + )); + } + + void _hideKeyboard() { + FocusScopeNode currentFocus = FocusScope.of(context); + if (!currentFocus.hasPrimaryFocus && currentFocus.focusedChild != null) { + FocusManager.instance.primaryFocus?.unfocus(); + } + } + + @override + void dispose() { + _emailController.dispose(); + super.dispose(); + } } diff --git a/lib/page/resetpassword/reset_password_state.dart b/lib/page/resetpassword/reset_password_state.dart new file mode 100644 index 0000000..eb0eeba --- /dev/null +++ b/lib/page/resetpassword/reset_password_state.dart @@ -0,0 +1,16 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'reset_password_state.freezed.dart'; + +@freezed +class ResetPasswordState with _$ResetPasswordState { + const factory ResetPasswordState.init() = _Init; + + const factory ResetPasswordState.loading() = _Loading; + + const factory ResetPasswordState.success() = _Success; + + const factory ResetPasswordState.apiError(String errorMessage) = _Error; + + const factory ResetPasswordState.invalidInputError() = _InvalidInputError; +} diff --git a/lib/page/resetpassword/reset_password_view_model.dart b/lib/page/resetpassword/reset_password_view_model.dart new file mode 100644 index 0000000..4cfe3b2 --- /dev/null +++ b/lib/page/resetpassword/reset_password_view_model.dart @@ -0,0 +1,27 @@ +import 'package:email_validator/email_validator.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:survey/page/resetpassword/reset_password_state.dart'; +import 'package:survey/usecase/base/base_use_case.dart'; +import 'package:survey/usecase/reset_password_use_case.dart'; + +class ResetPasswordViewModel extends StateNotifier { + final ResetPasswordUseCase _resetPasswordUseCase; + + ResetPasswordViewModel(this._resetPasswordUseCase) + : super(const ResetPasswordState.init()); + + void resetPassword(String email) async { + state = const ResetPasswordState.loading(); + if (EmailValidator.validate(email)) { + Result result = await _resetPasswordUseCase.call(email); + if (result is Success) { + state = const ResetPasswordState.success(); + } else { + state = + ResetPasswordState.apiError((result as Failed).getErrorMessage()); + } + } else { + state = const ResetPasswordState.invalidInputError(); + } + } +} diff --git a/lib/resource/dimens.dart b/lib/resource/dimens.dart index ad6290e..12d4bba 100644 --- a/lib/resource/dimens.dart +++ b/lib/resource/dimens.dart @@ -22,6 +22,8 @@ class Dimens { static const double roundedButtonBorderRadius = 10; static const double roundedButtonHeight = 56; + static const double circularProgressBarBackgroundSize = 56; + static const double homeSurveysIndicatorsSize = 8; static const double homeSurveysNextButtonSize = 56; static const double homeUserAvatarSize = 36; diff --git a/lib/widget/app_bar_back_button_widget.dart b/lib/widget/app_bar_back_button_widget.dart index 37bdaf8..122a9bd 100644 --- a/lib/widget/app_bar_back_button_widget.dart +++ b/lib/widget/app_bar_back_button_widget.dart @@ -12,7 +12,7 @@ class AppBarBackButtonWidget extends StatelessWidget { Widget build(BuildContext context) { return SafeArea( child: IconButton( - icon: Assets.images.icBackArrow.svg(), + icon: Assets.images.icArrowBack.svg(), padding: EdgeInsets.zero, constraints: BoxConstraints(), onPressed: () => _appNavigator.navigateBack(context), diff --git a/lib/widget/circular_progress_bar_widget.dart b/lib/widget/circular_progress_bar_widget.dart deleted file mode 100644 index 65fca3b..0000000 --- a/lib/widget/circular_progress_bar_widget.dart +++ /dev/null @@ -1,17 +0,0 @@ -import 'package:flutter/material.dart'; - -class CircularProgressBarWidget extends StatelessWidget { - const CircularProgressBarWidget({super.key}); - - @override - Widget build(BuildContext context) { - return Container( - width: double.infinity, - height: double.infinity, - color: Colors.transparent, - child: const Center( - child: CircularProgressIndicator(color: Colors.white), - ), - ); - } -} diff --git a/lib/widget/loading_indicator_widget.dart b/lib/widget/loading_indicator_widget.dart new file mode 100644 index 0000000..dfb9b12 --- /dev/null +++ b/lib/widget/loading_indicator_widget.dart @@ -0,0 +1,39 @@ +import 'package:flutter/material.dart'; +import 'package:loading_indicator/loading_indicator.dart'; +import 'package:survey/resource/dimens.dart'; + +class LoadingIndicatorWidget extends StatelessWidget { + final bool shouldIgnoreOtherGestures; + + const LoadingIndicatorWidget({ + Key? key, + this.shouldIgnoreOtherGestures = true, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + return shouldIgnoreOtherGestures + ? Container( + width: double.infinity, + height: double.infinity, + color: Colors.transparent, + child: _buildLoadingIndicator(), + ) + : _buildLoadingIndicator(); + } + + Widget _buildLoadingIndicator() { + return Center( + child: Container( + padding: EdgeInsets.all(Dimens.space12), + width: Dimens.circularProgressBarBackgroundSize, + height: Dimens.circularProgressBarBackgroundSize, + color: Colors.black54, + child: LoadingIndicator( + indicatorType: Indicator.lineSpinFadeLoader, + colors: const [Colors.white], + ), + ), + ); + } +} diff --git a/pubspec.lock b/pubspec.lock index e8cdadb..75e9325 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -15,6 +15,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "5.2.0" + another_flushbar: + dependency: "direct main" + description: + name: another_flushbar + url: "https://pub.dartlang.org" + source: hosted + version: "1.12.29" args: dependency: transitive description: @@ -413,6 +420,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "1.0.1" + loading_indicator: + dependency: "direct main" + description: + name: loading_indicator + url: "https://pub.dartlang.org" + source: hosted + version: "3.1.0" logging: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 47c4d30..9052bca 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -21,6 +21,7 @@ environment: sdk: ">=2.17.0 <3.0.0" dependencies: + another_flushbar: ^1.12.29 dio: ^4.0.6 email_validator: ^2.1.17 equatable: ^2.0.5 @@ -39,6 +40,7 @@ dependencies: intl: ^0.17.0 japx: ^2.0.4 json_annotation: ^4.6.0 + loading_indicator: ^3.1.0 page_view_dot_indicator: ^2.0.1 retrofit: ^3.0.1+1 rxdart: ^0.27.7 diff --git a/test/mock/mock_dependencies.dart b/test/mock/mock_dependencies.dart index 55706c0..e3a3699 100644 --- a/test/mock/mock_dependencies.dart +++ b/test/mock/mock_dependencies.dart @@ -14,6 +14,7 @@ import 'package:survey/usecase/get_cached_surveys_use_case.dart'; import 'package:survey/usecase/get_surveys_use_case.dart'; import 'package:survey/usecase/get_user_use_case.dart'; import 'package:survey/usecase/login_use_case.dart'; +import 'package:survey/usecase/reset_password_use_case.dart'; @GenerateNiceMocks([ MockSpec(), @@ -28,6 +29,7 @@ import 'package:survey/usecase/login_use_case.dart'; MockSpec(), MockSpec(), MockSpec(), + MockSpec(), MockSpec(), MockSpec(), MockSpec(), diff --git a/test/page/resetpassword/reset_password_view_model_test.dart b/test/page/resetpassword/reset_password_view_model_test.dart new file mode 100644 index 0000000..66808b6 --- /dev/null +++ b/test/page/resetpassword/reset_password_view_model_test.dart @@ -0,0 +1,93 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mockito/mockito.dart'; +import 'package:survey/api/exception/network_exceptions.dart'; +import 'package:survey/page/resetpassword/reset_password_page.dart'; +import 'package:survey/page/resetpassword/reset_password_state.dart'; +import 'package:survey/page/resetpassword/reset_password_view_model.dart'; +import 'package:survey/usecase/base/base_use_case.dart'; + +import '../../mock/mock_dependencies.mocks.dart'; + +void main() { + group('ResetPasswordViewModelTest', () { + late MockResetPasswordUseCase mockResetPasswordUseCase; + late ProviderContainer providerContainer; + late ResetPasswordViewModel resetPasswordViewModel; + + setUp(() { + mockResetPasswordUseCase = MockResetPasswordUseCase(); + + providerContainer = ProviderContainer( + overrides: [ + resetPasswordViewModelProvider.overrideWithValue( + ResetPasswordViewModel(mockResetPasswordUseCase)), + ], + ); + addTearDown(providerContainer.dispose); + resetPasswordViewModel = + providerContainer.read(resetPasswordViewModelProvider.notifier); + }); + + test('When initializing, it initializes with Init state', () { + expect( + providerContainer.read(resetPasswordViewModelProvider), + const ResetPasswordState.init(), + ); + }); + + test( + 'When calling reset password with Success result, it returns Success state', + () { + when(mockResetPasswordUseCase.call(any)) + .thenAnswer((_) async => Success(null)); + final stateStream = resetPasswordViewModel.stream; + + expect( + stateStream, + emitsInOrder([ + const ResetPasswordState.loading(), + const ResetPasswordState.success(), + ])); + + resetPasswordViewModel.resetPassword('chorny@berlento.com'); + }); + + test( + 'When calling reset password with invalid email, it returns InvalidInputError state', + () { + final stateStream = resetPasswordViewModel.stream; + + expect( + stateStream, + emitsInOrder([ + const ResetPasswordState.loading(), + const ResetPasswordState.invalidInputError(), + ])); + + resetPasswordViewModel.resetPassword('Chorny'); + }); + + test( + 'When calling reset password with Failed result, it returns ApiError state with corresponding errorMessage', + () { + final mockException = MockUseCaseException(); + when(mockException.actualException) + .thenReturn(NetworkExceptions.badRequest()); + when(mockResetPasswordUseCase.call(any)) + .thenAnswer((_) async => Failed(mockException)); + final stateStream = resetPasswordViewModel.stream; + + expect( + stateStream, + emitsInOrder([ + const ResetPasswordState.loading(), + ResetPasswordState.apiError( + NetworkExceptions.getErrorMessage(NetworkExceptions.badRequest()), + ), + ])); + + resetPasswordViewModel.resetPassword('chorny@berlento.com'); + }); + }); +}