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');
+ });
+ });
+}