diff --git a/lib/blocs/blocs.dart b/lib/blocs/blocs.dart index c3692b0..49a1dac 100644 --- a/lib/blocs/blocs.dart +++ b/lib/blocs/blocs.dart @@ -12,3 +12,7 @@ export 'favorite_manga_bloc.dart'; export 'history_manga_bloc.dart'; export 'user_bloc.dart'; export 'feedback_bloc.dart'; + +// Cubit +export 'email_verification_cubit.dart'; +export 'forgot_password_cubit.dart'; diff --git a/lib/blocs/event_states/event_states.dart b/lib/blocs/event_states/event_states.dart index 8ff660e..a108021 100644 --- a/lib/blocs/event_states/event_states.dart +++ b/lib/blocs/event_states/event_states.dart @@ -7,6 +7,7 @@ export 'chapter_image_state.dart'; export 'user_event.dart'; export 'user_state.dart'; export 'user_email_state.dart'; +export 'user_password_state.dart'; // Manga export 'manga/popular_manga_event.dart'; diff --git a/lib/blocs/event_states/user_password_state.dart b/lib/blocs/event_states/user_password_state.dart new file mode 100644 index 0000000..94b6737 --- /dev/null +++ b/lib/blocs/event_states/user_password_state.dart @@ -0,0 +1,5 @@ +import 'package:manga_nih/blocs/event_states/user_state.dart'; + +class UserForgotPasswordSend extends UserState {} + +class UserForgotPasswordSuccess extends UserState {} diff --git a/lib/blocs/forgot_password_cubit.dart b/lib/blocs/forgot_password_cubit.dart new file mode 100644 index 0000000..6fdb6d5 --- /dev/null +++ b/lib/blocs/forgot_password_cubit.dart @@ -0,0 +1,79 @@ +import 'dart:developer'; + +import 'package:firebase_auth/firebase_auth.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:manga_nih/blocs/event_states/event_states.dart'; +import 'package:manga_nih/core/core.dart'; +import 'package:manga_nih/models/models.dart'; + +class ForgotPasswordCubit extends Cubit { + final FirebaseAuth _firebaseAuth = FirebaseAuth.instance; + + ForgotPasswordCubit() : super(UserUninitialized()); + + Future sendForgotPassword(String email) async { + try { + emit(UserLoading()); + + Uri uri = Uri.https(Constants.webDomain, '', { + 'time': DateTime.now().millisecondsSinceEpoch.toString(), + }); + + await _firebaseAuth.sendPasswordResetEmail( + email: email, + actionCodeSettings: ActionCodeSettings( + url: uri.toString(), + androidPackageName: Constants.androidPackage, + dynamicLinkDomain: Constants.dynamicLink, + handleCodeInApp: true, + androidInstallApp: true, + iOSBundleId: Constants.iosPackage, + ), + ); + + emit(UserForgotPasswordSend()); + } on FirebaseAuthException catch (e) { + log(e.toString(), name: 'ForgotPasswordCubit - sendForgotPassword'); + + if (e.code == 'user-not-found') { + SnackbarModel.custom(true, 'User not found, try again'); + } + + emit(UserError()); + } catch (e) { + log(e.toString(), name: 'ForgotPasswordCubit - sendForgotPassword'); + + SnackbarModel.globalError(); + + emit(UserError()); + } + } + + Future verifyCode(String code, String password) async { + try { + emit(UserLoading()); + + await _firebaseAuth.confirmPasswordReset( + code: code, + newPassword: password, + ); + + emit(UserForgotPasswordSuccess()); + } on FirebaseAuthException catch (e) { + log(e.toString(), name: 'ForgotPasswordCubit - verifyCode'); + + if (e.code == 'invalid-action-code') { + SnackbarModel.custom( + true, 'Invalid code, please resend verification email'); + } + + emit(UserError()); + } catch (e) { + log(e.toString(), name: 'ForgotPasswordCubit - verifyCode'); + + SnackbarModel.globalError(); + + emit(UserError()); + } + } +} diff --git a/lib/main.dart b/lib/main.dart index 5658de1..aee2f85 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -8,7 +8,6 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:get/get.dart'; import 'package:google_fonts/google_fonts.dart'; import 'package:manga_nih/blocs/blocs.dart'; -import 'package:manga_nih/blocs/email_verification_cubit.dart'; import 'package:manga_nih/core/core.dart'; import 'package:manga_nih/ui/screens/screens.dart'; @@ -22,6 +21,10 @@ void _foregroundDynamicLink(PendingDynamicLinkData? onData) { emailCubit.verifyCode(code); } + + if (code != null && mode == 'resetPassword') { + Get.to(() => ResetPasswordScreen(code: code)); + } } void main() async { diff --git a/lib/ui/screens/auth/forgot_password_screen.dart b/lib/ui/screens/auth/forgot_password_screen.dart new file mode 100644 index 0000000..d2176ca --- /dev/null +++ b/lib/ui/screens/auth/forgot_password_screen.dart @@ -0,0 +1,133 @@ +import 'package:email_validator/email_validator.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:manga_nih/blocs/blocs.dart'; +import 'package:manga_nih/models/models.dart'; +import 'package:manga_nih/ui/configs/pallette.dart'; +import 'package:manga_nih/blocs/event_states/event_states.dart'; +import 'package:manga_nih/ui/widgets/widgets.dart'; + +class ForgotPasswordScreen extends StatefulWidget { + const ForgotPasswordScreen({Key? key}) : super(key: key); + + @override + _ForgotPasswordScreenState createState() => _ForgotPasswordScreenState(); +} + +class _ForgotPasswordScreenState extends State { + final GlobalKey _key = GlobalKey(); + final TextEditingController _emailController = TextEditingController(); + final ForgotPasswordCubit _forgotPasswordCubit = ForgotPasswordCubit(); + String? _emailErrorText; + + @override + void initState() { + // init bloc + + super.initState(); + } + + @override + void dispose() { + _emailController.dispose(); + + super.dispose(); + } + + void _sendCodeAction() { + if (_key.currentState!.validate()) { + // re-init error + setState(() { + _emailErrorText = null; + }); + + String email = _emailController.text.trim(); + + if (!EmailValidator.validate(email)) { + setState(() => _emailErrorText = 'Email invalid'); + } else { + _forgotPasswordCubit.sendForgotPassword(email); + } + } + } + + void _blocListener(BuildContext context, UserState userState) { + if (userState is UserForgotPasswordSend) { + SnackbarModel.custom(false, 'Check your email'); + } + } + + @override + Widget build(BuildContext context) { + final Size screenSize = MediaQuery.of(context).size; + + return BlocProvider( + create: (_) => _forgotPasswordCubit, + child: SafeArea( + child: Scaffold( + body: Center( + child: ListView( + physics: const BouncingScrollPhysics(), + shrinkWrap: true, + children: [ + Center( + child: Container( + padding: const EdgeInsets.symmetric( + vertical: 20.0, horizontal: 10.0), + width: screenSize.width * 0.9, + child: Form( + key: _key, + child: Column( + children: [ + Align( + alignment: Alignment.topLeft, + child: Text( + 'Forgot Password', + style: Theme.of(context) + .textTheme + .headline5! + .copyWith(color: Pallette.gradientEndColor), + ), + ), + const SizedBox(height: 5.0), + Align( + alignment: Alignment.topLeft, + child: Text( + 'Input your valid email', + style: TextStyle(color: Colors.grey.shade700), + ), + ), + const SizedBox(height: 25.0), + InputField( + icon: Icons.email, + hintText: 'Email', + controller: _emailController, + errorText: _emailErrorText, + ), + const SizedBox(height: 25.0), + BlocConsumer( + listener: _blocListener, + builder: (context, state) { + if (state is UserLoading) { + return const PrimaryButton.loading(); + } + + return PrimaryButton( + label: 'Send code', + onTap: _sendCodeAction, + ); + }, + ), + ], + ), + ), + ), + ), + ], + ), + ), + ), + ), + ); + } +} diff --git a/lib/ui/screens/auth/login_screen.dart b/lib/ui/screens/auth/login_screen.dart index 7d26e56..c56df59 100644 --- a/lib/ui/screens/auth/login_screen.dart +++ b/lib/ui/screens/auth/login_screen.dart @@ -63,6 +63,10 @@ class _LoginScreenState extends State { } } + void _forgotPasswordAction() { + Get.to(() => ForgotPasswordCubit()); + } + void _registerAction() { Get.to(() => const RegisterScreen()); } @@ -89,7 +93,9 @@ class _LoginScreenState extends State { Center( child: Container( padding: const EdgeInsets.symmetric( - vertical: 20.0, horizontal: 10.0), + vertical: 20.0, + horizontal: 10.0, + ), width: screenSize.width * 0.9, child: Form( key: _key, @@ -110,10 +116,7 @@ class _LoginScreenState extends State { alignment: Alignment.topLeft, child: Text( 'Hello, welcome back to Manga nih', - style: Theme.of(context) - .textTheme - .bodyText1! - .copyWith(color: Colors.grey.shade700), + style: TextStyle(color: Colors.grey.shade700), ), ), const SizedBox(height: 25.0), @@ -131,6 +134,19 @@ class _LoginScreenState extends State { controller: _passwordController, errorText: _passwordErrorText, ), + const SizedBox(height: 10.0), + Align( + alignment: Alignment.centerLeft, + child: TextButton( + onPressed: _forgotPasswordAction, + child: Text( + 'Forgot password', + style: TextStyle( + color: Pallette.gradientStartColor, + ), + ), + ), + ), const SizedBox(height: 25.0), BlocConsumer( listener: _blocListener, @@ -159,11 +175,8 @@ class _LoginScreenState extends State { onPressed: _registerAction, child: Text( 'Create an account', - style: Theme.of(context) - .textTheme - .bodyText1! - .copyWith( - color: Pallette.gradientStartColor), + style: TextStyle( + color: Pallette.gradientStartColor), ), ), ], diff --git a/lib/ui/screens/auth/register_screen.dart b/lib/ui/screens/auth/register_screen.dart index a64b74f..3111bcb 100644 --- a/lib/ui/screens/auth/register_screen.dart +++ b/lib/ui/screens/auth/register_screen.dart @@ -121,10 +121,7 @@ class _RegisterScreenState extends State { alignment: Alignment.topLeft, child: Text( 'Register a new account to read manga', - style: Theme.of(context) - .textTheme - .bodyText1! - .copyWith(color: Colors.grey.shade700), + style: TextStyle(color: Colors.grey.shade700), ), ), const SizedBox(height: 25.0), @@ -184,11 +181,8 @@ class _RegisterScreenState extends State { onPressed: _loginAction, child: Text( 'Login an account', - style: Theme.of(context) - .textTheme - .bodyText1! - .copyWith( - color: Pallette.gradientStartColor), + style: TextStyle( + color: Pallette.gradientStartColor), ), ), ], diff --git a/lib/ui/screens/auth/reset_password_screen.dart b/lib/ui/screens/auth/reset_password_screen.dart new file mode 100644 index 0000000..946d4f8 --- /dev/null +++ b/lib/ui/screens/auth/reset_password_screen.dart @@ -0,0 +1,179 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:get/get.dart'; +import 'package:manga_nih/blocs/blocs.dart'; +import 'package:manga_nih/models/models.dart'; +import 'package:manga_nih/ui/configs/pallette.dart'; +import 'package:manga_nih/blocs/event_states/event_states.dart'; +import 'package:manga_nih/ui/screens/screens.dart'; +import 'package:manga_nih/ui/widgets/widgets.dart'; + +class ResetPasswordScreen extends StatefulWidget { + final String code; + + const ResetPasswordScreen({ + Key? key, + required this.code, + }) : super(key: key); + + @override + _ResetPasswordScreenState createState() => _ResetPasswordScreenState(); +} + +class _ResetPasswordScreenState extends State { + final GlobalKey _key = GlobalKey(); + final TextEditingController _passwordController = TextEditingController(); + final TextEditingController _confirmPasswordController = + TextEditingController(); + final ForgotPasswordCubit _forgotPasswordCubit = ForgotPasswordCubit(); + String? _passwordErrorText; + String? _confirmPasswordErrorText; + + @override + void initState() { + // init bloc + + super.initState(); + } + + @override + void dispose() { + _passwordController.dispose(); + _confirmPasswordController.dispose(); + + super.dispose(); + } + + void _submitAction() { + if (_key.currentState!.validate()) { + // re-init error + setState(() { + _passwordErrorText = null; + _confirmPasswordErrorText = null; + }); + + bool doResetPasswordScreen = true; + String password = _passwordController.text.trim(); + String confirmPassword = _confirmPasswordController.text.trim(); + + if (password.length < 6) { + doResetPasswordScreen = false; + + setState(() => + _passwordErrorText = 'Password should be at least 6 characters'); + } + + if (confirmPassword.length < 6) { + doResetPasswordScreen = false; + + setState(() => _confirmPasswordErrorText = + 'Confirm password should be at least 6 characters'); + } + + if (password != confirmPassword) { + doResetPasswordScreen = false; + + setState(() { + _passwordErrorText = 'Password must be same'; + _confirmPasswordErrorText = 'Password must be same'; + }); + } + + if (doResetPasswordScreen) { + // reset password + _forgotPasswordCubit.verifyCode(widget.code, password); + } + } + } + + void _blocListener(BuildContext context, UserState userState) { + if (userState is UserForgotPasswordSuccess) { + SnackbarModel.custom(false, 'Success reset password, please login'); + + Get.offAll(() => const LoginScreen()); + } + } + + @override + Widget build(BuildContext context) { + final Size screenSize = MediaQuery.of(context).size; + + return BlocProvider( + create: (_) => _forgotPasswordCubit, + child: SafeArea( + child: Scaffold( + body: Center( + child: ListView( + physics: const BouncingScrollPhysics(), + shrinkWrap: true, + children: [ + Center( + child: Container( + padding: const EdgeInsets.symmetric( + vertical: 20.0, horizontal: 10.0), + width: screenSize.width * 0.9, + child: Form( + key: _key, + child: Column( + children: [ + Align( + alignment: Alignment.topLeft, + child: Text( + 'Forgot Password', + style: Theme.of(context) + .textTheme + .headline5! + .copyWith(color: Pallette.gradientEndColor), + ), + ), + const SizedBox(height: 5.0), + Align( + alignment: Alignment.topLeft, + child: Text( + 'Recover your password, to start reading', + style: TextStyle(color: Colors.grey.shade700), + ), + ), + const SizedBox(height: 25.0), + InputField( + icon: Icons.vpn_key, + hintText: 'Password', + isPassword: true, + controller: _passwordController, + errorText: _passwordErrorText, + ), + const SizedBox(height: 10.0), + InputField( + icon: Icons.vpn_key, + hintText: 'Confirm Password', + isPassword: true, + controller: _confirmPasswordController, + errorText: _confirmPasswordErrorText, + ), + const SizedBox(height: 25.0), + BlocConsumer( + listener: _blocListener, + builder: (context, state) { + if (state is UserLoading) { + return const PrimaryButton.loading(); + } + + return PrimaryButton( + label: 'Submit', + onTap: _submitAction, + ); + }, + ), + ], + ), + ), + ), + ), + ], + ), + ), + ), + ), + ); + } +} diff --git a/lib/ui/screens/profile/profile_screen.dart b/lib/ui/screens/profile/profile_screen.dart index 59c9a7b..293ef95 100644 --- a/lib/ui/screens/profile/profile_screen.dart +++ b/lib/ui/screens/profile/profile_screen.dart @@ -3,7 +3,6 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:get/get.dart'; import 'package:loading_indicator/loading_indicator.dart'; import 'package:manga_nih/blocs/blocs.dart'; -import 'package:manga_nih/blocs/email_verification_cubit.dart'; import 'package:manga_nih/blocs/event_states/event_states.dart'; import 'package:manga_nih/core/core.dart'; import 'package:manga_nih/models/models.dart'; diff --git a/lib/ui/screens/screens.dart b/lib/ui/screens/screens.dart index 907680d..1193ec1 100644 --- a/lib/ui/screens/screens.dart +++ b/lib/ui/screens/screens.dart @@ -9,6 +9,8 @@ export 'list_favorite_history_screen.dart'; // Auth export 'auth/login_screen.dart'; export 'auth/register_screen.dart'; +export 'auth/forgot_password_screen.dart'; +export 'auth/reset_password_screen.dart'; // Profile export 'profile/profile_screen.dart'; diff --git a/lib/ui/widgets/input_field.dart b/lib/ui/widgets/input_field.dart index 8899eac..9a103d8 100644 --- a/lib/ui/widgets/input_field.dart +++ b/lib/ui/widgets/input_field.dart @@ -56,7 +56,7 @@ class _InputFieldState extends State { textInputAction: widget.textInputAction, validator: (value) { if (value!.isEmpty) { - return 'fill this field'; + return 'Fill this field'; } return null; diff --git a/pubspec.yaml b/pubspec.yaml index a43068e..33b01c1 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -15,7 +15,7 @@ publish_to: "none" # Remove this line if you wish to publish to pub.dev # In iOS, build-name is used as CFBundleShortVersionString while build-number used as CFBundleVersion. # Read more about iOS versioning at # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html -version: 1.13.0+4 +version: 1.14.0+5 environment: sdk: ">=2.16.1 <3.0.0"