diff --git a/assets/icons/ic_heroicons_outline.svg b/assets/icons/ic_heroicons_outline.svg new file mode 100644 index 00000000..252c1a5a --- /dev/null +++ b/assets/icons/ic_heroicons_outline.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/icons/ic_mail.svg b/assets/icons/ic_mail.svg new file mode 100644 index 00000000..a7f4e69c --- /dev/null +++ b/assets/icons/ic_mail.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/assets/icons/ic_square_lock.svg b/assets/icons/ic_square_lock.svg new file mode 100644 index 00000000..6a663ccc --- /dev/null +++ b/assets/icons/ic_square_lock.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/assets/icons/ic_user_square.svg b/assets/icons/ic_user_square.svg new file mode 100644 index 00000000..5e88a421 --- /dev/null +++ b/assets/icons/ic_user_square.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/lib/core/l10n/app_ar.arb b/lib/core/l10n/app_ar.arb index 4e968d56..7bda3347 100644 --- a/lib/core/l10n/app_ar.arb +++ b/lib/core/l10n/app_ar.arb @@ -12,24 +12,31 @@ "moneyAmount": "{amount} {currency} ", "date": "التاريخ", "setUpYourAccount": "لنقم بإعداد حسابك", - "setSalary": "تحديد الراتب", - "currency": "العملة", - "salaryDay": "يوم الراتب", - "fromEachMonth": "من كل شهر", - "salary": "الراتب", - "accountSetup": "إعداد الحساب", - "search": "...بحث", - "select": "اختيار", - "next": "التالي", - "finishSetup": "إنهاء الإعداد", - "stepOf": "الخطوة {current} من {total}", - "stepOfTotal": "الخطوة {current} من {total}", + "setSalary": "تحديد الراتب", + "currency": "العملة", + "salaryDay": "يوم الراتب", + "fromEachMonth": "من كل شهر", + "salary": "الراتب", + "accountSetup": "إعداد الحساب", + "search": "...بحث", + "select": "اختيار", + "next": "التالي", + "finishSetup": "إنهاء الإعداد", + "stepOf": "الخطوة {current} من {total}", + "stepOfTotal": "الخطوة {current} من {total}", + "createAccount": "إنشاء حساب", + "createNewAccount": "إنشاء حساب جديد", + "startTakingControl": "ابدأ بالتحكم في أموالك", + "email": "البريد الإلكتروني", + "name": "الاسم", + "password": "كلمة المرور", + "passwordLimit": "*استخدم 8 أحرف على الأقل، وتأكد من وجود حرف كبير ورمز واحد على الأقل.", + "create": "إنشاء حساب", "forgetPasswordAppBarTitle": "نسيت كلمة السر", "forgetPasswordTitle": "نسيت كلمة المرور الخاصة بك", "forgetPasswordSubtitle": "أدخل بريدك الإلكتروني وسنرسل لك رابطًا لإعادة التعيين", "forgetPasswordEmailHint": "البريد الإلكتروني", "forgetPasswordButton": "إرسال رابط إعادة التعيين", - "updatePasswordAppBarTitle": "تحديث كلمة المرور", "updatePasswordTitle": "أدخل كلمة مرور جديدة لـ", "updatePasswordPasswordHint": "كلمة المرور الجديدة", diff --git a/lib/core/l10n/app_en.arb b/lib/core/l10n/app_en.arb index 01cd477d..8cb8c6a1 100644 --- a/lib/core/l10n/app_en.arb +++ b/lib/core/l10n/app_en.arb @@ -38,19 +38,36 @@ "next": "Next", "finishSetup": "Finish setup", "stepOf": "Step {current} of {total}", - "stepOfTotal": "Step {current} of {total}", - "@stepOfTotal": { - "placeholders": { - "current": {}, - "total": {} - } + "@stepOf": { + "placeholders": { + "current": { + "type": "int" }, + "total": { + "type": "int" + } + } + }, + "stepOfTotal": "Step {current} of {total}", + "@stepOfTotal": { + "placeholders": { + "current": {}, + "total": {} + } + }, + "createAccount": "Create Account", + "createNewAccount": "Create new account", + "startTakingControl": "Start taking control of your money", + "email": "Email", + "name": "Name", + "password": "Password", + "passwordLimit": "*Use at least 8 characters, Contain at least one capital letter and one symbol.", + "create": "Create", "forgetPasswordAppBarTitle": "Forget Password", "forgetPasswordTitle": "Forget your password", "forgetPasswordSubtitle": "Enter your email and we’ll send you a reset link", "forgetPasswordEmailHint": "Email", "forgetPasswordButton": "Send Reset Link", - "updatePasswordAppBarTitle": "Update Password", "updatePasswordTitle": "Enter a new password for", "updatePasswordPasswordHint": "New Password", @@ -58,7 +75,6 @@ "updatePasswordButton": "Update Password", "updatePasswordSuccessMessage": "Password updated successfully", "updatePasswordErrorMessage": "Error updating password", - "error": "Error", "success" : "Success", "loading" : "Loading", diff --git a/lib/data/repository/authentication_repository.dart b/lib/data/repository/authentication_repository.dart index 35d61a35..f705ec75 100644 --- a/lib/data/repository/authentication_repository.dart +++ b/lib/data/repository/authentication_repository.dart @@ -1,4 +1,3 @@ - import 'dart:developer'; import 'package:flutter/foundation.dart'; @@ -9,8 +8,8 @@ import '../../core/constants/app_constants.dart'; import '../../core/errors/error_model.dart'; import '../../core/errors/result.dart'; import '../../core/errors/supabase_auth_error.dart'; -import '../../domain/repository/authentication_repository.dart'; import '../../domain/entity/user.dart'; +import '../../domain/repository/authentication_repository.dart'; import '../service/app_secrets_provider.dart'; import '../service/supabase_service.dart'; @@ -23,6 +22,31 @@ class AuthenticationRepositoryImpl implements AuthenticationRepository { required this.appSecrets, }); + @override + Future> register(User user, String password) async { + try { + final client = await supabaseService.getClient(); + final response = await client.auth.signUp( + email: user.email, + password: password, + data: {"name": user.name, "is_complete": false}, + ); + + if (response.user != null) { + return Result.success(null); + } else { + return Result.error(ErrorModel('User data is null')); + } + } on AuthException catch (error) { + return Result.error(SupabaseAuthError.fromAuthException(error)); + } catch (error) { + if (kDebugMode) { + print('Caught error during register: $error'); + } + rethrow; + } + } + @override void signInWithGoogle() async { try { @@ -62,6 +86,7 @@ class AuthenticationRepositoryImpl implements AuthenticationRepository { static const String _googleWebClientId = "GOOGLE_WEB_CLIENT_ID"; static const String _googleIosClientId = "GOOGLE_IOS_CLIENT_ID"; static const List _googleScopes = ['email', 'profile', 'openid']; + @override Stream get onAuthStateChange { final supabaseClientFuture = supabaseService.getClient(); @@ -78,6 +103,7 @@ class AuthenticationRepositoryImpl implements AuthenticationRepository { redirectTo: AppConstants.resetPasswordRedirect, ); } + @override Future updatePassword(String password) async { final client = await supabaseService.getClient(); @@ -111,4 +137,4 @@ class AuthenticationRepositoryImpl implements AuthenticationRepository { return Result.error(ErrorModel(error.toString())); } } -} \ No newline at end of file +} diff --git a/lib/design_system/assets/app_assets.dart b/lib/design_system/assets/app_assets.dart index 5aefe23a..b858ce94 100644 --- a/lib/design_system/assets/app_assets.dart +++ b/lib/design_system/assets/app_assets.dart @@ -45,8 +45,9 @@ class AppAssets { static const String eyeClose = "$_icons/ic_eye_close.svg"; static const String eyeOpen = "$_icons/ic_eye_open.svg"; static const String lock = "$_icons/ic_lock.svg"; - static const String email = "$_icons/ic_email.svg"; static const String google = "$_icons/ic_google.svg"; + static const String icSquareLock = '$_icons/ic_square_lock.svg'; + static const String icUser = '$_icons/ic_user_square.svg'; static const icMoneyAmount = '$_icons/ic_money_amount.svg'; static const String imgForgetPasswordLock = "$_icons/img_forget_password_lock.png"; static const String icEmail = "$_icons/ic_email.svg"; diff --git a/lib/di/injection.dart b/lib/di/injection.dart index cef2fab7..d64a7d6a 100644 --- a/lib/di/injection.dart +++ b/lib/di/injection.dart @@ -13,6 +13,7 @@ import '../domain/repository/account_repository.dart'; import '../domain/repository/authentication_repository.dart'; import '../domain/repository/user_money_repository.dart'; import '../domain/validator/authentication_validator.dart'; +import '../presentation/createAccount/cubit/create_account_cubit.dart'; import '../presentation/home/cubit/home_cubit.dart'; import '../presentation/income/cubit/add_income_cubit.dart'; import '../presentation/login/cubit/login_cubit.dart'; @@ -25,7 +26,6 @@ void initDI() { () => SupabaseService(appSecretsProvider: getIt()), ); - getIt.registerLazySingleton( () => AuthenticationRepositoryImpl( supabaseService: getIt(), @@ -69,4 +69,10 @@ void initDI() { () => TransactionCubit(transactionRepository: getIt()), ); + getIt.registerFactory( + () => CreateAccountCubit( + getIt(), + getIt(), + ), + ); } diff --git a/lib/domain/entity/user.dart b/lib/domain/entity/user.dart index b9d6af29..c537cdda 100644 --- a/lib/domain/entity/user.dart +++ b/lib/domain/entity/user.dart @@ -1,7 +1,7 @@ class User { final String id; - final String email; final String name; + final String email; const User({ required this.id, diff --git a/lib/domain/repository/authentication_repository.dart b/lib/domain/repository/authentication_repository.dart index 3074da9f..da61734d 100644 --- a/lib/domain/repository/authentication_repository.dart +++ b/lib/domain/repository/authentication_repository.dart @@ -1,3 +1,5 @@ +import '../entity/user.dart' as user_entity; + import 'dart:async'; import 'package:supabase_flutter/supabase_flutter.dart' hide User; @@ -6,6 +8,7 @@ import '../../core/errors/result.dart'; import '../../domain/entity/user.dart'; abstract class AuthenticationRepository { + Future> register(user_entity.User user, String password); void signInWithGoogle(); Future resetPasswordForEmail(String email); diff --git a/lib/domain/validator/authentication_validator.dart b/lib/domain/validator/authentication_validator.dart index a21fdd61..e02eb6c6 100644 --- a/lib/domain/validator/authentication_validator.dart +++ b/lib/domain/validator/authentication_validator.dart @@ -1,9 +1,6 @@ class AuthenticationValidator { bool isEmailValid(String email) { - return email.trim().isNotEmpty && - RegExp( - r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$', - ).hasMatch(email); + return email.trim().isNotEmpty && RegExp(r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$').hasMatch(email); } bool isPasswordValid(String password) { diff --git a/lib/presentation/createAccount/cubit/create_account_cubit.dart b/lib/presentation/createAccount/cubit/create_account_cubit.dart new file mode 100644 index 00000000..3aa07554 --- /dev/null +++ b/lib/presentation/createAccount/cubit/create_account_cubit.dart @@ -0,0 +1,64 @@ +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:moneyplus/domain/repository/authentication_repository.dart'; + +import '../../../domain/validator/authentication_validator.dart'; +import 'create_account_state.dart'; + +class CreateAccountCubit extends Cubit { + final AuthenticationValidator _validator; + final AuthenticationRepository _authenticationRepository; + + CreateAccountCubit(this._validator, this._authenticationRepository) + : super(CreateAccountState()); + + void emailChanged(String value) { + emit(state.copyWith(email: value)); + enable(); + } + + void nameChanged(String value) { + emit(state.copyWith(name: value)); + enable(); + } + + void passwordChanged(String value) { + emit(state.copyWith(password: value)); + if (_validator.isPasswordValid(state.password)) { + enable(); + } else { + emit(state.copyWith(isEnabled: false)); + } + } + + void enable() { + if (_validator.isEmailValid(state.email) && + state.name.isNotEmpty && + _validator.isPasswordValid(state.password)) { + emit(state.copyWith(isEnabled: true)); + } else { + emit(state.copyWith(isEnabled: false)); + } + } + + Future submit() async { + emit(state.copyWith(isLoading: true)); + final result = await _authenticationRepository.register( + state.toEntity(), + state.password, + ); + result.when( + onSuccess: (user) { + emit(state.copyWith(isLoading: false)); + }, + onError: (error){ + emit(state.copyWith(isLoading: false)); + emit(state.copyWith(errorMessage: error.message)); + emit(state.copyWith(errorMessage: null)); + }, + ); + } + + void togglePasswordVisibility() { + emit(state.copyWith(isPasswordVisible: !state.isPasswordVisible)); + } +} diff --git a/lib/presentation/createAccount/cubit/create_account_state.dart b/lib/presentation/createAccount/cubit/create_account_state.dart new file mode 100644 index 00000000..4704908f --- /dev/null +++ b/lib/presentation/createAccount/cubit/create_account_state.dart @@ -0,0 +1,50 @@ +import '../../../domain/entity/user.dart'; + +class CreateAccountState { + final String email; + final String name; + final String password; + final bool isLoading; + final bool isEnabled; + final bool isPasswordVisible; + final String? errorMessage; + + const CreateAccountState({ + this.email = "", + this.name = "", + this.password = "", + this.isLoading = false, + this.isEnabled = false, + this.isPasswordVisible = false, + this.errorMessage, + }); + + CreateAccountState copyWith({ + String? email, + String? name, + String? password, + bool? isLoading, + bool? isEnabled, + bool? showPasswordRequirements, + bool? isPasswordVisible, + String? errorMessage, + }) { + return CreateAccountState( + email: email ?? this.email, + name: name ?? this.name, + password: password ?? this.password, + isLoading: isLoading ?? this.isLoading, + isEnabled: isEnabled ?? this.isEnabled, + isPasswordVisible: isPasswordVisible ?? this.isPasswordVisible, + errorMessage: errorMessage ?? this.errorMessage, + ); + } + + User toEntity() { + return User( + id: "", + email: email, + name: name, + ); + } +} diff --git a/lib/presentation/createAccount/screen/create_account_screen.dart b/lib/presentation/createAccount/screen/create_account_screen.dart new file mode 100644 index 00000000..7119d7d5 --- /dev/null +++ b/lib/presentation/createAccount/screen/create_account_screen.dart @@ -0,0 +1,189 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:moneyplus/design_system/assets/app_assets.dart'; +import 'package:moneyplus/design_system/widgets/app_logo.dart'; +import 'package:svg_flutter/svg.dart'; + +import '../../../core/l10n/app_localizations.dart'; +import '../../../design_system/theme/money_extension_context.dart'; +import '../../../design_system/widgets/app_bar.dart'; +import '../../../design_system/widgets/buttons/button/default_button.dart'; +import '../../../design_system/widgets/snack_bar.dart'; +import '../../../design_system/widgets/text_field.dart'; +import '../../../di/injection.dart'; +import '../../../domain/repository/authentication_repository.dart'; +import '../../../domain/validator/authentication_validator.dart'; +import '../cubit/create_account_cubit.dart'; +import '../cubit/create_account_state.dart'; + +class CreateAccountScreen extends StatefulWidget { + const CreateAccountScreen({super.key}); + + @override + State createState() => _CreateAccountScreenState(); +} + +class _CreateAccountScreenState extends State { + @override + Widget build(BuildContext context) { + final colors = context.colors; + final typography = context.typography; + final localizations = AppLocalizations.of(context)!; + + return BlocProvider( + create: (context) => CreateAccountCubit( + getIt(), + getIt(), + ), + child: BlocConsumer( + listener: (context, state) { + if (state.errorMessage != null) { + MSnackBar.error( + message: state.errorMessage!, + title: localizations.error, + ).showSnackBar(context: context); + } + }, + builder: (context, state) { + final cubit = context.read(); + return Scaffold( + appBar: CustomAppBar( + backgroundColor: colors.surfaceLow, + title: localizations.createAccount, + trailing: AppLogo(assetPath: AppAssets.appBrand), + leading: AppBarCircleButton( + assetPath: AppAssets.icArrowLeft, + onTap: () => Navigator.pop(context), + ), + ), + resizeToAvoidBottomInset: true, + body: Container( + color: colors.surface, + padding: const EdgeInsets.all(16), + child: SingleChildScrollView( + physics: BouncingScrollPhysics(), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + localizations.createNewAccount, + style: typography.headline.medium.copyWith( + color: colors.title, + ), + ), + const SizedBox(height: 4), + Text( + localizations.startTakingControl, + style: typography.body.small.copyWith(color: colors.body), + ), + const SizedBox(height: 24), + _textField( + hint: localizations.name, + value: state.name, + onChanged: cubit.nameChanged, + assetPath: AppAssets.icUser, + ), + const SizedBox(height: 12), + _textField( + hint: localizations.email, + value: state.email, + onChanged: cubit.emailChanged, + assetPath: AppAssets.icEmail, + ), + const SizedBox(height: 12), + _passwordTextField( + password: state.password, + hint: localizations.password, + isPasswordVisible: state.isPasswordVisible, + onPasswordChanged: cubit.passwordChanged, + onToggleVisibility: cubit.togglePasswordVisibility, + ), + const SizedBox(height: 8), + Text( + localizations.passwordLimit, + style: typography.label.small.copyWith( + color: colors.yellow, + ), + ), + ], + ), + ), + ), + + bottomNavigationBar: AnimatedPadding( + duration: const Duration(milliseconds: 150), + curve: Curves.easeOut, + padding: EdgeInsets.only( + bottom: MediaQuery.of(context).viewInsets.bottom + 16, + left: 16, + right: 16, + ), + child: SafeArea( + child: DefaultButton( + text: localizations.create, + onPressed: () => cubit.submit(), + isEnabled: state.isEnabled, + isLoading: state.isLoading, + ), + ), + ), + ); + }, + ), + ); + } + + Widget _textField({ + required String hint, + required String value, + required ValueChanged onChanged, + required String assetPath, + }) { + return MTextField( + hint: hint, + value: value, + onChanged: (value) => onChanged(value), + minLines: 1, + maxLines: 1, + leading: Padding( + padding: EdgeInsetsGeometry.symmetric(vertical: 14, horizontal: 8), + child: SvgPicture.asset(assetPath), + ), + ); + } + + Widget _passwordTextField({ + required String password, + required String hint, + required bool isPasswordVisible, + required ValueChanged onPasswordChanged, + required VoidCallback onToggleVisibility, + }) { + return MTextField( + hint: hint, + value: password, + onChanged: onPasswordChanged, + obscureText: !isPasswordVisible, + minLines: 1, + maxLines: 1, + leading: Padding( + padding: const EdgeInsetsDirectional.symmetric( + vertical: 14, + horizontal: 8, + ), + child: SvgPicture.asset(AppAssets.icSquareLock), + ), + trailing: Padding( + padding: EdgeInsetsGeometry.only(top: 2), + child: IconButton( + onPressed: onToggleVisibility, + icon: SvgPicture.asset( + isPasswordVisible ? AppAssets.openEye : AppAssets.closedEye, + height: 20, + width: 20, + ), + ), + ), + ); + } +} diff --git a/lib/presentation/login/screen/login_screen.dart b/lib/presentation/login/screen/login_screen.dart index 22cc16df..1d3c105a 100644 --- a/lib/presentation/login/screen/login_screen.dart +++ b/lib/presentation/login/screen/login_screen.dart @@ -223,7 +223,9 @@ class _SocialMediaButtons extends StatelessWidget { ), SizedBox(height: 8), MoneyButton( - onPressed: () {}, + onPressed: () { + CreateAccountRoute().push(context); + }, backgroundColor: colors.surfaceLow, disabledBackgroundColor: Colors.red, borderWidth: 0.5, diff --git a/lib/presentation/login/widget/login_form.dart b/lib/presentation/login/widget/login_form.dart index 3e0de06f..ddf918ed 100644 --- a/lib/presentation/login/widget/login_form.dart +++ b/lib/presentation/login/widget/login_form.dart @@ -34,7 +34,7 @@ class _LoginFormState extends State { hint: localizations.login_email_hint, leading: Padding( padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 8), - child: SvgPicture.asset(AppAssets.email), + child: SvgPicture.asset(AppAssets.icEmail), ), onChanged: widget.onEmailChanged, keyboardType: TextInputType.emailAddress, diff --git a/lib/presentation/navigation/routes.dart b/lib/presentation/navigation/routes.dart index 5a2cb58a..f64b0502 100644 --- a/lib/presentation/navigation/routes.dart +++ b/lib/presentation/navigation/routes.dart @@ -2,6 +2,8 @@ import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:go_router/go_router.dart'; +import 'package:moneyplus/presentation/createAccount/screen/create_account_screen.dart'; +import 'package:moneyplus/presentation/home/screen/home_screen.dart'; import 'package:moneyplus/presentation/login/screen/login_screen.dart'; import '../../di/injection.dart'; @@ -55,3 +57,14 @@ class MainRoute extends GoRouteData with $MainRoute { return const MainScreen(); } } + +@TypedGoRoute(path: '/createAccount') +@immutable +class CreateAccountRoute extends GoRouteData with $CreateAccountRoute { + const CreateAccountRoute(); + + @override + Widget build(BuildContext context, GoRouterState state) { + return CreateAccountScreen(); + } +} \ No newline at end of file diff --git a/lib/presentation/navigation/routes.g.dart b/lib/presentation/navigation/routes.g.dart index ebd1694f..9cf583e0 100644 --- a/lib/presentation/navigation/routes.g.dart +++ b/lib/presentation/navigation/routes.g.dart @@ -6,7 +6,7 @@ part of 'routes.dart'; // GoRouterGenerator // ************************************************************************** -List get $appRoutes => [$onBoardingRoute, $loginRoute, $mainRoute]; +List get $appRoutes => [$onBoardingRoute, $loginRoute, $mainRoute,$createAccountRoute]; RouteBase get $onBoardingRoute => GoRouteData.$route(path: '/', factory: $OnBoardingRoute._fromState); @@ -77,3 +77,27 @@ mixin $MainRoute on GoRouteData { @override void replace(BuildContext context) => context.replace(location); } + +RouteBase get $createAccountRoute => + GoRouteData.$route(path: '/createAccount', factory: $CreateAccountRoute._fromState); + +mixin $CreateAccountRoute on GoRouteData { + static $CreateAccountRoute _fromState(GoRouterState state) => + const CreateAccountRoute(); + + @override + String get location => GoRouteData.$location('/createAccount'); + + @override + void go(BuildContext context) => context.go(location); + + @override + Future push(BuildContext context) => context.push(location); + + @override + void pushReplacement(BuildContext context) => + context.pushReplacement(location); + + @override + void replace(BuildContext context) => context.replace(location); +} diff --git a/pubspec.yaml b/pubspec.yaml index 2daf09a2..e0a1cce3 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -36,6 +36,7 @@ dependencies: # --- Utils & Internationalization --- intl: any + uuid: ^4.5.1 dev_dependencies: flutter_test: