diff --git a/lib/core/di/cubit_di.dart b/lib/core/di/cubit_di.dart index dd5ad814..647c5465 100644 --- a/lib/core/di/cubit_di.dart +++ b/lib/core/di/cubit_di.dart @@ -6,6 +6,7 @@ import 'package:moneyplus/domain/repository/user_money_repository.dart'; import 'package:moneyplus/domain/validator/authentication_validator.dart'; import 'package:moneyplus/presentation/account_setup/cubit/account_setup_cubit.dart'; import 'package:moneyplus/presentation/createAccount/cubit/create_account_cubit.dart'; +import 'package:moneyplus/presentation/edit_salary/salary_settings_cubit.dart'; import 'package:moneyplus/presentation/home/cubit/home_cubit.dart'; import 'package:moneyplus/presentation/income/cubit/add_income_cubit.dart'; import 'package:moneyplus/presentation/login/cubit/login_cubit.dart'; @@ -43,6 +44,7 @@ void initCubitDI() { userMoneyRepository: getIt(), ), ); + getIt.registerFactory( () => AddIncomeCubit( transactionRepository: getIt(), @@ -71,7 +73,12 @@ void initCubitDI() { getIt(), ), ); + getIt.registerFactory( - () => AccountCubit(getIt()), + () => AccountCubit(getIt()), + ); + + getIt.registerFactory( + () => SalarySettingsCubit(userMoneyRepository: getIt()), ); } diff --git a/lib/core/l10n/app_ar.arb b/lib/core/l10n/app_ar.arb index 85fa1af5..6abf2f6a 100644 --- a/lib/core/l10n/app_ar.arb +++ b/lib/core/l10n/app_ar.arb @@ -156,6 +156,9 @@ "categoriesBreakdown": "تفاصيل الفئات", "no_monthly_breakdown": "لا يوجد نفقات لتحليلها بعد", "totalSpend": "إجمالي الإنفاق", + "salary_saved": "تم حفظ إعدادات الراتب بنجاح", + "failed_to_save_salary": "فشل في حفظ إعدادات الراتب", + "failed_to_load_salary_settings": "فشل في تحميل إعدادات الراتب", "cancel": "إلغاء", "english": "الإنجليزية", "arabic": "العربية", diff --git a/lib/core/l10n/app_en.arb b/lib/core/l10n/app_en.arb index 4d877f0a..a0805f02 100644 --- a/lib/core/l10n/app_en.arb +++ b/lib/core/l10n/app_en.arb @@ -174,6 +174,9 @@ "categoriesBreakdown": "Categories Breakdown", "no_monthly_breakdown": "No expenses to analyze yet", "totalSpend": "Total Spend", + "salary_saved": "Salary settings saved successfully", + "failed_to_save_salary": "Failed to save salary settings", + "failed_to_load_salary_settings": "Failed to load salary settings", "cancel": "Cancel", "english": "English", "arabic": "Arabic", diff --git a/lib/data/repository/user_money_repository.dart b/lib/data/repository/user_money_repository.dart index 4fab5672..244a9238 100644 --- a/lib/data/repository/user_money_repository.dart +++ b/lib/data/repository/user_money_repository.dart @@ -81,7 +81,7 @@ class UserRepositoryImpl implements UserMoneyRepository { } List _getTopSpendingCategoriesFromResponseRows( - List rows, + List rows, ) { return rows.map((row) { final data = row as Map; @@ -100,9 +100,9 @@ class UserRepositoryImpl implements UserMoneyRepository { @override Future getCurrency() async { - final client = await service.getClient(); - final response = await client.rpc('get_default_currency'); - return Currency.fromJson(response); + final client = await service.getClient(); + final response = await client.rpc('get_default_currency'); + return Currency.fromJson(response); } @override @@ -133,6 +133,35 @@ class UserRepositoryImpl implements UserMoneyRepository { return ((currentMonthBalance - previousMonthBalance) / previousMonthBalance) * 100; } + @override + Future getSalary() async { + final client = await service.getClient(); + final response = await client.from('users').select('salary_amount'); + final balance = (response.firstOrNull?['salary_amount'] as num?)?.toDouble() ?? 0.0; + return balance; + } + + @override + Future getSalaryDay() async { + final client = await service.getClient(); + final response = await client.from('users').select('salary_day'); + final balance = (response.firstOrNull?['salary_day'] as int?)?.toInt() ?? 0; + return balance; + } + + @override + Future updateSalarySettings({ + required double salary, + required int salaryDay, + }) async { + final client = await service.getClient(); + + await client + .from('users') + .update({'salary_amount': salary, 'salary_day': salaryDay}) + .eq('id', client.auth.currentUser!.id); + } + void _validateMonth(int month){ if(month < 1 || month > 12){ throw Exception('Month value: "$month" is not valid, Month must be between 1 and 12'); diff --git a/lib/domain/repository/user_money_repository.dart b/lib/domain/repository/user_money_repository.dart index 34c70c50..9efbe094 100644 --- a/lib/domain/repository/user_money_repository.dart +++ b/lib/domain/repository/user_money_repository.dart @@ -9,10 +9,22 @@ abstract class UserMoneyRepository { Future getMonthExpense(int month, int year); - Future> getTopSpendingCategoriesInMonth( - {required int month,required int year, required int count}); + Future> getTopSpendingCategoriesInMonth({ + required int month, + required int year, + required int count, + }); Future getCurrency(); Future getSavingSpendingPercentage(int month, int year); + + Future getSalary(); + + Future getSalaryDay(); + + Future updateSalarySettings({ + required double salary, + required int salaryDay, + }); } diff --git a/lib/presentation/accout/screen/account_screen.dart b/lib/presentation/accout/screen/account_screen.dart index 61c72e22..016e75d5 100644 --- a/lib/presentation/accout/screen/account_screen.dart +++ b/lib/presentation/accout/screen/account_screen.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:moneyplus/presentation/navigation/routes.dart'; import 'package:moneyplus/app_prefernces_cubit.dart'; import 'package:moneyplus/presentation/accout/widget/theme_selection_dialog.dart'; @@ -126,6 +127,9 @@ class AccountScreen extends StatelessWidget { context, title: l10n.salarySettings, iconPath: AppAssets.iconMoney, + onTap: () { + EditSalaryRoute().push(context); + }, ), accountSection( context, diff --git a/lib/presentation/edit_salary/salary_settings_cubit.dart b/lib/presentation/edit_salary/salary_settings_cubit.dart new file mode 100644 index 00000000..f1f0f36d --- /dev/null +++ b/lib/presentation/edit_salary/salary_settings_cubit.dart @@ -0,0 +1,83 @@ +import 'package:bloc/bloc.dart'; +import 'package:meta/meta.dart'; +import 'package:moneyplus/domain/repository/user_money_repository.dart'; +part 'salary_settings_state.dart'; + +class SalarySettingsCubit extends Cubit { + SalarySettingsCubit({required this.userMoneyRepository}) : super(SalarySettingsLoading()); + + UserMoneyRepository userMoneyRepository; + + void getData() async { + try{ + final salary = await userMoneyRepository.getSalary(); + final salaryDay = await userMoneyRepository.getSalaryDay(); + emit( + SalarySettingsLoaded( + salary: salary.toString(), + salaryDay: salaryDay.toString(), + isSaveButtonEnabled: false, + ), + ); + }catch(e){ + emit(SalarySettingsError(failure: SalarySettingsFailure.loadFailed)); + } + _setButtonVisibility(); + } + + void updateSalary(String salary) { + if (state is SalarySettingsLoaded) { + var currentState = state as SalarySettingsLoaded; + emit(currentState.copyWith(salary: salary)); + _setButtonVisibility(); + } + } + + void updateSalaryDay(String salaryDay) { + if (state is SalarySettingsLoaded) { + var currentState = state as SalarySettingsLoaded; + emit(currentState.copyWith(salaryDay: salaryDay)); + _setButtonVisibility(); + } + } + + Future saveChanges() async { + try{ + if (state is SalarySettingsLoaded) { + var currentState = state as SalarySettingsLoaded; + await userMoneyRepository.updateSalarySettings( + salary: double.parse(currentState.salary), + salaryDay: int.parse(currentState.salaryDay), + ); + } + return true; + }catch(e){ + return false; + } + + } + + + + void _setButtonVisibility() { + bool checkIfButtonShouldBeEnabled(SalarySettingsLoaded state) { + final salary = double.tryParse(state.salary); + if (salary == null) return false; + final salaryDay = int.tryParse(state.salaryDay); + if (salaryDay == null) return false; + if(salaryDay < 1 || salaryDay > 31) return false; + if(salary < 0) return false; + return true; + } + + if (state is SalarySettingsLoaded) { + var currentState = state as SalarySettingsLoaded; + final shouldBeEnabled = checkIfButtonShouldBeEnabled(currentState); + emit(currentState.copyWith(isButtonEnabled: shouldBeEnabled)); + } + } +} + +enum SalarySettingsFailure { + loadFailed, +} diff --git a/lib/presentation/edit_salary/salary_settings_screen.dart b/lib/presentation/edit_salary/salary_settings_screen.dart new file mode 100644 index 00000000..7b5afa75 --- /dev/null +++ b/lib/presentation/edit_salary/salary_settings_screen.dart @@ -0,0 +1,176 @@ +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/core/di/injection.dart'; +import 'package:moneyplus/design_system/assets/app_assets.dart'; +import 'package:moneyplus/design_system/theme/money_extension_context.dart'; +import 'package:moneyplus/design_system/widgets/app_bar.dart'; +import 'package:moneyplus/presentation/edit_salary/salary_settings_cubit.dart'; +import 'package:moneyplus/presentation/widgets/error_content.dart'; +import 'package:moneyplus/presentation/widgets/loading_indicator.dart'; +import 'package:svg_flutter/svg.dart'; + +import '../../design_system/widgets/buttons/button/default_button.dart'; +import '../../design_system/widgets/snack_bar.dart'; +import '../../design_system/widgets/text_field.dart'; + +class SalarySettingsScreen extends StatelessWidget { + const SalarySettingsScreen({super.key}); + + @override + Widget build(BuildContext context) { + final colors = context.colors; + final localization = context.localizations; + return Scaffold( + appBar: CustomAppBar( + title: localization.salarySettings, + leading: _appBarLeading(context), + backgroundColor: colors.surfaceLow, + ), + body: BlocProvider( + create: (context) => getIt()..getData(), + child: BlocBuilder( + builder: (context, state) { + var content = switch (state) { + SalarySettingsLoading() => LoadingIndicator(), + SalarySettingsLoaded() => _loadedContent(context, state), + SalarySettingsError() => errorContent(_getErrorMessage(state.failure, context)), + }; + return content; + }, + ), + ), + ); + } +} + +Widget _loadedContent(BuildContext context, SalarySettingsLoaded state) { + final colors = context.colors; + final cubit = context.read(); + + return Container( + color: colors.surface, + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + spacing:12, + children: [ + _salaryBox(context, state, cubit), + _salaryDayBox(context, state, cubit), + Spacer(), + _saveButton(context, state, cubit), + ], + ), + ), + ); +} + +Widget _salaryBox( + BuildContext context, + SalarySettingsLoaded state, + SalarySettingsCubit cubit, +) { + final localization = context.localizations; + return MTextField( + hint: localization.salary, + leading: Padding( + padding: const EdgeInsetsDirectional.only(top: 14, bottom: 14, end: 8), + child: SvgPicture.asset(AppAssets.iconMoney), + ), + keyboardType: TextInputType.number, + value: state.salary, + onChanged: (value) { + cubit.updateSalary(value); + }, + ); +} + +Widget _salaryDayBox( + BuildContext context, + SalarySettingsLoaded state, + SalarySettingsCubit cubit, +) { + final localization = context.localizations; + return MTextField( + hint: localization.salaryDay, + leading: Padding( + padding: const EdgeInsetsDirectional.only(top: 14, bottom: 14, end: 8), + child: SvgPicture.asset(AppAssets.iconCalender), + ), + trailing: Padding( + padding: const EdgeInsetsDirectional.only(top: 14, bottom: 14, end: 8), + child: Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(100), + color: context.colors.surface, + ), + child: Padding( + padding: const EdgeInsetsDirectional.symmetric( + vertical: 4, + horizontal: 8, + ), + child: Text( + localization.fromEachMonth, + style: context.typography.label.small.copyWith( + color: context.colors.body, + ), + ), + ), + ), + ), + keyboardType: TextInputType.number, + value: state.salaryDay, + onChanged: (value) { + cubit.updateSalaryDay(value); + }, + ); +} + +Widget _saveButton( + BuildContext context, + SalarySettingsLoaded state, + SalarySettingsCubit cubit, +) { + final localization = context.localizations; + return DefaultButton( + text: localization.save, + isEnabled: state.isSaveButtonEnabled, + onPressed: () { + cubit.saveChanges().then((success) { + if (success) { + MSnackBar.success( + message: localization.salary_saved, + title: localization.success, + ).showSnackBar(context: context); + context.pop(); + } else { + MSnackBar.error( + message: localization.failed_to_save_salary, + title: localization.error, + ).showSnackBar(context: context); + } + }); + }, + ); +} + +Widget _appBarLeading(BuildContext context) { + final colors = context.colors; + return GestureDetector( + onTap: () => context.pop(), + child: Container( + alignment: Alignment.center, + decoration: BoxDecoration(color: colors.surface, shape: BoxShape.circle), + child: SvgPicture.asset(AppAssets.icArrowLeft), + ), + ); +} + +String _getErrorMessage(SalarySettingsFailure failure, BuildContext context) { + final localization = context.localizations; + switch (failure) { + case SalarySettingsFailure.loadFailed: + return localization.failed_to_load_salary_settings; + } +} diff --git a/lib/presentation/edit_salary/salary_settings_state.dart b/lib/presentation/edit_salary/salary_settings_state.dart new file mode 100644 index 00000000..055e9aef --- /dev/null +++ b/lib/presentation/edit_salary/salary_settings_state.dart @@ -0,0 +1,36 @@ +part of 'salary_settings_cubit.dart'; + +@immutable +sealed class SalarySettingsState {} + +final class SalarySettingsLoading extends SalarySettingsState {} + +final class SalarySettingsLoaded extends SalarySettingsState { + final String salary; + final String salaryDay; + final bool isSaveButtonEnabled; + + SalarySettingsLoaded({ + required this.salary, + required this.salaryDay, + required this.isSaveButtonEnabled, + }); + + SalarySettingsLoaded copyWith({ + String? salary, + String? salaryDay, + bool? isButtonEnabled, + }) { + return SalarySettingsLoaded( + salary: salary ?? this.salary, + salaryDay: salaryDay ?? this.salaryDay, + isSaveButtonEnabled: isButtonEnabled ?? this.isSaveButtonEnabled, + ); + } +} + +final class SalarySettingsError extends SalarySettingsState { + final SalarySettingsFailure failure; + + SalarySettingsError({required this.failure}); +} diff --git a/lib/presentation/navigation/routes.dart b/lib/presentation/navigation/routes.dart index 114eb71f..08920b7d 100644 --- a/lib/presentation/navigation/routes.dart +++ b/lib/presentation/navigation/routes.dart @@ -2,6 +2,7 @@ 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/edit_salary/salary_settings_screen.dart'; import 'package:moneyplus/presentation/expense/screen/add_expense_screen.dart'; import 'package:moneyplus/presentation/login/screen/login_screen.dart'; import 'package:moneyplus/presentation/update_password/screen/update_password_screen.dart'; @@ -156,3 +157,14 @@ class AddExpenseRoute extends GoRouteData with $AddExpenseRoute { return const AddExpenseScreen(); } } + +@TypedGoRoute(path: '/edit-salary') +@immutable +class EditSalaryRoute extends GoRouteData with $EditSalaryRoute { + const EditSalaryRoute(); + + @override + Widget build(BuildContext context, GoRouterState state) { + return SalarySettingsScreen(); + } +} diff --git a/lib/presentation/widgets/error_content.dart b/lib/presentation/widgets/error_content.dart new file mode 100644 index 00000000..f4393b7d --- /dev/null +++ b/lib/presentation/widgets/error_content.dart @@ -0,0 +1,5 @@ +import 'package:flutter/material.dart'; + +Widget errorContent(String errorMsg) { + return Scaffold(body: Center(child: Text(errorMsg))); +} \ No newline at end of file diff --git a/lib/presentation/widgets/loading_indicator.dart b/lib/presentation/widgets/loading_indicator.dart new file mode 100644 index 00000000..f313ca61 --- /dev/null +++ b/lib/presentation/widgets/loading_indicator.dart @@ -0,0 +1,16 @@ +import 'package:flutter/material.dart'; +import 'package:moneyplus/design_system/theme/money_extension_context.dart'; +import '../../design_system/theme/money_colors.dart'; + +class LoadingIndicator extends StatelessWidget { + const LoadingIndicator({super.key}); + + @override + Widget build(BuildContext context) { + return Scaffold( + body: Center( + child: CircularProgressIndicator(color: context.colors.primary), + ), + ); + } +}